Files

133 lines
9.6 KiB
Markdown
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.
# Security — luci-app-substore
## SSRF 防护
- `http.lua` 下载前解析主机名,拒绝 localhost/内网/链路本地/保留地址
- DNS 解析后二次校验,处理重定向
- 仅允许 http/https 协议;端口限定 1–65535
- **解析失败即拒绝(fail-closed)**:**有解析手段**却解析不出 IP 时不放行。
检查的强度不能低于被检查者 —— `127.0.0.1.nip.io` 这类「公网可解析到内网」的域名,
若在本机解析失败的那一刻被放行,curl 自己仍会把它解析到 127.0.0.1 并连上去。
- **解析手段多级回退**:`nixio.getaddrinfo` → busybox `nslookup`。
`[2.6.8-r1]` 曾假设「本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
所以解析失败只意味着真的解析不了」——**该假设在部分设备上不成立**,
导致所有域名订阅报「无法解析目标主机名」(`[2.6.12-r1]` 修复)。
现在把「本机没有解析手段」与「这个域名解析不出来」严格区分:
前者放行并标记 `unverified`,后者仍 fail-closed。
- **连接时对端校验**:`fetch_curl` 用 `-w` 取 `%{remote_ip}`,在**连接建立后**
复核真正连上的 IP;重定向的每一跳(含第一跳)都复核。
这既兜住上面 `unverified` 的放行路径,也消除了「预检解析」与
「下载时解析」之间的 TOCTOU 窗口(DNS rebinding)。
走代理时跳过 —— `%{remote_ip}` 那时是代理地址。
`unverified` 且拿不到对端 IP 时拒绝:「无法校验」不等于「放行」。
- **wget 后端在 `unverified` 时直接拒绝**(`[2.6.16-r1]`)。此前该后端只有预检:
busybox wget 拿不到对端 IP,`-S` 日志里的重定向链也只能事后看 —— 于是
「本机无解析手段」的设备上,预检放行之后**不存在任何一处校验**,等于完全没有
SSRF 防护,且这条路径是静默的。现按与 `verify_peer_ip` 相同的原则收敛:
校验不了就拒绝,错误信息提示安装 curl。字面 IP 目标不受影响
(`check_public` 对 IP 提前返回,不产生 `unverified`)。
- **重定向链逐跳复检**:`validate_redirect_chain` 从 wget `-S` 日志按顺序取出每一跳
的 `Location`,逐跳 `check_public`,任一跳不安全即整体拒绝并丢弃响应体。
限制:这是**事后**校验(busybox wget 无 `--max-redirect`),请求已经发出;
curl 路径用 `--max-redirs 0` 自行逐跳跟随,每跳都在**发出前**校验。
## 访问控制
- **ACL 组**(`[2.6.16-r1]`):`root/usr/share/rpcd/acl.d/luci-app-substore.json`
定义 `luci-app-substore` 组(读写 `uci: substore`),`menu.d` 两个条目均声明
`depends.acl` 引用它 → 未获授权的 LuCI 用户看不到本应用入口。
在此之前本应用**没有任何 ACL**,任意已登录 LuCI 用户都能读写全部订阅(含凭据 URL)。
- **授权方式**:把用户加入该 ACL 组,例如在 `/etc/config/rpcd` 中为该用户的
`read` / `write` 列表加上 `luci-app-substore`(与其它 LuCI 应用一致);
仅 root 可见时无需任何配置。
- **改完必须刷新 LuCI 索引缓存**:菜单树与 ACL 都被缓存在 `/tmp/luci-indexcache*`
(缓存文件名含 menu.d 文件列表的哈希)。新增/修改 menu.d 或 acl.d 后若不刷新,
改动**不会生效**且不报错。包的 `postinst` 已自动执行
`rm -f /tmp/luci-indexcache && /etc/init.d/luci reload`;
手工拷贝文件时需自己执行这两条。
- **执行范围(已从上游源码核实)**:这是**入口级**门禁,不只是「菜单里看不见」。
ucode dispatcher(23.05+,现代目标机实际运行的那套)的 `build_pagetree()` 把
menu.d 与 Lua 控制器装进**同一棵树**,`dispatch()` 逐段 `ctx_append` 累积
路径上每个节点的 `depends.acl`,随后 ACL 不足即 `http.status(403)` ——
**直接访问 URL 会拿到 403**。又因 ACL 沿路径累积,挂在父节点
`admin/services/substore` 上的 ACL 已覆盖其下**全部 18 个** `entry`
(form / nodes / delete / save / update / probe …)。
旧版 Lua dispatcher(≤ 22.03)行为不同:menu.d 的 `depends.acl` 只影响菜单渲染,
入口级需另补 `entry.acl_depends` —— 但**本项目最低支持 23.05**,不在支持范围内,
故无需处理(见 LEGACY_ISSUES 1.2)。
**已在设备上实测通过**(2026-10-02,按 TESTING.md 第 9 项):未授权用户看不到
入口、加入组后入口出现、**直接访问 URL 返回 403 Forbidden**。
- **表单 token**:写操作要求 `token` 存在、非空,并在可取到时与
`luci.dispatcher.context.authtoken` 比对(该值正是模板中 `token` 的来源),
校验失败**回显原因**。所有 `entry` 均未声明 `post`,因此框架的
`test_post_security` **不会**执行 —— 上述检查是本应用自己的那一层。
## 资源限制
- 订阅响应体最大 10MB,可配置
- 连接/总超时 20s
- 并发限制由系统调度
- 下载临时文件(`.tmp` / `.tmp.hdr` / `.tmp.err`)在**每一条**退出路径上清理。
OpenWrt 的 `/tmp` 是 tmpfs,占的是内存;失败路径不清理会让 cron 定时重试
一轮轮往内存里堆文件,其中 `.tmp` 可能是最大到 `max-size` 的部分响应体
- 批量节点探测并发上限 `probe.MAX_PARALLEL = 16`。节点数由订阅内容决定(可上千),
每个探测占 1 个进程 + 1 个管道 fd;不限并发会打满路由器的 fd / 进程额度,
之后 `io.popen` **静默失败** —— 症状是一大片节点探测不出来,而不是报错
## 输入校验
- 订阅名/文件名白名单校验,禁止 `..`、`/`
- 解析时限制结构/大小/嵌套深度,防 YAML/JSON 资源耗尽
- URL 参数解码后二次校验
- **订阅客户端类型(User-Agent)**:`http.validate_user_agent` 拒绝控制字符与超过
256 字符的值。UA 来自表单,最终以 `-A <ua>` / `-U <ua>` 进入 shell 命令 ——
`util.shq` 的单引号挡得住注入,但挡不住 UA 里的**换行**:curl 会把它当成额外的
请求头拼进去(header injection),日志里也会被换行截断、伪造出额外行。
与代理同理,非法 UA **明确失败而非静默忽略**(§12):静默忽略会让用户以为 UA
已生效,实际拿到的仍是占位节点
- 探测目标主机名拒绝以 `-` 开头:`probe.lua` 的命令形如 `ping -c 1 -W 2 <host>`,
而 `--help` / `-c` 会被 busybox 的 getopt 当成**选项**。`util.shq` 的单引号由
shell 剥掉,getopt 看到的仍是 `-x`,引号挡不住这一层,必须在取值时就拒绝
## 凭据保护
- 订阅 URL 中的 token 不写入普通日志
- 输出/下载接口使用**每订阅随机 16 位十六进制 token** 鉴权(`core.ensure_token`),不可猜测;`/substore/download` 无登录态
- 日志使用 `logger -t luci-app-substore`,不记录敏感信息
- 落盘权限:`/etc/substore` 目录 `0700`,`subscriptions.json` 与 `nodes/*.json` `0600`。
`io.open` 按 umask 创建(通常 0644),同机任何用户都能读到订阅 URL、下载 token
与节点凭据,因此 `util.atomic_write` 在 `os.rename` **之后**显式 chmod ——
先 chmod 再 rename 的话,临时文件名可猜,中间窗口里仍能读到。
目录侧由 `core.ensure_dirs` 建目录时带 `-m 700`,并对旧版本升级上来、
已存在的目录补一次 `chmod 700`(每进程一次,避免 `load()` 每次都 fork)
- 注入到页面 `<script>` 的 JSON 转义**全部** `<`(`<`):只转 `</` 挡得住
`</script>` 提前闭合,却挡不住 `<!--` —— 后者让 HTML 词法阶段进入
script data escaped 状态,其后的 `</script>` 不再结束脚本块,整页被吞进脚本
## 安全测试
均为自包含 Lua 5.1 测试,`lua5.1 tests/<file>.lua` 即可运行(无需设备):
- `tests/dns_fallback_test.lua` —— SSRF 私网拒绝、解析手段回退、
「有解析手段却解析不出 → 拒绝」、「无解析手段 → 标记 `unverified`」、
curl 的 `%{remote_ip}` 连接后复核,以及 **wget 后端在 `unverified` 时拒绝**
(用例 H,含对照组 H2:有解析器时 wget 正常下载)
- `tests/parser_mixed_format_test.lua` —— 混合格式导入的拒绝与单一格式的不误判
- `tests/acl_menu_test.lua` —— ACL 组定义、菜单 `depends.acl` 引用同一组名、
Makefile 确实安装这两个文件(接线上任何一环写错都只会**静默失效**)
- `tests/controller_robustness_test.lua` —— 表单 token 校验(合法 / 空 / 不匹配 /
取不到 authtoken 的回退)与删除部分失败时的 cron 重写
- `tests/data_integrity_test.lua` —— 注入 `atomic_write` 写失败,断言失败被上报
而非静默吞掉
- Base64/JSON 解析异常处理、超大响应体截断测试见 `tests/` 下同名用例
**仍未在设备上验证的部分**(如实记录):LuCI 模板渲染、cron 落盘行为均需在目标
设备上复核 —— 见 [TESTING.md](TESTING.md)。(ACL 的实际拦截效果已于 2026-10-02
在设备上实测通过,见上文「访问控制」。)
参见 docs/ARCHITECTURE.md 第 5 节安全设计。
Key requirements (from CLAUDE.md):
- Prevent SSRF (reject localhost / private / link-local / reserved addresses)
- DNS rebinding protection
- Limit subscription response body size and set request timeouts
- Prevent path traversal and command injection
- Never log subscription credentials / tokens
- Output interface requires access control or a random token
- Validate imported data; prevent malicious YAML/JSON resource exhaustion