-- security_fixes_test.lua — 安全/健壮性修复的回归测试 -- 用法:lua5.1 tests/security_fixes_test.lua -- -- 覆盖本轮修复: -- 1. util.shq 单引号转义(string.format("%q") 不是 shell 安全的) -- 2. http.parse_url 剥离 query / fragment(SSRF fail-open 绕过) -- 3. core.save_meta 的 CLEAR 语义(无法表达「清空字段」) -- 4. node 改名:Lua 模式里的 `-` 是惰性量词、`|` 无 alternation -- 5. 模板改名:替换值里的 `%` 必须转义 package.path = "./root/usr/share/?.lua;" .. package.path local util = require("substore.util") local http = require("substore.http") local node = require("substore.node") local core = require("substore.core") 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 -- ---------- 1. util.shq ---------- -- 关键性质:拼进 shell 命令行后,命令替换不会发生。 -- 用真实的 sh -c 验证,而不是只看字符串长相。 local function sh_echo(s) local f = os.tmpname() -- 两层转义:外层 shq 把「echo <内层 shq(s)>」整体交给 sh -c, -- 内层 shq 才是被测对象。若 shq 失效,内层替换就会真的执行。 os.execute("sh -c " .. util.shq("echo " .. util.shq(s)) .. " > " .. util.shq(f)) local fh = io.open(f, "r") local out = fh and fh:read("*a") or "" if fh then fh:close() end os.remove(f) return (out:gsub("\n$", "")) end check("shq wraps in single quotes", util.shq("abc") == "'abc'") check("shq escapes embedded single quote", util.shq("a'b") == "'a'\\''b'") check("shq nil becomes empty", util.shq(nil) == "''") check("shq number coerced", util.shq(42) == "'42'") -- 双引号包裹(format("%q") 的做法)在 /bin/sh 里仍会做 $() 与 `` 替换 check("shq neutralizes $() substitution", sh_echo("$(echo pwned)") == "$(echo pwned)") check("shq neutralizes backtick substitution", sh_echo("`echo pwned`") == "`echo pwned`") check("shq neutralizes quote breakout", sh_echo("'; echo pwned; '") == "'; echo pwned; '") check("shq neutralizes semicolon", sh_echo("a; echo pwned") == "a; echo pwned") -- ---------- 2. http.parse_url:query / fragment ---------- -- 这些 URL 的 host 是回环/内网,必须解析成回环/内网地址, -- 交给 check_public 时才会被拦下;解析成 "127.0.0.1?a=1" 会因 DNS 失败而放行。 local u1 = http.parse_url("http://127.0.0.1?a=1") check("query stripped from host", u1 and u1.host == "127.0.0.1") check("query stripped keeps default port", u1 and u1.port == "80") local u2 = http.parse_url("http://127.0.0.1#frag") check("fragment stripped from host", u2 and u2.host == "127.0.0.1") local u3 = http.parse_url("http://127.0.0.1:8080/path?x=1#y") check("port survives path/query/fragment", u3 and u3.host == "127.0.0.1" and u3.port == "8080") local u4 = http.parse_url("http://evil@127.0.0.1/") check("userinfo stripped", u4 and u4.host == "127.0.0.1") local u5 = http.parse_url("http://user:pw@127.0.0.1:8080/") check("userinfo with password stripped", u5 and u5.host == "127.0.0.1" and u5.port == "8080") local u6 = http.parse_url("https://example.com/x") check("https default port", u6 and u6.host == "example.com" and u6.port == "443") local u7 = http.parse_url("http://[::1]:8080/x") check("ipv6 literal parsed", u7 and u7.host == "::1" and u7.port == "8080") local u8 = http.parse_url("ftp://example.com/") check("non-http scheme rejected", u8 == nil) -- ---------- 3. core.save_meta 的 CLEAR ---------- local tmp = os.tmpname() .. "_substore_sec" os.remove(tmp) core.DATA_DIR = tmp core.LIST_FILE = tmp .. "/subscriptions.json" core.NODES_DIR = tmp .. "/nodes" core.ensure_dirs() local id = core.add("sec", "http://example.com/sub") check("add returns id", type(id) == "string") core.save_meta(id, { upload = 100, download = 200, total = 300, expire = 400 }) local m1 = core.get(id) check("save_meta writes fields", m1.upload == 100 and m1.expire == 400) -- 传 nil 无法表达删除:pairs 永远不会给出 nil 值 core.save_meta(id, { upload = nil, download = nil, total = nil, expire = nil }) local m2 = core.get(id) check("nil patch cannot clear (documents why CLEAR exists)", m2.upload == 100) core.save_meta(id, { upload = core.CLEAR, download = core.CLEAR, total = core.CLEAR, expire = core.CLEAR, }) local m3 = core.get(id) check("CLEAR removes upload", m3.upload == nil) check("CLEAR removes download", m3.download == nil) check("CLEAR removes total", m3.total == nil) check("CLEAR removes expire", m3.expire == nil) check("CLEAR keeps other fields", m3.name == "sec" and m3.url == "http://example.com/sub") os.execute("rm -rf " .. util.shq(tmp)) -- ---------- 4. 改名:`-` 与 `|` ---------- local function rename_one(name, rules) local ns = node.rename_with_rules({ { name = name, proto = "vmess", server = "1.2.3.4", port = 443 } }, rules) return ns[1].name end -- Lua 模式里 `-` 是惰性量词:不转义则 "Node-42" 永远匹配不上 "Node-(%d+)" local r1 = { { type = "regex", pattern = "Node-(\\d+)", replacement = "N$1" } } check("dash in pattern is literal", rename_one("Node-42", r1) == "N42") check("dash pattern still matches without dash", rename_one("Node42", r1) == "Node42") -- Lua 模式没有 alternation:顶层 `|` 必须拆成多条候选依次替换 local r2 = { { type = "regex", pattern = "^HK-|^US-", replacement = "R-" } } check("alternation first branch", rename_one("HK-a", r2) == "R-a") check("alternation second branch", rename_one("US-b", r2) == "R-b") check("alternation non-matching branch untouched", rename_one("JP-c", r2) == "JP-c") -- 分组内的 `|` 不支持(Lua 的 () 只是捕获,没有「或」语义)。 -- 关键是不能把它当顶层 | 拆开:拆开会产生 `^(HK` / `US` / `JP)%-(.*)$` -- 这种残缺模式,三段都匹配不上;此处只要求「不误伤」——按字面处理、不匹配。 local r2b = { { type = "regex", pattern = "^(HK|US)-(.*)$", replacement = "R-$2" } } check("group alternation is literal (unsupported)", rename_one("HK-a", r2b) == "HK-a") check("group alternation does not corrupt name", rename_one("US-b", r2b) == "US-b") -- 字符类里的 `|` 是字面字符,不能被当分隔符拆开 local r2c = { { type = "regex", pattern = "^[|]x$", replacement = "PIPE" } } check("pipe inside class is literal", rename_one("|x", r2c) == "PIPE") -- 字符类里的 `-` 是范围符号,不能被转义成 %- local r3 = { { type = "regex", pattern = "^[a-z]+(\\d+)$", replacement = "X$1" } } check("dash inside class stays a range", rename_one("abc123", r3) == "X123") -- 反向引用仍然可用 local r4 = { { type = "regex", pattern = "^(.*):(\\d+)$", replacement = "$1" } } check("backreference still works", rename_one("1.2.3.4:443", r4) == "1.2.3.4") -- ---------- 5. 模板改名的 % 转义 ---------- local r5 = { { type = "template", template = "[50% OFF] {server}" } } check("percent preserved in template", rename_one("x", r5) == "[50% OFF] 1.2.3.4") local r6 = { { type = "template", template = "{name}-{port}" } } check("template fields substituted", rename_one("zz", r6) == "zz-443") print("") print(passed .. " passed, " .. failed .. " failed") os.exit(failed == 0 and 0 or 1)