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:
Irohoho
2026-09-26 19:15:47 +02:00
committed by GitHub
parent a0477f0d0b
commit 900514c2b3
6 changed files with 533 additions and 38 deletions
@@ -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
@@ -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
+1 -1
View File
@@ -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,