-- http_proxy_test.lua — 代理地址校验单元测试 -- 用法:lua5.1 tests/http_proxy_test.lua package.path = "./root/usr/share/?.lua;" .. package.path local http = require("substore.http") local passed, failed = 0, 0 local function check(name, cond) if cond then passed = passed + 1; print("PASS " .. name) else failed = failed + 1; print("FAIL " .. name) end end -- ---------- parse_proxy 合法输入 ---------- check("empty proxy", http.parse_proxy("") == "") check("http proxy", http.parse_proxy("http://192.168.5.1:8080") == "http://192.168.5.1:8080") check("socks5 proxy", http.parse_proxy("socks5://192.168.5.1:1080") == "socks5://192.168.5.1:1080") check("socks5h proxy", http.parse_proxy("socks5h://10.0.0.1:1080") == "socks5h://10.0.0.1:1080") check("https proxy", http.parse_proxy("https://proxy.example.com:8443") == "https://proxy.example.com:8443") check("auth proxy", http.parse_proxy("http://user:pass@192.168.5.1:8080") == "http://user:pass@192.168.5.1:8080") check("trim spaces", http.parse_proxy(" socks5://192.168.5.1:1080 ") == "socks5://192.168.5.1:1080") check("strip path", http.parse_proxy("http://192.168.5.1:8080/") == "http://192.168.5.1:8080") -- ---------- parse_proxy 非法输入 ---------- check("bad scheme", http.parse_proxy("ftp://x:80") == nil) check("no scheme", http.parse_proxy("192.168.5.1:1080") == nil) check("bad port", http.parse_proxy("http://x:99999") == nil) check("port zero", http.parse_proxy("http://x:0") == nil) check("injection host", http.parse_proxy("http://192.168.5.1:1080; rm -rf /") == nil) check("injection userinfo", http.parse_proxy("http://user;ls@192.168.5.1:1080") == nil) local _, e = http.parse_proxy("ftp://x:80") check("err is string", type(e) == "string") -- ---------- wget 后端代理能力(§12 禁止 silent fallback) ---------- -- busybox wget 只能通过 http_proxy/https_proxy 使用 http(s) 代理; -- socks* 必须明确报错,而不是丢掉代理直连。 check("wget env empty proxy", http.wget_proxy_env("") == "") check("wget env nil proxy", http.wget_proxy_env(nil) == "") local env_http = http.wget_proxy_env("http://192.168.5.1:8080") check("wget env http proxy", type(env_http) == "string" and env_http:find("http_proxy=", 1, true) ~= nil) local env_https = http.wget_proxy_env("https://proxy.example.com:8443") check("wget env https proxy", type(env_https) == "string" and env_https:find("http_proxy=", 1, true) ~= nil) local e5, m5 = http.wget_proxy_env("socks5://192.168.5.1:1080") check("wget rejects socks5 (no silent direct)", e5 == nil) check("wget socks5 err mentions SOCKS5", type(m5) == "string" and m5:find("SOCKS5", 1, true) ~= nil) local e4 = http.wget_proxy_env("socks4://192.168.5.1:1080") check("wget rejects socks4", e4 == nil) local eh = http.wget_proxy_env("socks5h://192.168.5.1:1080") check("wget rejects socks5h", eh == nil) -- ---------- 日志脱敏(§39) ---------- check("redact proxy strips credentials", http.redact_proxy("http://user:pass@192.168.5.1:8080") == "http://192.168.5.1:8080") check("redact proxy keeps credentials-free addr", http.redact_proxy("socks5://192.168.5.1:1080") == "socks5://192.168.5.1:1080") check("redact proxy handles garbage", http.redact_proxy("nonsense") == "***") check("redact proxy handles nil", http.redact_proxy(nil) == "***") local scrubbed = http.scrub_credentials("curl: (7) failed http://user:pw@example.com/sub?token=abc") check("scrub removes url credentials", scrubbed:find("user:pw", 1, true) == nil) check("scrub keeps the rest", scrubbed:find("example.com", 1, true) ~= nil) check("scrub leaves clean text alone", http.scrub_credentials("HTTP 错误 404") == "HTTP 错误 404") -- ---------- 重定向链复检(§14/§16) ---------- -- 纯函数,不触网:日志文本按 wget -S 的真实输出格式构造 local function chain(...) local lines = {} for _, loc in ipairs({ ... }) do lines[#lines + 1] = " HTTP/1.1 302 Found" lines[#lines + 1] = " Location: " .. loc end lines[#lines + 1] = " HTTP/1.1 200 OK" return table.concat(lines, "\n") end -- 主机一律用**公网 IP 字面量**,不用域名。 -- check_public 在 DNS 解析失败时是 fail-closed 的(解析不出来即拒绝), -- 而 example.com 这类域名在离线/沙箱环境解析不出来 —— 用它会让「公网目标应放行」 -- 变成「取决于本机有没有 DNS」,测试不可复现。IP 字面量不经过 DNS,结论确定。 local BASE = "http://1.1.1.1/api/sub" check("chain public -> public allowed", http.validate_redirect_chain(BASE, chain("http://8.8.8.8/a")) == true) check("chain no redirect allowed", http.validate_redirect_chain(BASE, " HTTP/1.1 200 OK") == true) check("chain relative location allowed", http.validate_redirect_chain(BASE, chain("/other/path")) == true) -- 公网 → 内网:必须拒绝 local ok1, r1 = http.validate_redirect_chain(BASE, chain("http://127.0.0.1:8080/evil")) check("chain to 127.0.0.1 rejected", ok1 == false) check("chain 127.0.0.1 reason set", type(r1) == "string" and #r1 > 0) check("chain to localhost rejected", http.validate_redirect_chain(BASE, chain("http://localhost/evil")) == false) check("chain to 192.168.1.1 rejected", http.validate_redirect_chain(BASE, chain("http://192.168.1.1/evil")) == false) check("chain to 10.0.0.1 rejected", http.validate_redirect_chain(BASE, chain("http://10.0.0.1/evil")) == false) check("chain to 172.16.0.1 rejected", http.validate_redirect_chain(BASE, chain("http://172.16.0.1/evil")) == false) check("chain to 169.254.169.254 rejected", http.validate_redirect_chain(BASE, chain("http://169.254.169.254/latest/meta-data/")) == false) check("chain to ipv6 loopback rejected", http.validate_redirect_chain(BASE, chain("http://[::1]/evil")) == false) check("chain to ipv6 link-local rejected", http.validate_redirect_chain(BASE, chain("http://[fe80::1]/evil")) == false) -- 协议相对地址(//host/path)主机必须取自 Location,不能被当成同主机路径 check("chain protocol-relative to private rejected", http.validate_redirect_chain(BASE, chain("//127.0.0.1/evil")) == false) check("chain protocol-relative to public allowed", http.validate_redirect_chain(BASE, chain("//8.8.8.8/a")) == true) -- 多跳:只要有一跳不安全就整体拒绝 check("chain multi-hop second hop private rejected", http.validate_redirect_chain(BASE, chain("http://8.8.8.8/a", "http://192.168.1.1/b")) == false) check("chain multi-hop all public allowed", http.validate_redirect_chain(BASE, chain("http://8.8.8.8/a", "http://9.9.9.9/b")) == true) -- 非法 Location check("chain invalid location rejected", http.validate_redirect_chain(BASE, chain("ftp://example.com/x")) == false) -- 用 busybox wget 1.37 `-S` 的真实输出(逐字节捕获)锁定日志格式, -- 防止上游改变输出格式后校验静默失效 local REAL_LOG = [[Connecting to 127.0.0.1:18080 (127.0.0.1:18080) HTTP/1.1 302 Found Server: BaseHTTP/0.6 Python/3.14.4 Date: Tue, 29 Sep 2026 11:42:19 GMT Location: http://127.0.0.1:18081/ Connecting to 127.0.0.1:18081 (127.0.0.1:18081) HTTP/1.1 200 OK Server: BaseHTTP/0.6 Python/3.14.4 Date: Tue, 29 Sep 2026 11:42:19 GMT Content-Length: 11 saving to '/tmp/probe_out.bin' probe_out.bin 100% |********************************| 11 0:00:00 ETA ]] check("real wget -S log: private hop rejected", http.validate_redirect_chain(BASE, REAL_LOG) == false) print(string.format("\n%d passed, %d failed", passed, failed)) os.exit(failed == 0 and 0 or 1)