mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +08:00
💐 Sync 2026-10-06 12:33:44
This commit is contained in:
1 parent
8be03ce939
commit
7fe69dbb3a
246 files changed
+50330
-3414
No files matched your search
@@ -2,6 +2,7 @@
|
||||
-- luci-app-substore
|
||||
|
||||
local util = require("substore.util")
|
||||
local msg = require("substore.msg")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -125,9 +126,9 @@ end
|
||||
function M.parse_url(url)
|
||||
url = util.trim(url or "")
|
||||
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
|
||||
if not scheme then return nil, "无效 URL" end
|
||||
if not scheme then return nil, "Invalid URL" end
|
||||
scheme = scheme:lower()
|
||||
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
|
||||
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 必然解析不出来,能不能拦住就全看本机有没有解析能力
|
||||
@@ -147,7 +148,7 @@ function M.parse_url(url)
|
||||
local host, port
|
||||
if hostport:sub(1, 1) == "[" then
|
||||
local close = hostport:find("]", 1, true)
|
||||
if not close then return nil, "无效主机名" end
|
||||
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
|
||||
@@ -158,13 +159,13 @@ function M.parse_url(url)
|
||||
host, port = hostport, (scheme == "https" and "443" or "80")
|
||||
end
|
||||
end
|
||||
if not host or host == "" then return nil, "无效主机名" 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, "端口无效" end
|
||||
if not pn or pn < 1 or pn > 65535 then return nil, "Invalid port" end
|
||||
return { scheme = scheme, host = host, port = tostring(pn) }
|
||||
end
|
||||
|
||||
@@ -182,8 +183,8 @@ 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
|
||||
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
|
||||
|
||||
@@ -191,11 +192,11 @@ 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
|
||||
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, "不支持的代理协议: " .. scheme
|
||||
return nil, msg.join("Unsupported proxy protocol: ", scheme)
|
||||
end
|
||||
local userinfo, hostport = "", rest:gsub("/.*$", "")
|
||||
local at = hostport:find("@", 1, true)
|
||||
@@ -204,7 +205,7 @@ function M.parse_proxy(p)
|
||||
hostport = hostport:sub(at + 1)
|
||||
end
|
||||
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
|
||||
return nil, "代理用户名/密码含非法字符"
|
||||
return nil, "Proxy username/password contains invalid characters"
|
||||
end
|
||||
local host, port
|
||||
local bracketed = hostport:sub(1, 1) == "["
|
||||
@@ -216,18 +217,18 @@ function M.parse_proxy(p)
|
||||
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, "代理主机无效"
|
||||
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, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080"
|
||||
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, "代理端口无效" end
|
||||
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= 都
|
||||
@@ -324,33 +325,33 @@ local LOOPBACK_NAMES = {
|
||||
-- 调用方(下载层)据此在连接建立后强制复核对端地址,见 verify_peer_ip。
|
||||
function M.check_public(host)
|
||||
host = util.trim(host or ""):lower()
|
||||
if host == "" then return false, "空主机名" end
|
||||
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, "主机名含非法字符 @" end
|
||||
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, "主机名含非法字符" end
|
||||
if host:find("[%s%%]") then return false, "Hostname contains invalid characters" end
|
||||
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
|
||||
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
|
||||
host = host:gsub("%.$", "")
|
||||
if host == "" then return false, "空主机名" end
|
||||
if host == "" then return false, "Empty hostname" 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
|
||||
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, "目标为内网/保留地址" end
|
||||
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, "目标为 localhost" end
|
||||
if LOOPBACK_NAMES[host] then return false, "Target is localhost" end
|
||||
|
||||
local ips, have_resolver = resolve(host)
|
||||
if not ips then
|
||||
@@ -362,7 +363,7 @@ function M.check_public(host)
|
||||
-- 只要本机这一刻解析不出来(解析器临时故障、超时),就绕过了上面
|
||||
-- 全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
|
||||
-- 检查的强度不能低于被检查者。
|
||||
return false, "无法解析目标主机名"
|
||||
return false, "Cannot resolve the target hostname"
|
||||
end
|
||||
-- 本机连一个解析手段都没有(nixio 不可用,且没有 nslookup)。
|
||||
--
|
||||
@@ -376,12 +377,12 @@ function M.check_public(host)
|
||||
-- 实际对端地址(见 verify_peer_ip)。那才是真正连上去的那个 IP,
|
||||
-- 比预检更贴近事实,顺带免疫「预检时解析到公网、连接时解析到内网」的
|
||||
-- DNS rebinding。
|
||||
return true, "本机无 DNS 解析能力,改由连接时校验对端地址", true
|
||||
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, "目标仅解析到内网/保留地址"
|
||||
return false, "Target resolves only to private/reserved addresses"
|
||||
end
|
||||
|
||||
-- ---------- 下载 ----------
|
||||
@@ -444,9 +445,9 @@ 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
|
||||
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, "重定向目标不安全: " .. (re or "") end
|
||||
if not ok then return false, msg.join("Unsafe redirect target: ", re or "") end
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -480,13 +481,13 @@ local function verify_peer_ip(ip, proxy, unverified)
|
||||
-- 预检时本机就没有解析能力(check_public 放行了),现在连对端地址
|
||||
-- 也拿不到 —— 这个请求从头到尾没有任何一处校验过目标,
|
||||
-- 必须拒绝,否则「无法校验」就等于「放行」。
|
||||
return false, "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)"
|
||||
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, "目标实际连接到内网/保留地址 (" .. ip .. ")"
|
||||
return false, msg.compose("Target actually connects to a private/reserved address (", ip, ")")
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -533,9 +534,9 @@ local function fetch_curl(url, parsed, opts)
|
||||
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 "下载失败")
|
||||
local errtext = util.trim(util.read_file(errf) or "Download failed")
|
||||
cleanup()
|
||||
return nil, msg
|
||||
return nil, errtext
|
||||
end
|
||||
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
|
||||
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
|
||||
@@ -549,12 +550,12 @@ local function fetch_curl(url, parsed, opts)
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then
|
||||
cleanup()
|
||||
return nil, "响应超过大小限制 (" .. max .. " 字节)"
|
||||
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, "读取响应失败" end
|
||||
if not content then return nil, "Failed to read the response" end
|
||||
return content, headers
|
||||
end
|
||||
if code:match("^3%d%d$") then
|
||||
@@ -569,27 +570,27 @@ local function fetch_curl(url, parsed, opts)
|
||||
local loc = location_from_headers(hdr)
|
||||
if not loc then
|
||||
cleanup()
|
||||
return nil, "重定向无 Location"
|
||||
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, "重定向目标无效"
|
||||
return nil, "Invalid redirect target"
|
||||
end
|
||||
local ok, re = M.check_public(np.host)
|
||||
if not ok then
|
||||
cleanup()
|
||||
return nil, "重定向目标不安全: " .. (re or "")
|
||||
return nil, msg.join("Unsafe redirect target: ", re or "")
|
||||
end
|
||||
cleanup()
|
||||
cur = next_url
|
||||
else
|
||||
cleanup()
|
||||
return nil, "HTTP 错误 " .. code
|
||||
return nil, msg.join("HTTP error ", code)
|
||||
end
|
||||
end
|
||||
return nil, "重定向次数过多"
|
||||
return nil, "Too many redirects"
|
||||
end
|
||||
|
||||
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
|
||||
@@ -600,8 +601,8 @@ function M.wget_proxy_env(proxy)
|
||||
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 代理"
|
||||
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
|
||||
@@ -614,7 +615,7 @@ local function fetch_wget(url, parsed, opts)
|
||||
-- 于是在这条路径上,「预检放行」之后不存在任何一处校验 —— 等于没有 SSRF 防护。
|
||||
-- 与 verify_peer_ip 的处置保持一致:校验不了就拒绝,而不是放行。
|
||||
if opts.unverified then
|
||||
return nil, "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)"
|
||||
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)
|
||||
@@ -645,15 +646,15 @@ local function fetch_wget(url, parsed, opts)
|
||||
return nil, reason
|
||||
end
|
||||
local size = util.file_size(tmp)
|
||||
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
|
||||
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, "下载失败(wget 退出码非 0)"
|
||||
return nil, "Download failed (wget exit code is non-zero)"
|
||||
end
|
||||
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" 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, "读取响应失败" end
|
||||
if not content then return nil, "Failed to read the response" end
|
||||
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
|
||||
end
|
||||
|
||||
@@ -670,13 +671,13 @@ function M.download(url, opts)
|
||||
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
|
||||
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, "无可用下载工具 (curl/wget)" end
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user