Files
bookorbit/koreader-plugin/spec/bookorbit_api_download_test.lua
Irohoho 900514c2b3 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>
2026-09-26 11:15:47 -06:00

400 lines
15 KiB
Lua

-- Proves BookOrbitApi transfers files through a subprocess-capable path that
-- publishes atomically, enforces response bounds and never lets two nested
-- workers own the same request.
local response = {
code = 200,
headers = { ["content-type"] = "image/jpeg" },
chunks = { "JFIF-part-one", "JFIF-part-two" },
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
active = table.remove(response.queue, 1)
end
for _, chunk in ipairs(active.chunks or {}) do
if response.on_chunk then response.on_chunk(chunk) end
local ok, err = request.sink(chunk)
if not ok then return nil, err end
end
request.sink(nil)
return 1, active.code, active.headers, active.status or "OK"
end,
}
package.loaded["ltn12"] = {
sink = {
file = function(handle)
return function(chunk, err)
if err then return nil, err end
if not chunk then
handle:close()
return 1
end
if chunk ~= "" then handle:write(chunk) end
return 1
end
end,
table = function(parts)
return function(chunk)
if chunk and chunk ~= "" then table.insert(parts, chunk) end
return 1
end
end,
},
source = {
string = function(value)
return value
end,
},
}
local encoded_subprocess_result
package.loaded["rapidjson"] = {
null = setmetatable({}, { __tostring = function() return "null" end }),
encode = function(value)
if type(value) == "table"
and (value.body ~= nil or value.err ~= nil or value.errbody ~= nil) then
encoded_subprocess_result = value
return "__subprocess_result__"
end
return "{}"
end,
decode = function(raw)
if raw == "__subprocess_result__" then return encoded_subprocess_result end
return { raw = raw }
end,
}
package.loaded["socket"] = {
skip = function(count, ...)
return select(count + 1, ...)
end,
}
package.loaded["socketutil"] = {
LARGE_BLOCK_TIMEOUT = 10,
LARGE_TOTAL_TIMEOUT = 30,
FILE_BLOCK_TIMEOUT = 15,
FILE_TOTAL_TIMEOUT = 60,
set_timeout = function() end,
reset_timeout = function() end,
}
package.loaded["logger"] = {
dbg = function() end,
warn = function() end,
}
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
urlEncode = function(value)
return tostring(value)
end,
removeFile = function(path)
os.remove(path)
end,
partialMD5 = function(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local content = handle:read("*a")
handle:close()
return "md5-" .. tostring(#content)
end,
}
local forks = 0
local wrapped = false
package.loaded["ui/trapper"] = {
isWrapped = function()
return wrapped
end,
dismissableRunInSubprocess = function(_, task, _, task_returns_simple_string)
if task_returns_simple_string ~= true then
error("subprocess result must use the string envelope")
end
forks = forks + 1
return true, task()
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitApi = require("bookorbit_api")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function readFile(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local content = handle:read("*a")
handle:close()
return content
end
local function exists(path)
local handle = io.open(path, "rb")
if not handle then return false end
handle:close()
return true
end
local temp_root = os.tmpname()
os.remove(temp_root)
assert(os.execute("mkdir -p '" .. temp_root .. "'") == 0)
local final_path = temp_root .. "/cover.jpg"
local temp_path = temp_root .. "/cover.part"
local api = BookOrbitApi.new{
server_url = "https://books.example.com/api/v1",
username = "reader",
userkey = "key",
background_requests = true,
}
-- A complete response publishes the final file and leaves no temporary behind.
response.on_chunk = function()
assert(not exists(final_path), "destination must not exist while the transfer is in flight")
end
local ok = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
response.on_chunk = nil
assertEqual(ok, true, "successful download reports ok")
assertEqual(readFile(final_path), "JFIF-part-oneJFIF-part-two", "published file holds the whole response")
assertEqual(exists(temp_path), false, "temporary file is consumed by the publish")
os.remove(final_path)
-- A server error publishes nothing and cleans up its temporary file.
response.code = 404
local failed, err = api:downloadBlocking("/thumb", final_path, { temp_path = temp_path })
assertEqual(failed, nil, "http error reports failure")
assertEqual(err, 404, "http error surfaces the status code")
assertEqual(exists(final_path), false, "http error publishes no file")
assertEqual(exists(temp_path), false, "http error removes the temporary file")
response.code = 200
-- An oversized body is cut off before it can be published.
local too_large, size_err = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
max_bytes = 4,
})
assertEqual(too_large, nil, "oversized response fails")
assertEqual(size_err, "response_too_large", "oversized response reports why")
assertEqual(exists(final_path), false, "oversized response publishes no file")
assertEqual(exists(temp_path), false, "oversized response removes the temporary file")
-- A wrong content type never reaches the cover cache.
response.headers = { ["content-type"] = "text/html" }
local wrong_type, type_err = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
assertEqual(wrong_type, nil, "unexpected content type fails")
assertEqual(type_err, "unexpected_content_type", "unexpected content type reports why")
assertEqual(exists(final_path), false, "unexpected content type publishes no file")
assertEqual(exists(temp_path), false, "unexpected content type removes the temporary file")
response.headers = { ["content-type"] = "image/jpeg" }
-- Same-origin redirects are followed explicitly without giving LuaSocket a
-- chance to forward credentials to an arbitrary host.
requests = {}
response.queue = {
{
code = 302,
headers = { location = "/api/v1/redirected" },
chunks = { "redirect body" },
},
{
code = 200,
headers = { ["content-type"] = "image/jpeg" },
chunks = { "redirected image" },
},
}
local redirected = api:downloadBlocking("/thumb", final_path, {
temp_path = temp_path,
expect_content_type = "image/",
})
assertEqual(redirected, true, "same-origin redirect succeeds")
assertEqual(#requests, 2, "same-origin redirect performs a bounded second request")
assertEqual(requests[1].redirect, false, "LuaSocket automatic redirects are disabled")
assertEqual(readFile(final_path), "redirected image", "redirect response body is not published")
os.remove(final_path)
response.queue = nil
-- Cross-origin redirects are rejected before credentials reach the target.
requests = {}
response.queue = {
{
code = 302,
headers = { location = "https://attacker.example/cover" },
chunks = {},
},
}
local unsafe, redirect_err = api:downloadBlocking("/thumb", final_path, { temp_path = temp_path })
assertEqual(unsafe, nil, "cross-origin redirect fails")
assertEqual(redirect_err, "unsafe_redirect", "cross-origin redirect reports why")
assertEqual(#requests, 1, "cross-origin target is never requested")
assertEqual(exists(final_path), false, "unsafe redirect publishes nothing")
response.queue = nil
-- Outside a Trapper coroutine nothing forks; the call stays blocking.
wrapped = false
forks = 0
assertEqual(api:canForkSubprocess(), false, "no fork without a Trapper coroutine")
api:download("/thumb", final_path, { temp_path = temp_path })
assertEqual(forks, 0, "unwrapped download does not fork")
os.remove(final_path)
-- Inside one, a cover transfer runs in exactly one subprocess.
wrapped = true
forks = 0
assertEqual(api:download("/thumb", final_path, { temp_path = temp_path }), true, "wrapped download succeeds")
assertEqual(forks, 1, "wrapped download forks exactly one worker")
os.remove(final_path)
-- A caller that needs live byte progress cannot use a child, so it stays on
-- the blocking path rather than silently losing its callback.
forks = 0
local progressed = 0
api:download("/thumb", final_path, {
temp_path = temp_path,
progress_cb = function(received)
progressed = received
end,
})
assertEqual(forks, 0, "progress-reporting download does not fork")
assertEqual(progressed, 26, "progress callback still runs")
os.remove(final_path)
-- A background transfer that publishes progress through a snapshot file keeps
-- its worker: the snapshot, not an in-process callback, is the channel back.
local TransferProgress = require("bookorbit_transfer_progress")
local progress_path = temp_root .. "/cover.progress"
forks = 0
assertEqual(api:download("/thumb", final_path, {
temp_path = temp_path,
progress_path = progress_path,
progress_generation = 7,
expected_bytes = 26,
}), true, "snapshot-reporting download succeeds")
assertEqual(forks, 1, "snapshot-reporting download still forks a worker")
local snapshot = TransferProgress.read(progress_path, { generation = 7 })
assertEqual(snapshot ~= nil, true, "the child published a progress snapshot")
assertEqual(snapshot.done, true, "completion is published")
assertEqual(snapshot.received, 26, "the snapshot carries the transferred byte count")
assertEqual(TransferProgress.read(progress_path, { generation = 8 }), nil, "another generation ignores the snapshot")
TransferProgress.cleanup(progress_path)
os.remove(final_path)
-- Parent-owned publishing hands the complete temporary file back instead of
-- renaming it, so a cancelled generation cannot be published by a late child.
forks = 0
local handed_back = api:download("/thumb", final_path, {
temp_path = temp_path,
publish = "parent",
hash = "partial_md5",
})
assertEqual(type(handed_back), "table", "parent publishing returns the transfer result")
assertEqual(handed_back.temp_path, temp_path, "the complete temporary file is handed back")
assertEqual(handed_back.bytes, 26, "the transferred byte count is reported")
assertEqual(handed_back.hash, "md5-26", "the child hashes the file it just wrote")
assertEqual(exists(final_path), false, "parent publishing never renames in the child")
assertEqual(exists(temp_path), true, "the temporary file survives for the parent to publish")
os.remove(temp_path)
-- An owned subprocess covers everything inside it: nested requests run inline
-- in the child instead of forking a second worker.
forks = 0
local completed, result = api:runInSubprocess(function()
api:request("GET", "/koreader/plugin/version")
api:request("GET", "/koreader/plugin/catalog/dashboard")
api:download("/thumb", final_path, { temp_path = temp_path })
return "done"
end)
assertEqual(completed, true, "owned subprocess completes")
assertEqual(result.body, "done", "owned subprocess returns its result")
assertEqual(forks, 1, "one worker owns every call inside the boundary")
assertEqual(api.subprocess_depth, 0, "ownership depth unwinds")
os.remove(final_path)
-- A dismissed request reports cancellation and publishes nothing.
package.loaded["ui/trapper"].dismissableRunInSubprocess = function()
forks = forks + 1
return false
end
forks = 0
local cancelled, cancel_err = api:download("/thumb", final_path, { temp_path = temp_path })
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")