mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 08:17:52 +08:00
539 lines
22 KiB
Lua
539 lines
22 KiB
Lua
-- http.lua — 订阅下载(SSRF 防护、超时、大小限制)(纯 Lua)
|
||
-- luci-app-substore
|
||
|
||
local util = require("substore.util")
|
||
|
||
local M = {}
|
||
|
||
M.DEFAULT_MAX_SIZE = 10 * 1024 * 1024 -- 10MB
|
||
M.DEFAULT_TIMEOUT = 20
|
||
M.MAX_REDIRECTS = 4
|
||
|
||
-- ---------- 私网 / 保留地址判断 ----------
|
||
|
||
-- 单段数值:十进制 / 八进制(前导 0)/ 十六进制(0x 前缀)
|
||
-- 返回数值;不是数值写法返回 nil
|
||
local function numeral(seg)
|
||
if seg:match("^0[xX]%x+$") then return tonumber(seg:sub(3), 16) end
|
||
if seg:match("^0%d+$") then return tonumber(seg:sub(2), 8) end
|
||
if seg:match("^%d+$") then return tonumber(seg, 10) end
|
||
return nil
|
||
end
|
||
|
||
-- 把 inet_aton 接受的各种数值型 IPv4 写法归一为点分十进制。
|
||
-- curl / wget 都按 inet_aton 语义解析主机名,因此 SSRF 检查必须用同一套语义,
|
||
-- 否则 "0177.0.0.1"(八进制 127.0.0.1)、"2130706433"(十进制)、"0x7f000001"(十六进制)、
|
||
-- "127.1"(短写)会被 is_private_ipv4 当成公网地址而放行,实测这几种写法都能连到 127.0.0.1。
|
||
-- 段数与点号数必须一致(拒绝 "1..2" 这类畸形写法)。
|
||
-- 返回 "a.b.c.d";不是数值型 IPv4 时返回 nil。
|
||
local function normalize_ipv4(host)
|
||
local dots = select(2, host:gsub("%.", ""))
|
||
local parts = {}
|
||
for seg in host:gmatch("[^%.]+") do parts[#parts + 1] = seg end
|
||
if #parts ~= dots + 1 or #parts > 4 then return nil end
|
||
|
||
local vals = {}
|
||
for i, seg in ipairs(parts) do
|
||
local v = numeral(seg)
|
||
if not v then return nil end
|
||
vals[i] = v
|
||
end
|
||
|
||
-- inet_aton:前 k-1 段各 8 位,最后一段吃掉剩余位宽
|
||
local k = #vals
|
||
for i = 1, k - 1 do
|
||
if vals[i] > 255 then return nil end
|
||
end
|
||
if vals[k] >= 2 ^ (32 - 8 * (k - 1)) then return nil end
|
||
|
||
local n = 0
|
||
for i, v in ipairs(vals) do
|
||
local shift = (i < k) and (32 - 8 * i) or 0
|
||
n = n + v * 2 ^ shift
|
||
end
|
||
|
||
local o1 = math.floor(n / 2 ^ 24) % 256
|
||
local o2 = math.floor(n / 2 ^ 16) % 256
|
||
local o3 = math.floor(n / 2 ^ 8) % 256
|
||
local o4 = n % 256
|
||
return string.format("%d.%d.%d.%d", o1, o2, o3, o4)
|
||
end
|
||
|
||
local function is_private_ipv4(ip)
|
||
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
|
||
if not a then return false end
|
||
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
|
||
if not (a and b and c and d) then return true end
|
||
if a == 0 then return true end
|
||
if a == 10 then return true end
|
||
if a == 127 then return true end
|
||
if a == 100 and b >= 64 and b <= 127 then return true end -- 100.64/10 CGNAT
|
||
if a == 169 and b == 254 then return true end -- link-local
|
||
if a == 172 and b >= 16 and b <= 31 then return true end -- 172.16/12
|
||
if a == 192 and b == 168 then return true end -- 192.168/16
|
||
if a >= 224 then return true end -- multicast + reserved
|
||
return false
|
||
end
|
||
|
||
local function is_private_ipv6(ip)
|
||
if ip == "::" or ip == "::1" then return true end
|
||
local l = ip:lower()
|
||
if l:match("^0*:") and not l:match("^0*::?0*$") and l ~= "0" then return true end
|
||
if l:sub(1, 2) == "fc" or l:sub(1, 2) == "fd" then return true end -- ULA fc00/7
|
||
local ll = l:sub(1, 4)
|
||
if ll == "fe80" or ll == "fe81" or ll == "fe82" or ll == "fe83"
|
||
or ll == "fe84" or ll == "fe85" or ll == "fe86" or ll == "fe87"
|
||
or ll == "fe88" or ll == "fe89" or ll == "fe8a" or ll == "fe8b"
|
||
or ll == "fe8c" or ll == "fe8d" or ll == "fe8e" or ll == "fe8f"
|
||
or ll == "fe90" or ll == "fe91" or ll == "fe92" or ll == "fe93"
|
||
or ll == "fe94" or ll == "fe95" or ll == "fe96" or ll == "fe97"
|
||
or ll == "fe98" or ll == "fe99" or ll == "fe9a" or ll == "fe9b"
|
||
or ll == "fe9c" or ll == "fe9d" or ll == "fe9e" or ll == "fe9f"
|
||
or ll == "fea0" or ll == "fea1" or ll == "fea2" or ll == "fea3"
|
||
or ll == "fea4" or ll == "fea5" or ll == "fea6" or ll == "fea7"
|
||
or ll == "fea8" or ll == "fea9" or ll == "feaa" or ll == "feab"
|
||
or ll == "feac" or ll == "fead" or ll == "feae" or ll == "feaf"
|
||
or ll == "feb0" or ll == "feb1" or ll == "feb2" or ll == "feb3"
|
||
or ll == "feb4" or ll == "feb5" or ll == "feb6" or ll == "feb7"
|
||
or ll == "feb8" or ll == "feb9" or ll == "feba" or ll == "febb"
|
||
or ll == "febc" or ll == "febd" or ll == "febe" or ll == "febf" then
|
||
return true -- link-local fe80::/10
|
||
end
|
||
if l:sub(1, 2) == "ff" then return true end -- multicast
|
||
return false
|
||
end
|
||
|
||
local function is_private(ip)
|
||
ip = ip or ""
|
||
if ip:find(":", 1, true) then return is_private_ipv6(ip) end
|
||
return is_private_ipv4(ip)
|
||
end
|
||
|
||
-- 粗校验:像不像一个 IPv6 字面量。
|
||
-- 带 ":" 的主机并不都是 IPv6 —— parse_url 对 "127.0.0.1:" 这类「有冒号但端口为空」
|
||
-- 的写法取不到端口,会把整个 "127.0.0.1:" 当主机名返回;它既不是合法域名,
|
||
-- is_private_ipv6 也识别不出,于是被当成公网放行。实测 curl 会把它连到 127.0.0.1:80。
|
||
-- 因此:含 ":" 但不像 IPv6 的一律拒绝(fail-closed)。
|
||
local function looks_like_ipv6(h)
|
||
if not h:match("^[%x:%.]+$") then return false end
|
||
if select(2, h:gsub(":", "")) < 2 then return false end
|
||
if h:find("::.*::") then return false end -- "::" 最多出现一次
|
||
return true
|
||
end
|
||
|
||
-- ---------- URL 解析 ----------
|
||
function M.parse_url(url)
|
||
url = util.trim(url or "")
|
||
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
|
||
if not scheme then return nil, "无效 URL" end
|
||
scheme = scheme:lower()
|
||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
|
||
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
|
||
-- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open),
|
||
-- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。
|
||
local hostport = rest:match("^[^/%?#]*") or ""
|
||
-- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止,
|
||
-- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1"
|
||
-- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open),
|
||
-- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。
|
||
local at = hostport:find("@", 1, true)
|
||
while at do
|
||
hostport = hostport:sub(at + 1)
|
||
at = hostport:find("@", 1, true)
|
||
end
|
||
local host, port
|
||
if hostport:sub(1, 1) == "[" then
|
||
local close = hostport:find("]", 1, true)
|
||
if not close then return nil, "无效主机名" end
|
||
host = hostport:sub(2, close - 1)
|
||
port = hostport:sub(close + 1):match("^:(%d+)$") or (scheme == "https" and "443" or "80")
|
||
else
|
||
local h, p = hostport:match("^([^:]+):(%d+)$")
|
||
if h then
|
||
host, port = h, p
|
||
else
|
||
host, port = hostport, (scheme == "https" and "443" or "80")
|
||
end
|
||
end
|
||
if not host or host == "" then return nil, "无效主机名" end
|
||
-- 端口范围校验:`(%d+)` 只保证是数字,`http://host:99999/` 与 `:0` 都能通过,
|
||
-- 而这两个端口连不出去 —— 下载必然失败,却会先经过一轮 DNS/SSRF 检查,
|
||
-- 报出来的是「连接失败」这种指错方向的原因。parse_proxy 早已做了同样的校验,
|
||
-- 这里对齐。
|
||
local pn = tonumber(port)
|
||
if not pn or pn < 1 or pn > 65535 then return nil, "端口无效" end
|
||
return { scheme = scheme, host = host, port = tostring(pn) }
|
||
end
|
||
|
||
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
|
||
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
|
||
function M.parse_proxy(p)
|
||
local s = util.trim(p or "")
|
||
if s == "" then return "" end
|
||
local scheme, rest = s:match("^(%a[%w]*)://(.*)$")
|
||
if not scheme then return nil, "代理格式无效" end
|
||
scheme = scheme:lower()
|
||
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
|
||
and scheme ~= "socks5" and scheme ~= "socks5h" then
|
||
return nil, "不支持的代理协议: " .. scheme
|
||
end
|
||
local userinfo, hostport = "", rest:gsub("/.*$", "")
|
||
local at = hostport:find("@", 1, true)
|
||
if at then
|
||
userinfo = hostport:sub(1, at - 1)
|
||
hostport = hostport:sub(at + 1)
|
||
end
|
||
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
|
||
return nil, "代理用户名/密码含非法字符"
|
||
end
|
||
local host, port
|
||
local bracketed = hostport:sub(1, 1) == "["
|
||
if bracketed then
|
||
host = hostport:match("^%[([%w%.%-%:]+)%]")
|
||
port = hostport:match("^%[.*%]%:(%d+)$")
|
||
else
|
||
local h, prt = hostport:match("^([^:]+):(%d+)$")
|
||
if h then host, port = h, prt else host = hostport end
|
||
end
|
||
if not host or host == "" or not host:match("^[%w%.%-%:]+$") then
|
||
return nil, "代理主机无效"
|
||
end
|
||
-- 未加方括号的 IPv6 字面量:RFC 3986 要求 IPv6 主机必须写成 [addr]。
|
||
-- 没有方括号时 `::1:1080` 既可能是「地址 ::1 + 端口 1080」,也可能就是
|
||
-- 地址 `::1:1080` —— 无法判定。与其猜一个再拼出一条 curl -x 解析不了的
|
||
-- 代理串(静默失效),不如明确报错让用户补上方括号。
|
||
if not bracketed and select(2, host:gsub(":", "")) > 1 then
|
||
return nil, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080"
|
||
end
|
||
if port then
|
||
port = tonumber(port)
|
||
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
|
||
end
|
||
-- IPv6 字面量必须把方括号拼回去:上面为做主机校验把 [::1] 拆成了 ::1,
|
||
-- 直接拼会得到 `http://::1:1080` —— 冒号歧义,curl -x 与 http_proxy= 都
|
||
-- 解析不了(curl 会把 `::1:1080` 整个当成主机名),代理静默失效。
|
||
-- 单个冒号才是 host:port 的分隔符。
|
||
if host:find(":", 1, true) then host = "[" .. host .. "]" end
|
||
local r = scheme .. "://"
|
||
if userinfo ~= "" then r = r .. userinfo .. "@" end
|
||
r = r .. host
|
||
if port then r = r .. ":" .. port end
|
||
return r
|
||
end
|
||
|
||
-- 解析主机 → IP 列表;无法解析时返回 nil
|
||
local function resolve(host)
|
||
local nixio = util.try_require("nixio")
|
||
if not nixio then return nil end
|
||
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
|
||
if not ok or type(addrs) ~= "table" then return nil end
|
||
local ips = {}
|
||
for _, a in ipairs(addrs) do
|
||
if a and a.addr then ips[#ips + 1] = a.addr end
|
||
end
|
||
if #ips == 0 then return nil end
|
||
return ips
|
||
end
|
||
|
||
-- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点
|
||
local LOOPBACK_NAMES = {
|
||
["localhost"] = true,
|
||
["localhost.localdomain"] = true,
|
||
["localhost4"] = true,
|
||
["localhost4.localdomain4"] = true,
|
||
["localhost6"] = true,
|
||
["localhost6.localdomain6"] = true,
|
||
["ip6-localhost"] = true,
|
||
["ip6-loopback"] = true,
|
||
["ip6-localnet"] = true,
|
||
}
|
||
|
||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
|
||
function M.check_public(host)
|
||
host = util.trim(host or ""):lower()
|
||
if host == "" then return false, "空主机名" end
|
||
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
|
||
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
|
||
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
|
||
if host:find("@", 1, true) then return false, "主机名含非法字符 @" end
|
||
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
|
||
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
|
||
if host:find("[%s%%]") then return false, "主机名含非法字符" end
|
||
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
|
||
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
|
||
host = host:gsub("%.$", "")
|
||
if host == "" then return false, "空主机名" end
|
||
|
||
if host:find(":", 1, true) then
|
||
if not looks_like_ipv6(host) then return false, "无效主机名" end
|
||
if is_private_ipv6(host) then return false, "目标为内网/保留地址" end
|
||
return true
|
||
end
|
||
|
||
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
|
||
local norm = normalize_ipv4(host)
|
||
if norm then
|
||
if is_private_ipv4(norm) then return false, "目标为内网/保留地址" end
|
||
return true
|
||
end
|
||
|
||
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
|
||
|
||
local ips = resolve(host)
|
||
if not ips then
|
||
-- 解析不出 IP 一律拒绝(fail-closed)。
|
||
--
|
||
-- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个
|
||
-- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
|
||
-- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),
|
||
-- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1
|
||
-- 并连上去。检查的强度不能低于被检查者。
|
||
--
|
||
-- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
|
||
-- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下
|
||
-- curl 同样解析不了,下载本来就会失败,只是错误信息会变成
|
||
-- 「连接失败」这种指错方向的说法。
|
||
return false, "无法解析目标主机名"
|
||
end
|
||
for _, ip in ipairs(ips) do
|
||
if not is_private(ip) then return true end
|
||
end
|
||
return false, "目标仅解析到内网/保留地址"
|
||
end
|
||
|
||
-- ---------- 下载 ----------
|
||
local function have(cmd)
|
||
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
|
||
end
|
||
|
||
local function detect_tool()
|
||
if have("curl") then return "curl" end
|
||
if have("wget") then return "wget" end
|
||
return nil
|
||
end
|
||
|
||
local function location_from_headers(path)
|
||
local raw = util.read_file(path)
|
||
if not raw then return nil end
|
||
for line in raw:gmatch("[^\r\n]+") do
|
||
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
|
||
if v then return util.trim(v) end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- 读取响应头(curl -D 输出),返回小写键 → 值的表;重复键取最后一个
|
||
local function read_headers(path)
|
||
local h = {}
|
||
local raw = util.read_file(path)
|
||
if not raw then return h end
|
||
for line in raw:gmatch("[^\r\n]+") do
|
||
local k, v = line:match("^%s*([^:]+):%s*(.*)$")
|
||
if k then h[k:lower()] = v end
|
||
end
|
||
return h
|
||
end
|
||
|
||
local function resolve_url(base, loc)
|
||
if loc:find("://", 1, true) then return loc end
|
||
local scheme, host = base:match("^([%w]+)://([^/]+)")
|
||
if not scheme then return loc end
|
||
-- 协议相对地址(//host/path):沿用 scheme,但主机取自 Location 本身,
|
||
-- 否则会被误当成同主机的路径而漏掉跨主机跳转
|
||
if loc:sub(1, 2) == "//" then return scheme .. ":" .. loc end
|
||
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
|
||
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
|
||
local dir = base_path:match("^(.*)/[^/]*$") or ""
|
||
return scheme .. "://" .. host .. dir .. "/" .. loc
|
||
end
|
||
|
||
-- 从 wget -S 日志中按出现顺序提取重定向目标(Location)
|
||
local function locations_from_log(raw)
|
||
local locs = {}
|
||
for line in (raw or ""):gmatch("[^\r\n]+") do
|
||
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
|
||
if v then locs[#locs + 1] = util.trim(v) end
|
||
end
|
||
return locs
|
||
end
|
||
|
||
-- 复检重定向链:每一跳都必须通过 check_public。
|
||
-- busybox wget 没有 --max-redirect,无法在发出请求前拦住重定向,只能在 -S 日志里
|
||
-- 逐跳校验;任一跳不安全即整体失败并丢弃响应体(§14/§16)。
|
||
-- 纯函数,不触网,便于离线测试。返回 ok, err
|
||
function M.validate_redirect_chain(base_url, log)
|
||
for _, loc in ipairs(locations_from_log(log)) do
|
||
local next_url = resolve_url(base_url, loc)
|
||
local np = M.parse_url(next_url)
|
||
if not np then return false, "重定向目标无效: " .. tostring(loc) end
|
||
local ok, re = M.check_public(np.host)
|
||
if not ok then return false, "重定向目标不安全: " .. (re or "") end
|
||
end
|
||
return true
|
||
end
|
||
|
||
-- 代理地址去凭据,用于日志(§39:不记录代理密码)
|
||
function M.redact_proxy(p)
|
||
local scheme, rest = tostring(p or ""):match("^(%a[%w]*)://(.*)$")
|
||
if not scheme then return "***" end
|
||
return scheme .. "://" .. (rest:gsub("^[^@]*@", ""))
|
||
end
|
||
|
||
-- 抹掉文本中的 URL 凭据(//user:pass@),用于日志与错误信息(§39)
|
||
function M.scrub_credentials(text)
|
||
return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@"))
|
||
end
|
||
|
||
local function fetch_curl(url, parsed, opts)
|
||
local max, t = opts.max_size, opts.timeout
|
||
local proxy_arg = ""
|
||
if opts.proxy and opts.proxy ~= "" then
|
||
proxy_arg = " -x " .. util.shq(opts.proxy)
|
||
end
|
||
-- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发,
|
||
-- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。
|
||
local tag = util.rnd_hex(8)
|
||
local cur = url
|
||
for redirect = 0, M.MAX_REDIRECTS do
|
||
local tmp = "/tmp/substore_dl_" .. tag .. "_" .. redirect .. ".tmp"
|
||
local hdr = tmp .. ".hdr"
|
||
local errf = tmp .. ".err"
|
||
-- 清理必须在**每一条**退出路径上执行。
|
||
-- 此前只在「本轮开始」和「2xx 成功」两处 os.remove:失败路径(curl 报错、
|
||
-- 超限、重定向无 Location / 目标不安全、HTTP 4xx-5xx、重定向次数过多)
|
||
-- 都把文件留在 /tmp。而 OpenWrt 的 /tmp 是 tmpfs —— 占的是内存;
|
||
-- 订阅更新失败(cron 定时重试)会一轮轮往内存里堆 .tmp/.hdr/.err,
|
||
-- 其中 .tmp 可能是部分下载的响应体,最大到 max_size。
|
||
local function cleanup()
|
||
os.remove(tmp); os.remove(hdr); os.remove(errf)
|
||
end
|
||
cleanup()
|
||
local cmd = string.format(
|
||
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s",
|
||
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
|
||
local p = io.popen(cmd)
|
||
local code = p and p:read("*a") or ""
|
||
if p then p:close() end
|
||
code = util.trim(code)
|
||
if code == "" or code == "000" then
|
||
local msg = util.trim(util.read_file(errf) or "下载失败")
|
||
cleanup()
|
||
return nil, msg
|
||
end
|
||
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
|
||
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
|
||
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
|
||
if code:match("^2%d%d$") then
|
||
local size = util.file_size(tmp)
|
||
if size > max then
|
||
cleanup()
|
||
return nil, "响应超过大小限制 (" .. max .. " 字节)"
|
||
end
|
||
local content = util.read_file(tmp)
|
||
local headers = read_headers(hdr)
|
||
cleanup()
|
||
if not content then return nil, "读取响应失败" end
|
||
return content, headers
|
||
end
|
||
if code:match("^3%d%d$") then
|
||
local loc = location_from_headers(hdr)
|
||
if not loc then
|
||
cleanup()
|
||
return nil, "重定向无 Location"
|
||
end
|
||
local next_url = resolve_url(cur, loc)
|
||
local np = M.parse_url(next_url)
|
||
if not np then
|
||
cleanup()
|
||
return nil, "重定向目标无效"
|
||
end
|
||
local ok, re = M.check_public(np.host)
|
||
if not ok then
|
||
cleanup()
|
||
return nil, "重定向目标不安全: " .. (re or "")
|
||
end
|
||
cleanup()
|
||
cur = next_url
|
||
else
|
||
cleanup()
|
||
return nil, "HTTP 错误 " .. code
|
||
end
|
||
end
|
||
return nil, "重定向次数过多"
|
||
end
|
||
|
||
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
|
||
-- 使用 http(s) 代理;遇到它不支持的协议(socks*)必须明确报错,
|
||
-- 不能丢掉代理静默直连(§12)。返回 env 或 nil, err
|
||
function M.wget_proxy_env(proxy)
|
||
if proxy == nil or proxy == "" then return "" end
|
||
local scheme = tostring(proxy):match("^(%a[%w]*)://")
|
||
scheme = scheme and scheme:lower() or ""
|
||
if scheme ~= "http" and scheme ~= "https" then
|
||
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
|
||
" 代理,请安装 curl 或改用 http 代理"
|
||
end
|
||
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(proxy) .. " "
|
||
end
|
||
|
||
local function fetch_wget(url, parsed, opts)
|
||
local max, t = opts.max_size, opts.timeout
|
||
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
|
||
if not proxy_env then return nil, perr end
|
||
local tag = util.rnd_hex(8)
|
||
local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp"
|
||
local log = tmp .. ".log"
|
||
os.remove(tmp); os.remove(log)
|
||
-- -S 打印响应头(含整条重定向链),据此复检 SSRF;
|
||
-- 日志与响应体分流:体写 tmp,链写 log
|
||
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1",
|
||
t, util.shq(tmp), util.shq(url), util.shq(log))
|
||
local rc = os.execute(cmd)
|
||
-- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0
|
||
-- 会把残缺内容当成下载成功。
|
||
-- Lua 5.1 的 os.execute 返回数字退出码,5.2+ 返回 true/nil, "exit", code,两者都兼容。
|
||
local exit_ok = (rc == true) or (type(rc) == "number" and rc == 0)
|
||
local logtext = util.read_file(log) or ""
|
||
os.remove(log)
|
||
local safe, reason = M.validate_redirect_chain(url, logtext)
|
||
if not safe then
|
||
os.remove(tmp)
|
||
return nil, reason
|
||
end
|
||
local size = util.file_size(tmp)
|
||
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||
if not exit_ok then
|
||
os.remove(tmp)
|
||
return nil, "下载失败(wget 退出码非 0)"
|
||
end
|
||
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
|
||
local content = util.read_file(tmp)
|
||
os.remove(tmp)
|
||
if not content then return nil, "读取响应失败" end
|
||
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
|
||
end
|
||
|
||
local function fetch(tool, url, parsed, opts)
|
||
if tool == "curl" then return fetch_curl(url, parsed, opts) end
|
||
return fetch_wget(url, parsed, opts)
|
||
end
|
||
|
||
-- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err
|
||
-- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验
|
||
function M.download(url, opts)
|
||
opts = opts or {}
|
||
local max_size = opts.max_size or M.DEFAULT_MAX_SIZE
|
||
local timeout = opts.timeout or M.DEFAULT_TIMEOUT
|
||
local parsed = M.parse_url(url)
|
||
if not parsed then return nil, nil, "无效 URL" end
|
||
local ok, reason = M.check_public(parsed.host)
|
||
if not ok then return nil, nil, reason end
|
||
local tool = detect_tool()
|
||
if not tool then return nil, nil, "无可用下载工具 (curl/wget)" end
|
||
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy })
|
||
if not body then return nil, nil, headers end
|
||
return body, headers or {}, nil
|
||
end
|
||
|
||
return M |