diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua index f2671ad09..b133e7429 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua @@ -16,6 +16,7 @@ local util = require("util") local TransferPolicy = require("bookorbit_transfer_policy") local TransferProgress = require("bookorbit_transfer_progress") +local Proxy = require("bookorbit_proxy") local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit local MATCH_HASH_LENGTH = 32 @@ -136,6 +137,12 @@ local function absoluteUrl(base, location) if location:match("^https?://") then return location end local parsed = parseHttpUrl(base) if not parsed then return nil end + if location:sub(1, 1) == "?" then + return base:gsub("[?#].*$", "") .. location + end + if location:sub(1, 1) == "#" then + return base:gsub("#.*$", "") .. location + end if location:sub(1, 2) == "//" then return parsed.scheme .. ":" .. location end @@ -145,6 +152,18 @@ local function absoluteUrl(base, location) return directory .. "/" .. location end +local function safeRequestRedirect(initial_url, current_url, location) + if type(location) ~= "string" or location == "" then return nil end + local resolved = absoluteUrl(current_url, location:gsub("%s", "")) + local initial = parseHttpUrl(initial_url) + local current = parseHttpUrl(current_url) + local target = parseHttpUrl(resolved) + if not initial or not current or not target or target.host ~= initial.host then return nil end + if target.scheme == current.scheme and target.port == current.port then return resolved end + if current.scheme == "http" and target.scheme == "https" then return resolved end + return nil +end + local function forkSafeServerUrl(value) local scheme, authority, suffix = tostring(value or ""):match("^(https?)://([^/%?#]+)(.*)$") if scheme ~= "http" or not authority then return value end @@ -176,6 +195,7 @@ function BookOrbitApi.normalizeServerUrl(input) end function BookOrbitApi.new(opts) + opts = opts or {} return setmetatable({ server_url = forkSafeServerUrl(opts.server_url), username = opts.username, @@ -184,9 +204,51 @@ function BookOrbitApi.new(opts) device_model = opts.device_model, plugin_version = opts.plugin_version, background_requests = opts.background_requests == true, + proxy = opts.proxy, }, BookOrbitApi) end +-- Resolves the HTTP proxy for a target URL. +-- Prefers an explicit proxy option, then KOReader's enabled global HTTP proxy. +function BookOrbitApi:getProxy(target_url) + local url = (target_url and tostring(target_url):match("^https?://")) and tostring(target_url) or self.server_url + local parsed = parseHttpUrl(url) + if not parsed then return nil end + + if self.proxy and self.proxy ~= "" then + return self.proxy + end + + if G_reader_settings and G_reader_settings:isTrue("http_proxy_enabled") then + local p = G_reader_settings:readSetting("http_proxy") + if p and p ~= "" then + return p + end + end + + return nil +end + +local function sendHttpRequest(request) + if not request.url:lower():match("^https://") then + return http.request(request) + end + + local transport_request = {} + for key, value in pairs(request) do transport_request[key] = value end + if request.proxy then + transport_request.create = Proxy.connectSocket(request.proxy) + transport_request.proxy = nil + end + -- LuaSocket's global proxy overrides a custom HTTPS socket unless cleared. + local global_proxy = http.PROXY + http.PROXY = nil + local ok, result, code, headers, status = pcall(http.request, transport_request) + http.PROXY = global_proxy + if not ok then return nil, tostring(result) end + return result, code, headers, status +end + function BookOrbitApi:isConfigured() return self.server_url ~= nil and self.username ~= nil and self.userkey ~= nil end @@ -194,11 +256,11 @@ end -- Returns decoded_body on success, or nil, err_code, decoded_error_body. -- err_code is a number for HTTP errors and a string for transport errors. function BookOrbitApi:requestBlocking(method, path, body, extra_headers) - local sink = {} + local initial_url = self.server_url .. path local request = { - url = self.server_url .. path, + url = initial_url, method = method, - sink = ltn12.sink.table(sink), + redirect = false, headers = { ["accept"] = "application/json", ["x-auth-user"] = self.username, @@ -223,26 +285,40 @@ function BookOrbitApi:requestBlocking(method, path, body, extra_headers) request.headers["Content-Length"] = #body_json end - socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) - local code, _, status = socket.skip(1, http.request(request)) - socketutil:reset_timeout() + local current_url = initial_url + for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do + local sink = {} + request.url = current_url + request.sink = ltn12.sink.table(sink) + request.proxy = self:getProxy(current_url) - if type(code) ~= "number" then - logger.dbg("BookOrbit: network error:", status or code) - return nil, tostring(status or code or "network_error") + socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) + local code, headers, status = socket.skip(1, sendHttpRequest(request)) + socketutil:reset_timeout() + + if type(code) ~= "number" then + logger.dbg("BookOrbit: network error:", status or code) + return nil, tostring(status or code or "network_error") + end + + if not request.source and (method == "GET" or method == "HEAD") + and (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then + if redirect_count >= MAX_DOWNLOAD_REDIRECTS then + return nil, "too_many_redirects" + end + local location = headers and (headers.location or headers.Location) + local redirected = safeRequestRedirect(initial_url, current_url, location) + if not redirected then return nil, "unsafe_redirect" end + current_url = redirected + else + local decoded, decode_err = decodeResponse(sink) + if code < 200 or code >= 300 then return nil, code, decoded end + if decode_err then return nil, decode_err end + return decoded or {} + end end - local decoded, decode_err = decodeResponse(sink) - - if code < 200 or code >= 300 then - return nil, code, decoded - end - - if decode_err then - return nil, decode_err - end - - return decoded or {} + return nil, "too_many_redirects" end -- True when this call may fork its own background worker: the client is in @@ -342,8 +418,8 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts) generation = opts.progress_generation, total = tonumber(opts.expected_bytes) or 0, }) - local current_url = self.server_url .. path - local origin = parseHttpUrl(current_url) + local initial_url = self.server_url .. path + local current_url = initial_url local total_received = 0 local function fail(out, err) @@ -352,19 +428,6 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts) return nil, err end - local function sameOriginRedirect(location) - if type(location) ~= "string" or location == "" then return nil end - local resolved = absoluteUrl(current_url, location:gsub("%s", "")) - local parsed = parseHttpUrl(resolved) - if not origin or not parsed - or parsed.scheme ~= origin.scheme - or parsed.host ~= origin.host - or parsed.port ~= origin.port then - return nil - end - return resolved - end - for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do local out, open_err = io.open(temp_path, "w") if not out then @@ -401,10 +464,12 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts) }, } + request.proxy = self:getProxy(current_url) + socketutil:set_timeout( opts.block_timeout or socketutil.FILE_BLOCK_TIMEOUT, opts.total_timeout or socketutil.FILE_TOTAL_TIMEOUT) - local code, headers, status = socket.skip(1, http.request(request)) + local code, headers, status = socket.skip(1, sendHttpRequest(request)) socketutil:reset_timeout() if too_large then return fail(out, "response_too_large") end @@ -419,7 +484,7 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts) return nil, "too_many_redirects" end local location = headers and (headers.location or headers.Location) - local redirected = sameOriginRedirect(location) + local redirected = safeRequestRedirect(initial_url, current_url, location) if not redirected then return nil, "unsafe_redirect" end current_url = redirected else diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_proxy.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_proxy.lua new file mode 100644 index 000000000..bffdd3a77 --- /dev/null +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_proxy.lua @@ -0,0 +1,118 @@ +local Proxy = {} + +local function proxyConfig(proxy_url) + if type(proxy_url) ~= "string" or proxy_url:find("[\r\n]") then return nil end + local url = require("socket.url") + local parsed = url.parse(proxy_url) + local port = parsed and tonumber(parsed.port or 3128) + if not parsed or parsed.scheme ~= "http" or not parsed.host + or not port or port < 1 or port > 65535 or port ~= math.floor(port) then + return nil + end + + local authorization = "" + if parsed.user and parsed.password then + local user = url.unescape(parsed.user) + local password = url.unescape(parsed.password) + if user:find("[\r\n]") or password:find("[\r\n]") then return nil end + authorization = "Proxy-Authorization: Basic " + .. require("mime").b64(user .. ":" .. password) .. "\r\n" + end + + return { + host = parsed.host:match("^%[(.*)%]$") or parsed.host, + port = port, + authorization = authorization, + } +end + +local function readLine(sock, remaining) + local chars = {} + for _ = 1, remaining do + local char, err = sock:receive(1) + if not char then return nil, err end + if char == "\n" then + if chars[#chars] == "\r" then chars[#chars] = nil end + return table.concat(chars) + end + chars[#chars + 1] = char + end + return nil, "proxy_response_too_large" +end + +function Proxy.connectSocket(proxy_url) + return function() + local config = proxyConfig(proxy_url) + if not config then error("invalid_http_proxy") end + + local socket = require("socket") + local conn = { sock = assert(socket.tcp()) } + function conn:settimeout(...) + return self.sock:settimeout(...) + end + function conn:close() + return self.sock:close() + end + function conn:connect(host, port) + local target_port = tonumber(port) + if type(host) ~= "string" or host:find("[%c%s]") + or not target_port or target_port < 1 or target_port > 65535 + or target_port ~= math.floor(target_port) then + return nil, "invalid_https_target" + end + local connected, connect_err = self.sock:connect(config.host, config.port) + if not connected then return nil, connect_err end + local tls_host = host:match("^%[(.*)%]$") or host + local authority = tls_host:find(":", 1, true) + and "[" .. tls_host .. "]:" .. tostring(target_port) + or tls_host .. ":" .. tostring(target_port) + local sent, send_err = self.sock:send("CONNECT " .. authority .. " HTTP/1.1\r\nHost: " + .. authority .. "\r\n" .. config.authorization .. "\r\n") + if not sent then return nil, send_err end + + local status, status_err = readLine(self.sock, 8192) + if not status then return nil, status_err end + if status:match("^HTTP/%d+%.%d+%s+(%d%d%d)") ~= "200" then + return nil, "proxy_connect_failed" + end + local header_bytes = #status + 2 + local headers_complete = false + for _ = 1, 64 do + local line, line_err = readLine(self.sock, 8192 - header_bytes) + if not line then return nil, line_err end + header_bytes = header_bytes + #line + 2 + if header_bytes > 8192 then return nil, "proxy_response_too_large" end + if line == "" then + headers_complete = true + break + end + end + if not headers_complete then return nil, "proxy_response_too_large" end + + -- Keep the same TLS policy as KOReader's LuaSec HTTPS transport. + local tls, tls_err = require("ssl").wrap(self.sock, { + protocol = "any", + options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1" }, + verify = "none", + mode = "client", + }) + if not tls then return nil, tls_err end + tls:sni(tls_host) + tls:settimeout(require("ssl.https").TIMEOUT) + self.sock = tls + local handshook, handshake_err = tls:dohandshake() + if not handshook then return nil, handshake_err end + for name, method in pairs(getmetatable(tls).__index) do + if type(method) == "function" then + self[name] = function(_, ...) + return method(self.sock, ...) + end + end + end + return 1 + end + return conn + end +end + +return Proxy diff --git a/koreader-plugin/bookorbit.koplugin/main.lua b/koreader-plugin/bookorbit.koplugin/main.lua index 2d8391aa9..69752d8c0 100644 --- a/koreader-plugin/bookorbit.koplugin/main.lua +++ b/koreader-plugin/bookorbit.koplugin/main.lua @@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync") local BookOrbitSweep = require("bookorbit_sweep") local BookOrbitUpdater = require("bookorbit_updater") -local PLUGIN_VERSION = "1.5.4" +local PLUGIN_VERSION = "1.5.5" local SYNC_STRATEGY = { PROMPT = 1, diff --git a/koreader-plugin/spec/bookorbit_api_download_test.lua b/koreader-plugin/spec/bookorbit_api_download_test.lua index 1ef2ca1eb..2716ae9f0 100644 --- a/koreader-plugin/spec/bookorbit_api_download_test.lua +++ b/koreader-plugin/spec/bookorbit_api_download_test.lua @@ -9,9 +9,11 @@ local response = { on_chunk = nil, } local requests = {} +local last_global_proxy package.loaded["socket.http"] = { request = function(request) + last_global_proxy = package.loaded["socket.http"].PROXY table.insert(requests, request) local active = response if response.queue and #response.queue > 0 then @@ -335,6 +337,63 @@ assertEqual(cancelled, nil, "dismissed download fails") assertEqual(cancel_err, "cancelled", "dismissed download reports cancellation") assertEqual(exists(final_path), false, "dismissed download publishes no file") +local mock_http = package.loaded["socket.http"] +mock_http.PROXY = "http://127.0.0.1:8080" +assertEqual(api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true, + "HTTPS download succeeds without the global HTTP proxy") +assertEqual(last_global_proxy, nil, "HTTPS download clears LuaSocket's global proxy") +assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", + "HTTPS download restores LuaSocket's global proxy") +os.remove(final_path) +mock_http.PROXY = nil + +local proxied_https_api = BookOrbitApi.new{ + server_url = "https://books.example.com/api/v1", + username = "reader", + userkey = "key", + proxy = "http://127.0.0.1:8080", +} +assertEqual(proxied_https_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true, + "HTTPS download succeeds with a CONNECT proxy") +assertEqual(requests[#requests].proxy, nil, "HTTPS download keeps the proxy URL out of LuaSocket") +assertEqual(type(requests[#requests].create), "function", "HTTPS download uses a CONNECT socket") +os.remove(final_path) + +G_reader_settings = { + isTrue = function(_, key) return key == "http_proxy_enabled" end, + readSetting = function(_, key) + if key == "http_proxy" then return "http://127.0.0.1:8080" end + end, +} +local http_api = BookOrbitApi.new{ + server_url = "http://books.example.com/api/v1", + username = "reader", + userkey = "key", +} +assertEqual(http_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true, + "HTTP download succeeds with KOReader's configured proxy") +assertEqual(requests[#requests].proxy, "http://127.0.0.1:8080", + "HTTP download passes the configured proxy to LuaSocket") +os.remove(final_path) + +mock_http.PROXY = "http://127.0.0.1:8080" +response.queue = { + { code = 301, headers = { location = "https://books.example.com/api/v1/file" } }, + { code = 200, headers = { ["content-type"] = "image/jpeg" }, chunks = { "upgraded image" } }, +} +assertEqual(http_api:downloadBlocking("/thumb", final_path, { temp_path = temp_path }), true, + "HTTP download follows a same-host HTTPS upgrade through CONNECT") +assertEqual(requests[#requests - 1].proxy, "http://127.0.0.1:8080", + "HTTP download starts through the configured proxy") +assertEqual(requests[#requests].proxy, nil, "HTTPS upgrade keeps proxy URL out of LuaSocket") +assertEqual(type(requests[#requests].create), "function", "HTTPS upgrade uses a CONNECT socket") +assertEqual(last_global_proxy, nil, "HTTPS upgrade clears LuaSocket's global proxy") +assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", "HTTPS upgrade restores the global proxy") +os.remove(final_path) +response.queue = nil +mock_http.PROXY = nil +G_reader_settings = nil + os.execute("rm -rf '" .. temp_root .. "'") print("bookorbit_api_download_test.lua: ok") diff --git a/koreader-plugin/spec/bookorbit_api_test.lua b/koreader-plugin/spec/bookorbit_api_test.lua index fc96f4369..c99026a12 100644 --- a/koreader-plugin/spec/bookorbit_api_test.lua +++ b/koreader-plugin/spec/bookorbit_api_test.lua @@ -71,13 +71,33 @@ package.loaded["ltn12"] = { local last_request_url local last_request_headers +local last_request_proxy +local last_global_proxy +local mock_http_error = false +local mock_http_queue +local request_history = {} local request_count = 0 package.loaded["socket.http"] = { request = function(request) + last_global_proxy = package.loaded["socket.http"].PROXY + if mock_http_error then error("simulated transport failure") end request_count = request_count + 1 request_ran_in_subprocess = in_subprocess last_request_url = request.url last_request_headers = request.headers + last_request_proxy = request.proxy + table.insert(request_history, { + url = request.url, + proxy = request.proxy, + global_proxy = last_global_proxy, + redirect = request.redirect, + create = request.create, + }) + if mock_http_queue then + local response = table.remove(mock_http_queue, 1) + if response.body then request.sink(response.body) end + return 1, response.code, response.headers or {}, "HTTP " .. tostring(response.code) + end if mock_http_body then request.sink(mock_http_body) end @@ -321,6 +341,7 @@ local background_identity_client = BookOrbitApi.new{ background_requests = true, } body, err = background_identity_client:getPluginVersion() +assertEqual(body ~= nil, true, "background update check has a response body") assertEqual(err, nil, "background update check succeeds") assertEqual(subprocess_calls, 2, "background update check uses subprocess") assertEqual(last_request_headers["x-bookorbit-device-id"], "background-device", @@ -343,7 +364,138 @@ wrapped = false request_ran_in_subprocess = false body, err = background_client:auth() assertEqual(body.ok, true, "unwrapped request falls back safely") +assertEqual(err, nil, "unwrapped request succeeds") assertEqual(subprocess_calls, 4, "unwrapped request does not start subprocess") assertEqual(request_ran_in_subprocess, false, "unwrapped fallback runs in current process") +-- Proxy resolution tests +assertEqual(client:getProxy(), nil, "default client has no proxy") + +local explicit_proxy_client = BookOrbitApi.new{ + server_url = "http://bookorbit.example.com/api/v1", + username = "reader", + userkey = "secret", + proxy = "http://127.0.0.1:8080", +} +assertEqual(explicit_proxy_client:getProxy(), "http://127.0.0.1:8080", + "explicit proxy is returned for HTTP server") + +mock_http_body = "{\"ok\":true}" +mock_http_code = 200 +explicit_proxy_client:auth() +assertEqual(last_request_proxy, "http://127.0.0.1:8080", + "explicit proxy is passed to request table for HTTP server") + +local https_explicit_proxy_client = BookOrbitApi.new{ + server_url = "https://bookorbit.example.com/api/v1", + proxy = "http://127.0.0.1:8080", +} +assertEqual(https_explicit_proxy_client:getProxy(), "http://127.0.0.1:8080", + "explicit proxy is returned for HTTPS server") + +local mock_http = package.loaded["socket.http"] +mock_http.PROXY = "http://127.0.0.1:8080" +https_explicit_proxy_client:auth() +assertEqual(last_global_proxy, nil, "HTTPS request clears LuaSocket's global proxy") +assertEqual(last_request_proxy, nil, "HTTPS request does not send a proxy URL to LuaSocket") +assertEqual(type(request_history[#request_history].create), "function", + "HTTPS request uses a CONNECT socket") +assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", "HTTPS request restores the global proxy") + +mock_http_error = true +local _, transport_error = https_explicit_proxy_client:auth() +assertEqual(transport_error:find("simulated transport failure", 1, true) ~= nil, true, + "HTTPS transport failure is reported") +assertEqual(mock_http.PROXY, "http://127.0.0.1:8080", + "HTTPS transport failure restores the global proxy") +mock_http_error = false +mock_http.PROXY = nil + +mock_http.PROXY = "http://127.0.0.1:8080" +mock_http_queue = { + { code = 302, headers = { location = "https://attacker.example.com/api/v1" } }, +} +local before_redirect = #request_history +local _, unsafe_redirect = https_explicit_proxy_client:auth() +assertEqual(unsafe_redirect, "unsafe_redirect", "cross-host API redirect is rejected") +assertEqual(#request_history, before_redirect + 1, "cross-host API redirect sends one request") +assertEqual(request_history[#request_history].redirect, false, + "LuaSocket cannot automatically forward API credentials") +assertEqual(request_history[#request_history].global_proxy, nil, + "HTTPS redirect response cannot use the global HTTP proxy") +mock_http_queue = { + { code = 302, headers = { location = "/api/v1/redirected" } }, + { code = 200, body = "{\"ok\":true}" }, +} +local same_origin_body, same_origin_error = https_explicit_proxy_client:auth() +assertEqual(same_origin_error, nil, "same-origin API redirect succeeds") +assertEqual(same_origin_body.ok, true, "same-origin API redirect decodes the final body") +assertEqual(request_history[#request_history].url, + "https://bookorbit.example.com/api/v1/redirected", "same-origin redirect uses the new URL") +assertEqual(request_history[#request_history].global_proxy, nil, + "same-origin HTTPS redirect stays off the global HTTP proxy") +mock_http_queue = { + { code = 302, headers = { location = "?refresh=1" } }, + { code = 200, body = "{\"ok\":true}" }, +} +https_explicit_proxy_client:auth() +assertEqual(request_history[#request_history].url, + "https://bookorbit.example.com/api/v1/koreader/users/auth?refresh=1", + "query-only redirect preserves the original path") +mock_http_queue = nil +mock_http.PROXY = nil + +-- KOReader global settings proxy test +local http_client = BookOrbitApi.new{ + server_url = "http://bookorbit.example.com/api/v1", + username = "reader", + userkey = "secret", +} + +local mock_settings = { + http_proxy_enabled = true, + http_proxy = "http://192.168.1.1:3128", +} +G_reader_settings = { + isTrue = function(self, key) return mock_settings[key] == true end, + readSetting = function(self, key) return mock_settings[key] end, +} + +assertEqual(http_client:getProxy(), "http://192.168.1.1:3128", + "G_reader_settings proxy is used when enabled for HTTP server") + +http_client:auth() +assertEqual(last_request_proxy, "http://192.168.1.1:3128", + "G_reader_settings proxy is passed to request table") +assertEqual(last_global_proxy, nil, "HTTP requests do not change LuaSocket's global proxy") + +mock_http.PROXY = "http://192.168.1.1:3128" +mock_http_queue = { + { code = 301, headers = { location = "https://bookorbit.example.com/api/v1/redirected" } }, + { code = 200, body = "{\"ok\":true}" }, +} +local upgraded_body, upgraded_error = http_client:auth() +assertEqual(upgraded_error, nil, "same-host HTTPS upgrade succeeds") +assertEqual(upgraded_body.ok, true, "same-host HTTPS upgrade decodes the final body") +assertEqual(request_history[#request_history - 1].proxy, "http://192.168.1.1:3128", + "HTTP upgrade starts through the configured proxy") +assertEqual(request_history[#request_history - 1].global_proxy, "http://192.168.1.1:3128", + "HTTP upgrade leaves KOReader's global proxy intact") +assertEqual(request_history[#request_history].proxy, nil, + "HTTPS upgrade keeps the proxy URL out of LuaSocket's request") +assertEqual(type(request_history[#request_history].create), "function", + "HTTPS upgrade uses a CONNECT socket") +assertEqual(request_history[#request_history].global_proxy, nil, + "HTTPS upgrade clears LuaSocket's global HTTP proxy") +assertEqual(mock_http.PROXY, "http://192.168.1.1:3128", + "HTTPS upgrade restores LuaSocket's global HTTP proxy") +mock_http_queue = nil +mock_http.PROXY = nil + +mock_settings.http_proxy_enabled = false +assertEqual(http_client:getProxy(), nil, + "G_reader_settings proxy is ignored when disabled") + +G_reader_settings = nil + print("bookorbit_api_test.lua: ok") diff --git a/koreader-plugin/spec/bookorbit_proxy_test.lua b/koreader-plugin/spec/bookorbit_proxy_test.lua new file mode 100644 index 000000000..7e942a117 --- /dev/null +++ b/koreader-plugin/spec/bookorbit_proxy_test.lua @@ -0,0 +1,101 @@ +local url = { + parse = function(value) + local scheme, rest = value:match("^(%w+)://(.+)$") + if not scheme then return nil end + local credentials = rest:match("^([^@]+)@") + if credentials then rest = rest:sub(#credentials + 2) end + local host, port = rest:match("^([^:]+):(%d+)$") + return { + scheme = scheme, + host = host, + port = port, + user = credentials and credentials:match("^([^:]+):"), + password = credentials and credentials:match(":(.+)$"), + } + end, + unescape = function(value) return value end, +} +local mime = { b64 = function() return "encoded-credentials" end } + +local response +local raw +local tls +package.loaded["socket"] = { + tcp = function() + raw = { sent = "", position = 1 } + function raw:settimeout(value) + self.timeout = value + return 1 + end + function raw:connect(host, port) + self.host, self.port = host, port + return 1 + end + function raw:send(value) + self.sent = self.sent .. value + return #value + end + function raw:receive() + if self.position > #response then return nil, "closed" end + local char = response:sub(self.position, self.position) + self.position = self.position + 1 + return char + end + function raw:close() return 1 end + return raw + end, +} +package.loaded["socket.url"] = url +package.loaded["mime"] = mime +package.loaded["ssl.https"] = { TIMEOUT = 10 } +package.loaded["ssl"] = { + wrap = function() + tls = setmetatable({}, { __index = { + sni = function(self, host) self.host = host end, + settimeout = function(self, value) self.timeout = value end, + dohandshake = function() return 1 end, + send = function() return 1 end, + receive = function() return "ok" end, + close = function() return 1 end, + } }) + return tls + end, +} + +package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path +local Proxy = require("bookorbit_proxy") + +local function assertEqual(actual, expected, label) + if actual ~= expected then error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) end +end + +response = "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n" +local conn = Proxy.connectSocket("http://reader:password@127.0.0.1:3128")() +conn:settimeout(10) +assertEqual(conn:connect("books.example", 443), 1, "CONNECT succeeds") +assertEqual(raw.host, "127.0.0.1", "connects to proxy host") +assertEqual(raw.port, 3128, "connects to proxy port") +assertEqual(raw.sent:match("^CONNECT books%.example:443 HTTP/1%.1"), + "CONNECT books.example:443 HTTP/1.1", "sends tunnel request") +assertEqual(raw.sent:find("Proxy-Authorization: Basic ", 1, true) ~= nil, true, + "sends proxy credentials only in tunnel request") +assertEqual(raw.sent:find("x-auth-key", 1, true), nil, "never sends BookOrbit credentials in CONNECT") +assertEqual(tls.host, "books.example", "uses target host for TLS SNI") +assertEqual(tls.timeout, 10, "sets TLS timeout") +assertEqual(conn:receive(), "ok", "forwards socket methods to TLS") + +response = "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n" +conn = Proxy.connectSocket("http://127.0.0.1:3128")() +local success, err = conn:connect("books.example", 443) +assertEqual(success, nil, "rejects failed CONNECT") +assertEqual(err, "proxy_connect_failed", "reports failed CONNECT") +assertEqual(raw.sent:find("Proxy-Authorization", 1, true), nil, + "does not invent a proxy authorization header") + +response = "HTTP/1.1 200 Connection Established\r\nX-Large: " .. string.rep("x", 8192) +conn = Proxy.connectSocket("http://127.0.0.1:3128")() +success, err = conn:connect("books.example", 443) +assertEqual(success, nil, "rejects oversized proxy response") +assertEqual(err, "proxy_response_too_large", "bounds proxy headers") + +print("bookorbit_proxy_test.lua: ok")