diff --git a/docs/SECURITY_CODEQL_TRIAGE.md b/docs/SECURITY_CODEQL_TRIAGE.md index 52e9a57..fde2d45 100644 --- a/docs/SECURITY_CODEQL_TRIAGE.md +++ b/docs/SECURITY_CODEQL_TRIAGE.md @@ -468,3 +468,34 @@ if not os.path.realpath(p).startswith(os.path.realpath(PERSONA_DIR)): 都没有指向一个 open issue,Dependabot 又只会为 A1–A8(GHSA 有记录的那批)开单, A9–A16 那 8 条 PYSEC-only 的永远不会自己冒出来。要不要为这 16 条开一个显式跟踪 issue (标题挂 `transformers 4.52.x` + 截止 2026-12-31),留在第二阶段的四态清单里等一句话。 + +### 8.6 #155 合并后的重扫实测(2026-09-24,main=d875c9c) + +扫描:run 35901422189(push 触发,`headSha=d875c9c`,success)——**确认是真重扫**,不是沿用旧结果。 + +| 族 | 合并前 open | 重扫后 | 拆开看 | +|---|---|---|---| +| `py/path-injection` | 36 | **0** | 35 条被分析器判定 fixed;#14 由我按 `mitigated` 交差(该函数在 #155 之前就是正确实现) | +| `py/stack-trace-exposure` | 18 →(§8.4 后 12) | 21 | 7 条(#75/#78–#82/#88)判 fixed;**新冒出来 16 条 #114–#129** | + +那 16 条新告警不是新问题,而是**同一批流在被我改过行号的位置上重新登记**: +`model.py` 9 条 = §7.1 那批已 dismiss 的 `safe_error_message` 调用点(我的删函数动作把整段行号往上推了约 46 行); +`settings.py` 5 条 = 本 PR 刚改的 726/770/803/820/853;`persona.py` 1 条 = 323;`training.py` 1 条 = 542。 +逐条读过落点原文后全部按 `mitigated` 单独交差,理由绑定 `d875c9c` + 行号 + 测试名。 + +**这条要记住的操作口径**:dismiss 绑定的是**告警号**而不是代码事实,所以 +"改一次这些文件 = 行号推移 = 旧告警 closed/fixed + 同位置重生一批新号"。 +以后在这两族上做改动,预期成本是"再读一遍、再逐条交差一遍",而不是"上次已经做完了"。 + +另一个统计口径的坑,本次又踩到一次:`gh api …?state=fixed --paginate` 在结果集同时变动时 +**会重复返回记录**,我一度读到 fixed=62;用单次全量拉取按 `number` 去重后是 +**total 128 = dismissed 76 + fixed 52 + open 0**(112 旧 + 16 新;#113 缺号)。 +报数前先去重,别信 `--paginate` 的长度。 + +最后补一层没做过的验证:`tests/test_security_surface_http.py`(10 条)经**真实 ASGI 栈** +(CSRF 中间件 + 异常处理 + 路由匹配)复验三处行为收紧 —— 越界名不变 500、磁盘不留东西、 +失败响应不含服务端路径、拒绝文案仍带规则。这里有个反例值得写下来:第一版测试忘了 +先 `GET /` 取 `csrf_token` Cookie,于是所有写请求都以 **CSRF 403** 结束,5 条断言"全绿" +却一条都没打到业务分支 —— 加正向对照(合规但不存在的名字要回 400「不存在」)才暴露出来。 +**仍未覆盖**:`resolve_persona_ref` / `voxcpm2` 设计与剧本工坊三条生成路由的音色解析 +(要真的加载引擎才会走到,属 GPU 验收范围,交回人耳/人工侧)。 diff --git a/tests/test_security_surface_http.py b/tests/test_security_surface_http.py new file mode 100644 index 0000000..3a8ab27 --- /dev/null +++ b/tests/test_security_surface_http.py @@ -0,0 +1,120 @@ +"""HTTP 层复验 #155 的两处收敛:越界名字与异常文本都到不了响应体。 + +单元级断言(`test_path_guard.py` / `test_error_surface_leaks.py`)只证明函数本身; +这里经真实 ASGI 栈(CSRF 中间件 + 异常处理 + 路由匹配)跑一遍,锁三件事: + +1. 非法音色名不会变成 500,也不会在磁盘上留下任何东西; +2. 失败响应里不含服务端绝对路径; +3. 拒绝时仍给出可操作提示(用户知道该改什么)。 + +不碰 GPU:conftest 已把 ``CUDA_VISIBLE_DEVICES`` 置空并关掉自动加载模型。 +""" + +import sys +from pathlib import Path + +import pytest + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_APP_DIR = str(_PROJECT_ROOT / "app") +if _APP_DIR not in sys.path: + sys.path.insert(0, _APP_DIR) + +#: 响应体里一出现就说明服务端目录结构外泄了 +LEAK_MARKERS = ("C:\\", "D:\\", "/home/", "/Users/", "/srv/", "/app/", str(_PROJECT_ROOT)) + +#: 带目录成分的裸名:``%2F`` 那几条在路由层就匹配不到(404),另两条进白名单(400) +EVIL_NAMES = ["..%2Fevil", "%2e%2e%2f%2e%2e%2fetc%2fpasswd", "sub%2Fdir", ".hidden", "a" * 80] + +#: 最小合法 WAV 头(与 test_routes_htmx 同源),用于过 save_uploaded_audio 的魔术字节 +_WAV_BYTES: bytes = ( + b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00" + b"\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00LIST\x1a\x00\x00\x00INFOISFT" +) + + +def _csrf_headers(client) -> dict[str, str]: + """先 GET 一次拿 ``csrf_token`` Cookie。 + + 少了这一步,所有写请求都以 CSRF 403 结束,下面的断言就成了"假通过" —— + 正向对照(``test_legit_name_reaches_the_handler``)就是用来证明这条链真的通了。 + """ + client.get("/") + return {"X-CSRF-Token": client.cookies.get("csrf_token") or ""} + + +def _assert_no_server_path(body: str) -> None: + for marker in LEAK_MARKERS: + assert marker not in body, body[:400] + + +class TestPersonaDeleteSurface: + def test_legit_name_reaches_the_handler(self, client): + """正向对照:合规但不存在的名字要走到业务分支并回「不存在」。""" + resp = client.request("DELETE", "/api/persona/no_such_persona", headers=_csrf_headers(client)) + + assert resp.status_code == 400, resp.text[:200] + assert "不存在" in resp.text, resp.text[:200] + + @pytest.mark.parametrize("name", EVIL_NAMES) + def test_illegal_names_never_500_or_leak(self, client, name): + resp = client.request("DELETE", f"/api/persona/{name}", headers=_csrf_headers(client)) + + assert resp.status_code in (400, 404), (name, resp.status_code, resp.text[:200]) + _assert_no_server_path(resp.text) + + def test_rejection_still_gives_actionable_hint(self, client): + """白名单拒绝时必须说清规则,不能只回「失败」。""" + resp = client.request("DELETE", "/api/persona/bad%20name%21", headers=_csrf_headers(client)) + + assert resp.status_code == 400 + assert "1-50" in resp.text, resp.text[:300] + + +class TestSettingsErrorSurface: + def test_permission_error_returns_fixed_hint(self, client, monkeypatch): + from integrated_app.routes.system import settings + + async def _denied(*_a, **_k): + raise OSError(13, "Permission denied", "/srv/tts/config/generation_defaults.json") + + monkeypatch.setattr(settings, "_load_json_file", _denied) + resp = client.get("/api/system/generation_defaults") + + assert resp.status_code == 200 + assert resp.json()["status"] == "error" + assert "/srv/tts" not in resp.text + _assert_no_server_path(resp.text) + + def test_unknown_error_redacts_to_placeholder(self, client, monkeypatch): + from integrated_app.routes.system import settings + + async def _boom(*_a, **_k): + raise RuntimeError("解析 /srv/tts/config/generation_defaults.json 失败") + + monkeypatch.setattr(settings, "_load_json_file", _boom) + resp = client.get("/api/system/generation_defaults") + + assert "[PATH]" in resp.text, resp.text[:200] + assert "/srv/tts" not in resp.text + + +class TestPersonaSaveSurface: + def test_traversal_name_rejected_without_writing_anything(self, client, tmp_path, monkeypatch): + from integrated_app import persona_manager as pm + + root = tmp_path / "personas" + root.mkdir() + monkeypatch.setattr(pm, "PERSONA_DIR", str(root)) + + resp = client.post( + "/api/persona/save", + data={"save_name": "../escaped", "ref_text": "x", "has_consent": "true"}, + files={"ref_audio": ("a.wav", _WAV_BYTES, "audio/wav")}, + headers=_csrf_headers(client), + ) + + assert resp.status_code == 200, resp.text[:200] + assert "不合法" in resp.text, resp.text[:300] + assert list(root.iterdir()) == [], "非法名不得在 PERSONA_DIR 内留下半截文件" + assert not (tmp_path / "escaped.wav").exists(), "非法名不得写到 PERSONA_DIR 之外"