Files

687 lines
30 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- http.lua — 订阅下载(SSRF 防护、超时、大小限制)(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local msg = require("substore.msg")
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, "Invalid URL" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return nil, "Only http/https is supported" end
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
-- 数值型 IPv4,DNS 必然解析不出来,能不能拦住就全看本机有没有解析能力
-- (见 check_public 的 fail-open 分支)—— 不能指望;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:它不是合法域名,本机有解析能力时会被
-- 判为「无法解析」而拒绝,但本机没有解析能力时是放行的 —— 不能指望
-- 那一层兜住。而 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, "Invalid hostname" 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, "Invalid hostname" 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, "Invalid port" end
return { scheme = scheme, host = host, port = tostring(pn) }
end
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
-- 部分机场按 User-Agent 区分客户端:同一个订单链接,只有用「该订单绑定的客户端」
-- 的 UA 去请求才返回真实节点,其余 UA 得到一段占位内容(见 README「订阅客户端类型」)。
-- 这里只做「能不能安全地放进命令行」的校验,不判断 UA 是否符合某家客户端。
--
-- 拒绝控制字符:UA 最终会以 `-A <ua>` 的形式进入 shell 命令,util.shq 的单引号
-- 能挡住注入,但挡不住 UA 里的换行 —— curl 会把它当成**响应头之外的**新请求头
-- 拼进去(header injection),且日志里也会被换行截断、伪造出额外行。
-- 长度上限 256:真实 UA 都在 100 字符内,超长的只可能是构造出来的。
function M.validate_user_agent(ua)
if ua == nil then return "" end
local s = util.trim(tostring(ua))
if s == "" then return "" end
if #s > 256 then return nil, "User-Agent is too long (at most 256 characters)" end
if s:find("%c") then return nil, "User-Agent must not contain control characters" end
return s
end
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, "Invalid proxy format" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
and scheme ~= "socks5" and scheme ~= "socks5h" then
return nil, msg.join("Unsupported proxy protocol: ", 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, "Proxy username/password contains invalid characters"
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, "Invalid proxy host"
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, "An IPv6 proxy address must be bracketed, e.g. http://[::1]:1080"
end
if port then
port = tonumber(port)
if not port or port < 1 or port > 65535 then return nil, "Invalid proxy port" 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
-- ---------- 主机名解析 ----------
-- 解析手段按代价逐级回退:nixio(进程内,不 fork)→ busybox nslookup(fork 一次)。
local function have(cmd)
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
end
-- busybox nslookup 的输出形如:
-- Server: 192.168.1.1
-- Address: 192.168.1.1:53
--
-- Name: example.com
-- Address: 93.184.216.34
-- Address: 2606:2800:220:1:248:1893:25c8:1946
-- 只取 "Name:" 之后的 "Address:" 行:前面那两行是 **DNS 服务器自己**的地址
-- (路由器上通常就是 192.168.x.x),当成解析结果会让每个域名都被判成内网而全部误拒。
-- 解析失败(NXDOMAIN / 超时)时 busybox 只往 stderr 写错误,stdout 里没有 Name: 段,
-- ips 为空 → 返回 nil,与「解析失败」语义一致。
local function resolve_via_nslookup(host)
local p = io.popen("nslookup " .. util.shq(host) .. " 2>/dev/null")
if not p then return nil end
local out = p:read("*a") or ""
p:close()
local ips, in_answer = {}, false
for line in out:gmatch("[^\r\n]+") do
if line:match("^%s*Name%s*:") then
in_answer = true
elseif in_answer then
local a = line:match("^%s*Address%s*:%s*(%S+)")
if a then
a = a:match("^([^%%]+)") or a -- 去掉 IPv6 的 %zone 后缀
if a ~= "" then ips[#ips + 1] = a end
end
end
end
if #ips == 0 then return nil end
return ips
end
-- 解析主机 → IP 列表。返回 ips, have_resolver
-- ips 解析结果;解析不出来为 nil
-- have_resolver 本机是否存在**可用**的解析手段
-- 第二个返回值必须与第一个分开:「解析失败」和「本机根本没有解析能力」是两回事,
-- 处置方式完全相反(见 check_public),合并成一个 nil 会得出错误结论。
local function resolve(host)
local have_resolver = false
local nixio = util.try_require("nixio")
if nixio then
have_resolver = true
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
if ok and type(addrs) == "table" then
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 ips, true end
end
end
if have("nslookup") then
have_resolver = true
local ips = resolve_via_nslookup(host)
if ips then return ips, true end
end
return nil, have_resolver
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, unverified
-- unverified 为 true 表示「本机没有任何 DNS 解析手段,这次放行没有经过解析校验」。
-- 调用方(下载层)据此在连接建立后强制复核对端地址,见 verify_peer_ip。
function M.check_public(host)
host = util.trim(host or ""):lower()
if host == "" then return false, "Empty hostname" end
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
if host:find("@", 1, true) then return false, "Hostname contains the invalid character @" end
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
if host:find("[%s%%]") then return false, "Hostname contains invalid characters" end
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
host = host:gsub("%.$", "")
if host == "" then return false, "Empty hostname" end
if host:find(":", 1, true) then
if not looks_like_ipv6(host) then return false, "Invalid hostname" end
if is_private_ipv6(host) then return false, "Target is a private/reserved address" end
return true
end
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
local norm = normalize_ipv4(host)
if norm then
if is_private_ipv4(norm) then return false, "Target is a private/reserved address" end
return true
end
if LOOPBACK_NAMES[host] then return false, "Target is localhost" end
local ips, have_resolver = resolve(host)
if not ips then
if have_resolver then
-- 本机有解析手段却解析不出来 → 域名确实不存在(NXDOMAIN)。
-- 拒绝(fail-closed)。
--
-- 不能在这里放行:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
-- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面
-- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
-- 检查的强度不能低于被检查者。
return false, "Cannot resolve the target hostname"
end
-- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。
--
-- 这种情形**不能**按「解析失败」处理:[2.6.8-r1] 把两者合并成了同一个
-- nil,于是这种设备上**所有**域名订阅都被拒,报「无法解析目标主机名」——
-- 而 curl/wget 自带 libc 解析器,照样能解析并下载,2.6.7-r1 实测正常。
-- 「本机没装 luci-lib-nixio」不等于「这个域名解析不出来」,两者被混为一谈
-- 就是那次回归的根因。
--
-- 放行,改由下载层在**连接建立之后**用 curl 回报的 %{remote_ip} 复核
-- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP,
-- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的
-- DNS rebinding。
return true, "No local DNS resolution; the peer address will be verified at connect time", true
end
for _, ip in ipairs(ips) do
if not is_private(ip) then return true end
end
return false, "Target resolves only to private/reserved addresses"
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, msg.join("Invalid redirect target: ", loc) end
local ok, re = M.check_public(np.host)
if not ok then return false, msg.join("Unsafe redirect target: ", 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
-- 连接建立后复核**实际**对端地址,返回 ok, err。
--
-- check_public 是预检,它解析的是「此刻」的 DNS;curl 稍后会自己再解析一次,
-- 两次结果未必相同(DNS rebinding、TTL 过期、多 A 记录轮询、本机无解析手段时的
-- 直接放行)。curl 的 %{remote_ip} 回报的是真正建立连接的那个 IP ——
-- 用它复核,SSRF 防护才不依赖「预检那一刻的 DNS 恰好和连接时一致」。
--
-- 走代理时 %{remote_ip} 是代理的地址(多半就在内网),必须跳过:代理是用户
-- 自己配置的,不属于 SSRF 防护要拦的目标。
local function verify_peer_ip(ip, proxy, unverified)
if proxy and proxy ~= "" then return true end
if not ip or ip == "" then
-- curl 没回报对端地址(连接没建立,或 curl < 7.29 不支持该变量)。
if unverified then
-- 预检时本机就没有解析能力(check_public 放行了),现在连对端地址
-- 也拿不到 —— 这个请求从头到尾没有任何一处校验过目标,
-- 必须拒绝,否则「无法校验」就等于「放行」。
return false, "Cannot verify the target address (no local DNS resolution and no peer IP obtained)"
end
-- 有解析能力时预检已经查过一轮,这里无从复核不额外拒绝。
return true
end
if is_private(ip) then
return false, msg.compose("Target actually connects to a private/reserved address (", ip, ")")
end
return true
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
-- 订阅客户端类型(User-Agent)。空 = 不传,curl 用自带的 curl/x.y.z。
-- 必须在**每一跳**都带上:重定向后的目标同样按 UA 决定返回什么内容。
local ua_arg = ""
if opts.user_agent and opts.user_agent ~= "" then
ua_arg = " -A " .. util.shq(opts.user_agent)
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()
-- -w 同时取 http_code 与 remote_ip:后者是真正建立连接的对端地址,
-- 用来在拿到响应体之前复核目标(见 verify_peer_ip)。
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} %%{remote_ip}\"%s%s %s 2>%s",
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, ua_arg, util.shq(cur), util.shq(errf))
local p = io.popen(cmd)
local raw = util.trim(p and p:read("*a") or "")
if p then p:close() end
-- 正常是 "200 93.184.216.34";连接没建立时 curl 只回 "000"(没有 IP)。
local code, peer = raw:match("^(%d+)%s*(%S*)$")
if not code then code = raw end
if code == "" or code == "000" then
local errtext = util.trim(util.read_file(errf) or "Download failed")
cleanup()
return nil, errtext
end
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
if code:match("^2%d%d$") then
local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified)
if not pok then
cleanup()
return nil, perr
end
local size = util.file_size(tmp)
if size > max then
cleanup()
return nil, msg.compose("Response exceeds the size limit (", max, " bytes)")
end
local content = util.read_file(tmp)
local headers = read_headers(hdr)
cleanup()
if not content then return nil, "Failed to read the response" end
return content, headers
end
if code:match("^3%d%d$") then
-- 重定向的**发起方**也要复核:否则一个公网域名可以先 302 到
-- 127.0.0.1 并在第一跳就连上内网服务(Location 检查只能拦住
-- 第二跳的目标,拦不住第一跳本身)。
local pok, perr = verify_peer_ip(peer, opts.proxy, opts.unverified)
if not pok then
cleanup()
return nil, perr
end
local loc = location_from_headers(hdr)
if not loc then
cleanup()
return nil, "Redirect without a Location header"
end
local next_url = resolve_url(cur, loc)
local np = M.parse_url(next_url)
if not np then
cleanup()
return nil, "Invalid redirect target"
end
local ok, re = M.check_public(np.host)
if not ok then
cleanup()
return nil, msg.join("Unsafe redirect target: ", re or "")
end
cleanup()
cur = next_url
else
cleanup()
return nil, msg.join("HTTP error ", code)
end
end
return nil, "Too many redirects"
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, msg.compose("The current download backend (wget) does not support ", string.upper(scheme),
" proxies; install curl or use an http proxy")
end
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(proxy) .. " "
end
local function fetch_wget(url, parsed, opts)
-- 本机没有 DNS 解析能力时,check_public 只能 fail-open 放行,把校验推迟到
-- 「连接建立后复核对端地址」。curl 后端有 %{remote_ip} 可用(见 verify_peer_ip),
-- wget 后端**没有任何等价物**:拿不到对端地址,busybox wget 也没有可用的
-- 重定向拦截(重定向链只能事后从 -S 日志里看,拦不住已经发出去的请求)。
-- 于是在这条路径上,「预检放行」之后不存在任何一处校验 —— 等于没有 SSRF 防护。
-- 与 verify_peer_ip 的处置保持一致:校验不了就拒绝,而不是放行。
if opts.unverified then
return nil, "No local DNS resolution: the wget backend cannot verify the target address and refused the download (install curl and retry)"
end
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
-- busybox wget 用 -U 设置 User-Agent(已核对 busybox 1.37 的 --help)。
local ua_arg = ""
if opts.user_agent and opts.user_agent ~= "" then
ua_arg = " -U " .. util.shq(opts.user_agent)
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%s -O %s %s >%s 2>&1",
t, ua_arg, 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, msg.compose("Response exceeds the size limit (", max, " bytes)") end
if not exit_ok then
os.remove(tmp)
return nil, "Download failed (wget exit code is non-zero)"
end
if size == 0 then os.remove(tmp); return nil, "Download failed or the content is empty" end
local content = util.read_file(tmp)
os.remove(tmp)
if not content then return nil, "Failed to read the response" 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 校验
-- opts.user_agent 为可选的订阅客户端 User-Agent,应由调用方用 validate_user_agent 校验
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, "Invalid URL" end
local ua, uaerr = M.validate_user_agent(opts.user_agent)
if not ua then return nil, nil, uaerr end
local ok, reason, unverified = 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, "No download tool available (curl/wget)" end
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy,
user_agent = ua, unverified = unverified })
if not body then return nil, nil, headers end
return body, headers or {}, nil
end
return M