mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(koreader): respect KOReader global HTTP proxy settings (#1562)
* fix(koreader): respect KOReader global HTTP proxy settings Allow KOReader's Network -> Proxy settings (stored in G_reader_settings) to be respected for both JSON API requests and binary downloads. Also support explicit proxy in BookOrbitApi options, and route Tailscale tailnet addresses (100.x.y.z and *.ts.net) through the local userspace HTTP proxy (127.0.0.1:1056) when running on e-ink devices lacking TUN. Fixes #753 * fix(koreader): address review feedback on host parsing and HTTPS proxy safety * fix(koreader): simplify getProxy to respect KOReader proxy settings without vendor fallbacks * fix: tunnel KOReader HTTPS through configured HTTP proxies Route HTTPS requests through CONNECT so proxy-only servers stay reachable without exposing BookOrbit credentials to the proxy. Restrict redirects to the original host and allow secure upgrades for API calls and downloads. Bump the plugin version so installed clients discover the fix. --------- Co-authored-by: Andrej <acikarski14@gmail.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -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,8 +285,15 @@ function BookOrbitApi:requestBlocking(method, path, body, extra_headers)
|
||||
request.headers["Content-Length"] = #body_json
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
|
||||
local code, _, status = socket.skip(1, http.request(request))
|
||||
local code, headers, status = socket.skip(1, sendHttpRequest(request))
|
||||
socketutil:reset_timeout()
|
||||
|
||||
if type(code) ~= "number" then
|
||||
@@ -232,18 +301,25 @@ function BookOrbitApi:requestBlocking(method, path, body, extra_headers)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
return nil, "too_many_redirects"
|
||||
end
|
||||
|
||||
-- True when this call may fork its own background worker: the client is in
|
||||
-- background mode, a Trapper coroutine is driving us, and no enclosing
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user