mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-10 08:17:53 +08:00
🦄 Sync 2026-10-02 14:52:05
This commit is contained in:
1 parent
4c85793342
commit
0bfe84f1e8
133 files changed
+7983
-3225
No files matched your search
@@ -130,13 +130,15 @@ function M.parse_url(url)
|
||||
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)。
|
||||
-- 数值型 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,DNS 解析失败即放行(fail-open),
|
||||
-- 而 curl 实际连的是 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)
|
||||
@@ -168,6 +170,23 @@ 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 过长(最多 256 字符)" end
|
||||
if s:find("%c") then return nil, "User-Agent 不能包含控制字符" end
|
||||
return s
|
||||
end
|
||||
|
||||
function M.parse_proxy(p)
|
||||
local s = util.trim(p or "")
|
||||
if s == "" then return "" end
|
||||
@@ -222,20 +241,71 @@ function M.parse_proxy(p)
|
||||
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
|
||||
-- ---------- 主机名解析 ----------
|
||||
-- 解析手段按代价逐级回退: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,
|
||||
@@ -249,7 +319,9 @@ local LOOPBACK_NAMES = {
|
||||
["ip6-localnet"] = true,
|
||||
}
|
||||
|
||||
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
|
||||
-- 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, "空主机名" end
|
||||
@@ -280,21 +352,31 @@ function M.check_public(host)
|
||||
|
||||
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
|
||||
|
||||
local ips = resolve(host)
|
||||
local ips, have_resolver = resolve(host)
|
||||
if not ips then
|
||||
-- 解析不出 IP 一律拒绝(fail-closed)。
|
||||
if have_resolver then
|
||||
-- 本机有解析手段却解析不出来 → 域名确实不存在(NXDOMAIN)。
|
||||
-- 拒绝(fail-closed)。
|
||||
--
|
||||
-- 不能在这里放行:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
|
||||
-- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面
|
||||
-- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
|
||||
-- 检查的强度不能低于被检查者。
|
||||
return false, "无法解析目标主机名"
|
||||
end
|
||||
-- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。
|
||||
--
|
||||
-- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个
|
||||
-- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
|
||||
-- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),
|
||||
-- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1
|
||||
-- 并连上去。检查的强度不能低于被检查者。
|
||||
-- 这种情形**不能**按「解析失败」处理:[2.6.8-r1] 把两者合并成了同一个
|
||||
-- nil,于是这种设备上**所有**域名订阅都被拒,报「无法解析目标主机名」——
|
||||
-- 而 curl/wget 自带 libc 解析器,照样能解析并下载,2.6.7-r1 实测正常。
|
||||
-- 「本机没装 luci-lib-nixio」不等于「这个域名解析不出来」,两者被混为一谈
|
||||
-- 就是那次回归的根因。
|
||||
--
|
||||
-- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
|
||||
-- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下
|
||||
-- curl 同样解析不了,下载本来就会失败,只是错误信息会变成
|
||||
-- 「连接失败」这种指错方向的说法。
|
||||
return false, "无法解析目标主机名"
|
||||
-- 放行,改由下载层在**连接建立之后**用 curl 回报的 %{remote_ip} 复核
|
||||
-- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP,
|
||||
-- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的
|
||||
-- DNS rebinding。
|
||||
return true, "本机无 DNS 解析能力,改由连接时校验对端地址", true
|
||||
end
|
||||
for _, ip in ipairs(ips) do
|
||||
if not is_private(ip) then return true end
|
||||
@@ -303,10 +385,6 @@ function M.check_public(host)
|
||||
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
|
||||
@@ -385,12 +463,46 @@ 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, "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)"
|
||||
end
|
||||
-- 有解析能力时预检已经查过一轮,这里无从复核不额外拒绝。
|
||||
return true
|
||||
end
|
||||
if is_private(ip) then
|
||||
return false, "目标实际连接到内网/保留地址 (" .. 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)
|
||||
@@ -409,13 +521,17 @@ local function fetch_curl(url, parsed, opts)
|
||||
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}\"%s %s 2>%s",
|
||||
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
|
||||
"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 code = p and p:read("*a") or ""
|
||||
local raw = util.trim(p and p:read("*a") or "")
|
||||
if p then p:close() end
|
||||
code = util.trim(code)
|
||||
-- 正常是 "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 msg = util.trim(util.read_file(errf) or "下载失败")
|
||||
cleanup()
|
||||
@@ -425,6 +541,11 @@ local function fetch_curl(url, parsed, opts)
|
||||
-- 或一段 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()
|
||||
@@ -437,6 +558,14 @@ local function fetch_curl(url, parsed, opts)
|
||||
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()
|
||||
@@ -478,17 +607,31 @@ function M.wget_proxy_env(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, "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"
|
||||
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 -O %s %s >%s 2>&1",
|
||||
t, util.shq(tmp), util.shq(url), util.shq(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
|
||||
-- 会把残缺内容当成下载成功。
|
||||
@@ -521,17 +664,21 @@ 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, "无效 URL" end
|
||||
local ok, reason = M.check_public(parsed.host)
|
||||
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, "无可用下载工具 (curl/wget)" end
|
||||
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy })
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user