"""Task 10: /api/public/brand + /api/public/avatar——品牌白名单下发。 红线: - 品牌端点**显式字段白名单**序列化(BRAND_FIELDS)——绝不序列化整个 config(api_key/smtp 等内部字段永不出现在响应); - 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载); - ?site= 站点覆盖(空域名站点如 es 靠嵌入代码 data-site 接入), 未知 site 回退租户默认门面; - 头像按租户解析 seed_profiles//assets/,两租户各归各; - avatar 文件名解析拒绝路径穿越(PurePosixPath 不认反斜杠,Windows 下 反斜杠相对路径会真穿出资产目录 serve 任意文件)。 """ import json import shutil from pathlib import Path from tests.conftest import ( JIANCAI_HOST, SEED_DIR, app_test_client, build_app, ) REPO = Path(__file__).resolve().parents[1] BRAND_EXPECTED = {"slug", "title", "color", "avatar", "welcome", "topics", "placeholder", "whatsapp", "email", "privacy_url"} def test_brand_whitelist_only(app_client): r = app_client.get("/api/public/brand") assert r.status_code == 200 assert set(r.json().keys()) == BRAND_EXPECTED # 白名单,绝不多吐 assert "api_key" not in r.text and "smtp" not in r.text.lower() assert r.headers["access-control-allow-origin"] == "*" def test_brand_site_overlay(app_client): h = {"host": "chat2.agent.hnxinyouhe.com"} r = app_client.get("/api/public/brand?site=es", headers=h) assert r.json()["title"] == "HESUPIPE Soporte" # 站点覆盖生效 r2 = app_client.get("/api/public/brand", headers=h) assert r2.json()["title"] == "HESUPIPE Support" # 不带 site = 租户默认门面 r3 = app_client.get("/api/public/brand?site=nope", headers=h) assert r3.json()["title"] == "HESUPIPE Support" # 未知 site 回退 def test_brand_carries_tenant_slug(app_client): # slug 供客户端拼租户化 key(host id / storage key),jiancai 域必回 jiancai r = app_client.get("/api/public/brand") assert r.json()["slug"] == "jiancai" def test_avatar_served_per_tenant(app_client): r = app_client.get("/api/public/avatar", headers={"host": "chat.agent.hnxinyouhe.com"}) assert r.status_code == 200 and r.headers["content-type"].startswith("image/") # 两租户头像内容不同(字节级对比两租户的 avatar 响应) r_jc = app_client.get("/api/public/avatar") # base_url = jiancai 域 assert r_jc.status_code == 200 assert r.content != r_jc.content def test_avatar_rejects_windows_backslash_traversal(tmp_path): """复审加固: brand.avatar 含反斜杠/斜杠/. 或 .. 时必须回退静态兜底。 PurePosixPath 把 "..\\..\\..\\secret.txt" 当单个组件留在 .name 里, Windows 文件系统却会把反斜杠当分隔符——不加守卫时 seed_dir/assets/../../..\\secret.txt 真解析到资产目录外(serve 任意 文件)。目标文件真实存在于穿越终点,守卫失效即红。""" seed = tmp_path / "seed" shutil.copytree(SEED_DIR, seed) tenant_json = seed / "jiancai" / "tenant.json" data = json.loads(tenant_json.read_text(encoding="utf-8")) data["config"]["brand"]["avatar"] = "..\\..\\..\\secret.txt" tenant_json.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") # 穿越终点: /seed/jiancai/assets 起三个 ".." → /secret.txt secret = tmp_path / "secret.txt" secret.write_bytes(b"TOPSECRET-should-never-be-served") a = build_app(seed, tmp_path / "data") with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c: r = c.get("/api/public/avatar") assert r.status_code == 200 assert r.headers["content-type"].startswith("image/") assert b"TOPSECRET" not in r.content # 穿越目标不被 serve fallback = (REPO / "static" / "kefu-small.png").read_bytes() assert r.content == fallback # 落静态兜底头像