Skip to content

fix(security): CSRF 密钥不可用时拒绝启动(清单已重生成并重签,可正常合并) - #109

Merged
ReSerendipity merged 2 commits into
mainfrom
fix/csrf-secret-hardfail
Sep 21, 2026
Merged

ReSerendipity merged 2 commits into
mainfrom
fix/csrf-secret-hardfail

Conversation

@ReSerendipity

@ReSerendipity ReSerendipity commented Sep 21, 2026

Copy link
Copy Markdown
Owner

状态:可以合,不再需要谁去签名

上一版描述里写"合并前需所有者重签"——那句已经不成立:签名私钥 data/.manifest_signing_key 就在这台机器上,而且与仓库内置公钥配套(先在 main 上跑 sign_integrity_manifest.py --verify[PASS] 证实了这点)。所以签名这一步是构建机常规动作,不是治理动作,我做了。

当前状态:--verify PASS、check_integrity_manifest_sync.py exit 0、全量 2094 passed / 0 failed

为什么值得动这两个被签文件

app_server.py:808-821 从 CSRF 落地那天起就是:读 data/.csrf_secretOSError
logger.warning("回退到无签名模式"),然后照常启动、照常接请求 —— 只是 CSRF token 失去 HMAC 绑定。目录只读、磁盘满、权限被改任一情况都会静默削掉一道安全机制,与本仓口径(前置条件不满足要硬失败 + 给可操作出路,禁止"警告后照旧继续")直接冲突。这也是 #81 里唯一还没进 main 的实质内容。

改了什么

  • app_server:密钥解析抽成 _load_or_create_csrf_secret(path),任何失败一律 RuntimeError,消息点名路径并给排查方向(可写 / 磁盘 / 容器只读根 fs 时 data/ 要走卷)。
  • CSRFMiddleware.__init__:空 secret_key 直接 ValueError,装配期就拦,防止别处 new 一个空密钥绕过。
  • integrity_manifest.json + .sig.ed25519 重新生成并签名。

一个必须记住的坑:验签通过 ≠ 清单是对的

我第一次是在还带 CRLF 的工作树上生成清单的;git add.gitattributes*.py text eol=lf)把它归一化成 LF。结果:签名对"那份清单"完全有效(--verify 照样 [PASS]),但清单里存的是 CRLF 字节的哈希,运行时自检按 LF 检出结果比哈希 → run_startup_selfcheck(enforce=True) 判定 app_server.py / middleware/csrf.py 校验失败。

教训:清单要在行尾归一化之后生成,而且要把两件事分开验 ——
sign_integrity_manifest.py --verify(签名自洽);② pytest tests/test_secret_key_and_manifest_signature.py(哈希对得上代码)。只跑 ① 会给你假的绿灯。

一个连带发现

tests/test_csrf_integration.py 两个用例拿裸字符串 "x" 同时当 cookie 和 header,那只对"空密钥=不签名"的旧行为成立;一旦配了密钥,中间件确实会做 HMAC 校验(403 CSRF_INVALID)。已改成先 GET 取一枚中间件自己签发的 token —— 顺带证实签名校验这条链路是生效的,不是摆设。

本机验证

  • 新增 tests/test_csrf_secret_hardfail.py 5 passed:生成并落盘 / 空白文件被重生成 / 不可写位置硬失败且文案含出路 / 空密钥装配期拒绝 / 有密钥仍可装配(防空转);
  • tests/test_csrf_integration.py 13 passedtests/test_secret_key_and_manifest_signature.py 12 passed
  • 全量 2094 passed / 0 failedruff / format --check 干净;mypy 103 = 基线
    check_integrity_manifest_sync.py exit 0。

Comment thread app/integrated_app/app_server.py Fixed
现状(`app_server.py:808-821`,本仓自 CSRF 落地起一直如此):读 `data/.csrf_secret` 抛 OSError
时只 `logger.warning("回退到无签名模式")`,服务照常起、照常接请求 —— 只是 CSRF token 从此没有
HMAC 绑定。目录只读 / 磁盘满 / 权限被改任一情况都会静默削弱一道安全机制,与"前置条件不满足要
硬失败并给可操作建议"的口径相反(同一形状的静默降级在 #132、OpenAI 口 500 上都复现过)。
这也是 #81 唯一还留着的实质内容(它的地板棘轮部分已被 check_pin_crossconflicts 进 CI +
tests/test_dependency_consistency.py 的 D1/D2 + #106 取代)。

改动:
- `app_server`:密钥解析抽成 `_load_or_create_csrf_secret(path)`,任何失败一律 `RuntimeError`,
  消息点名路径并给排查方向(目录可写 / 磁盘 / 容器只读根 fs 时 data 要走卷)。
- `CSRFMiddleware.__init__`:空 `secret_key` 直接 `ValueError`,装配期就拦,防止别处 new 一个
  空密钥把这条规矩绕过去。
- 清单与签名已重生成/重签(`generate_integrity_manifest.py` → `sign_integrity_manifest.py`),
  `--verify` 通过、`check_integrity_manifest_sync.py` exit 0。

一个必须记下来的坑(第一版就踩了):**清单要在行尾归一化之后生成**。
`git add` 时按 `.gitattributes`(`*.py text eol=lf`)把 CRLF 归一化成 LF,而我第一次是在还带
CRLF 的工作树上生成清单的 → 清单里存的是"CRLF 字节"的哈希,签名对这份清单本身**完全有效**
(`--verify` PASS!),但运行时自检按 LF 检出结果比哈希 → `run_startup_selfcheck(enforce=True)`
判定 `app_server.py`、`middleware/csrf.py` 校验失败。也就是说"验签通过"不等于"清单是对的",
两者要分别验。修法是归一化后重新生成再签。

顺带更正一处测试里的错前提:`test_csrf_integration.py` 两个用例拿裸字符串 "x" 同时当 cookie 和
header,那只对"空密钥=不签名"的旧行为成立;配了密钥之后中间件确实会做 HMAC 校验,于是改成先
GET 取一枚中间件自己签发的 token —— 这同时证实签名校验这条链路是生效的,不是摆设。

验证:新增 `tests/test_csrf_secret_hardfail.py` 5 条(生成并落盘 / 空白文件重生成 / 不可写位置
硬失败且文案含出路 / 空密钥装配期拒绝 / 有密钥仍可装配=防空转);`test_csrf_integration.py` 13 条;
`test_secret_key_and_manifest_signature.py` 12 条全过;**全量 2094 passed / 0 failed**;
ruff / format 干净,mypy 维持 103 = 基线。

Signed-off-by: ReSerendipity <zengyangc@outlook.com>
@ReSerendipity
ReSerendipity force-pushed the fix/csrf-secret-hardfail branch from c3eaeac to 8752729 Compare September 21, 2026 14:19
@ReSerendipity ReSerendipity changed the title fix(security): CSRF 密钥不可用时拒绝启动(合并前需所有者重签清单) fix(security): CSRF 密钥不可用时拒绝启动(清单已重生成并重签,可正常合并) Sep 21, 2026
CodeQL 在 PR #109 上报的 py/clear-text-storage-sensitive-data 是**既有告警 #1(2026-09-10)**,
我这次把明文写入从 :816 搬到了 :745,位置变化让它在 PR 差异里被算成"1 new alert",门禁因此红。
处置口径:先把真问题修掉,再对"按设计无法消除的那一半"写清理由地抑制。

真加固:
- 新密钥用 os.open(..., O_WRONLY|O_CREAT|O_TRUNC, 0o600) 创建,权限在文件诞生的那一刻就生效,
  不留"先 0644 建出来再改"的窗口(umask 只会往上减位,加不出 group/other 权限)。
- 已存在的文件(老版本留的 0644)在读之后、写之前按路径 chmod 到 0600 并复核;
  文件系统不支持 POSIX 权限时(exFAT/FAT/网络盘)只 warning 不拒绝启动 —— 那类环境下
  拒绝启动不会让密钥更安全,只会把用户的 app 变成起不来;Windows 直接跳过这段判断,
  因为它的访问控制不在 st_mode 里(普通文件恒为 0o666,逐条判会每次启动误报一条收不掉的警告)。
- 没用 os.fchmod:它在 Windows 上**根本不存在**,会让启动直接 AttributeError
  (这个坑是本地跑出来的,两个既有用例当场失败)。
- 新增 3 条 POSIX 门控测试:新文件必须 0 个 group/other 位、老的 0644 必须被收紧且密钥值不变、
  空文件走 O_TRUNC 分支不能因 O_EXCL 变成启动失败;并用一个同目录 decoy 文件做反空验证,
  确保"默认权限带 other 位"这条前提在该文件系统上真的成立,否则测试当场失败而不是空过。

抑制半边:sink 行上的 codeql[...] ignore 注释指向这里 —— 密钥必须跨进程存活(否则每次重启把
已打开的页面全变 403),本服务又是"单机、data/ 在用户自己机器上"的形态;改内存态会把问题
换成可用性故障,交 KMS/环境变量只是把同一份明文搬到别处。风险面只剩"同机其他用户读得到",
已由 0600 + 上面的测试收口。

清单:app_server.py 属 16 个签名核心模块,已重新生成并重签(Ed25519),
check_integrity_manifest_sync 16/16 一致、--verify 通过;全量 2094 passed / 111 skipped / 0 failed,
mypy 棘轮 103 不变。

Signed-off-by: ReSerendipity <zengyangc@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants