Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/SECURITY_CODEQL_TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 验收范围,交回人耳/人工侧)。
120 changes: 120 additions & 0 deletions tests/test_security_surface_http.py
Original file line number Diff line number Diff line change
@@ -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 之外"
Loading