fix: 品牌下发审查修复(esc统一/语料中性化/穿越加固/反向断言动态化)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 22:52:08 +08:00
parent bbc7bc520e
commit 7697673a3b
8 changed files with 137 additions and 34 deletions
+7 -3
View File
@@ -49,10 +49,9 @@ async def ws_chat(websocket: WebSocket):
init_msg = json.loads(init_data) init_msg = json.loads(init_data)
# Task 10: 客户端自报 site(空域名站点如 es 靠嵌入代码 data-site # Task 10: 客户端自报 site(空域名站点如 es 靠嵌入代码 data-site
# 接入)记入 client_info 作会话站点标——只加记录,不参与业务逻辑 # 接入)作会话站点标——只加记录,不参与业务逻辑;写入点在下方
# 新建会话处(优先级对齐 REST,见那里注释)
site_slug = str(init_msg.get("site") or "").strip() site_slug = str(init_msg.get("site") or "").strip()
if site_slug:
client_info["site"] = site_slug
if "session_id" in init_msg: if "session_id" in init_msg:
conv = await manager.get_by_session(init_msg["session_id"]) conv = await manager.get_by_session(init_msg["session_id"])
@@ -84,6 +83,11 @@ async def ws_chat(websocket: WebSocket):
# 无 XFF 时保留客户端自报,user_agent 等其他字段不受影响 # 无 XFF 时保留客户端自报,user_agent 等其他字段不受影响
if forwarded: if forwarded:
merged_info["ip"] = forwarded merged_info["ip"] = forwarded
# Task 10 复审: 专用 site 字段同样赢过 init_msg.client_info
# 自报的 site(与 REST ?site= 覆盖 req.client_info 的优先级
# 对齐——专用通道是嵌入代码显式声明,可信度高于任意自报 dict)
if site_slug:
merged_info["site"] = site_slug
conv = await manager.create_conversation( conv = await manager.create_conversation(
source=source, source=source,
client_info=merged_info, client_info=merged_info,
+5
View File
@@ -76,6 +76,11 @@ async def avatar(request: Request):
# "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径 # "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径
# 逃逸面(单成分文件名不可能越出 assets/) # 逃逸面(单成分文件名不可能越出 assets/)
name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name
# 复审加固: PurePosixPath 不认反斜杠(整串留在 .name 里),Windows 文件
# 系统却把反斜杠当分隔符——seed_dir/assets/.. 会真穿出资产目录 serve
# 任意文件。显式拒绝分隔符与目录点,命中即回退静态兜底。
if name in {".", ".."} or "/" in name or "\\" in name:
name = ""
candidate = runtime.seed_dir / "assets" / name if name else None candidate = runtime.seed_dir / "assets" / name if name else None
if candidate is not None and candidate.is_file(): if candidate is not None and candidate.is_file():
return FileResponse( return FileResponse(
+1 -1
View File
@@ -30,7 +30,7 @@
<div style="display:flex;align-items:center;gap:14px;"> <div style="display:flex;align-items:center;gap:14px;">
<div class="help-content"> <div class="help-content">
<div class="help-title">How can we help?</div> <div class="help-title">How can we help?</div>
<div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div> <div class="help-description">Ask about products, quotations, MOQ, shipping and delivery.</div>
</div> </div>
<div class="help-actions"> <div class="help-actions">
<!-- Task 10: 联系按钮/标题/欢迎语/话题/隐私链接由 chat.js 从 <!-- Task 10: 联系按钮/标题/欢迎语/话题/隐私链接由 chat.js 从
+6 -10
View File
@@ -86,9 +86,12 @@ var chatBrandReady = (function () {
var em = contactHref(emailAttr || b.email || "", "mail"); var em = contactHref(emailAttr || b.email || "", "mail");
if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; } if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; }
if (b.placeholder) inputEl.placeholder = b.placeholder; if (b.placeholder) inputEl.placeholder = b.placeholder;
var firstMsg = document.querySelector("#messages .message");
var bubble = firstMsg && firstMsg.querySelector(".bubble");
if (b.welcome) { if (b.welcome) {
var bubble = document.querySelector("#messages .message .bubble");
if (bubble) bubble.textContent = b.welcome; if (bubble) bubble.textContent = b.welcome;
} else if (firstMsg) {
firstMsg.style.display = "none"; // 空 welcome 不渲染空气泡(与挂件行为对齐)
} }
if (Array.isArray(b.topics) && b.topics.length) { if (Array.isArray(b.topics) && b.topics.length) {
var grid = document.querySelector(".popular-topics-grid"); var grid = document.querySelector(".popular-topics-grid");
@@ -286,15 +289,8 @@ var chatBrandReady = (function () {
} }
}); });
// Popular topics buttons(出厂模板的兜底话题;brand 下发后由 applyBrand // (出厂模板话题区为空——话题按钮只由 applyBrand 重建并自带绑定,
// 重建,这里的绑定覆盖"brand 失败"路径) // 无静态兜底绑定段)
var topicBtns = document.querySelectorAll(".popular-topic-btn");
topicBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
var topic = this.getAttribute("data-topic");
sendTopicMessage(topic);
});
});
// Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或 // Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或
// 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串 // 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串
+8 -6
View File
@@ -39,11 +39,10 @@
<p><strong>Expected behavior:</strong></p> <p><strong>Expected behavior:</strong></p>
<ul class="test-list"> <ul class="test-list">
<li>Invite card appears after 5 seconds</li> <li>Invite card appears after 5 seconds</li>
<li>Invite card has blue gradient header with robot emoji</li> <li>Invite card has brand-color gradient header with robot emoji</li>
<li>Three topic buttons: "Get Quotation", "Pipe Specifications" and "Delivery &amp; Shipping"</li> <li>Topic buttons come from the tenant brand config (<code>/api/public/brand</code> → topics)</li>
<li>Action buttons: "Start Chat" and "No Thanks"</li> <li>Action buttons: "Start Chat" and "No Thanks"</li>
<li>Click "Get Quotation" → invite closes → chat opens → message auto-sends</li> <li>Click a topic button → invite closes → chat opens → message auto-sends</li>
<li>Click "Pipe Specifications" → invite closes → chat opens → message auto-sends</li>
<li>Click "Start Chat" → invite closes → chat opens</li> <li>Click "Start Chat" → invite closes → chat opens</li>
<li>Click "No Thanks" → invite permanently closes</li> <li>Click "No Thanks" → invite permanently closes</li>
<li>Click "No Thanks" or X → refresh → invite stays hidden AND the old card area stays clickable (no invisible overlay; regression 2026-09-01)</li> <li>Click "No Thanks" or X → refresh → invite stays hidden AND the old card area stays clickable (no invisible overlay; regression 2026-09-01)</li>
@@ -55,7 +54,7 @@
<div class="info"> <div class="info">
<h2>Popular Topics in Chat Panel</h2> <h2>Popular Topics in Chat Panel</h2>
<ul class="test-list"> <ul class="test-list">
<li>Chat panel shows "POPULAR TOPICS" section with two buttons</li> <li>Chat panel shows "POPULAR TOPICS" section with the tenant's topic buttons</li>
<li>Click topic → message auto-sends → topics section hides</li> <li>Click topic → message auto-sends → topics section hides</li>
</ul> </ul>
</div> </div>
@@ -108,7 +107,7 @@
"data-mobile": "true", "data-mobile": "true",
"data-debug": "true", "data-debug": "true",
"data-whatsapp": "https://wa.me/8613800138000", "data-whatsapp": "https://wa.me/8613800138000",
"data-email": "mailto:demo@hesupipe.com" "data-email": "mailto:demo@example.com"
}; };
var rebuildLoading = false, rebuildPending = null; var rebuildLoading = false, rebuildPending = null;
@@ -116,6 +115,9 @@
// Task 10: host id 已租户化(cc-widget-host-<slug>),按前缀清扫 // Task 10: host id 已租户化(cc-widget-host-<slug>),按前缀清扫
var hosts = document.querySelectorAll('[id^="cc-widget-host-"]'); var hosts = document.querySelectorAll('[id^="cc-widget-host-"]');
for (var i = 0; i < hosts.length; i++) hosts[i].remove(); for (var i = 0; i < hosts.length; i++) hosts[i].remove();
// 复审: 清掉旧挂件的 debug 钩子——waitForWidget 轮询它,旧钩子残留
// 会让重建中的断言误读"新挂件已挂载"
try { delete window.__chatWidgetDebug; } catch (e) {}
} }
function rebuildWidget(extra) { function rebuildWidget(extra) {
if (rebuildLoading) { rebuildPending = extra || {}; return; } // 快速连点:只记最新意图,当前加载完接力重建 if (rebuildLoading) { rebuildPending = extra || {}; return; } // 快速连点:只记最新意图,当前加载完接力重建
+6 -6
View File
@@ -388,7 +388,7 @@
panel.className = "chat-panel"; panel.className = "chat-panel";
panel.innerHTML = [ panel.innerHTML = [
'<div class="panel-header">', '<div class="panel-header">',
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>', '<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+esc(avatarUrl)+'"></div>',
'<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>', '<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="actions">', '<div class="actions">',
'<button class="action-btn" aria-label="Minimize">−</button>', '<button class="action-btn" aria-label="Minimize">−</button>',
@@ -396,7 +396,7 @@
'</div></div>', '</div></div>',
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>', '<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
'<div class="panel-top">', '<div class="panel-top">',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>', '<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about products, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>',
'</div>', '</div>',
'<div class="chat-separator"></div>', '<div class="chat-separator"></div>',
'<div class="panel-messages" id="w-msgs">', '<div class="panel-messages" id="w-msgs">',
@@ -416,10 +416,10 @@
'<div class="invite-panel hide">', '<div class="invite-panel hide">',
'<div class="invite-header">', '<div class="invite-header">',
'<div class="invite-icon-wrapper">', '<div class="invite-icon-wrapper">',
'<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"><span class="invite-status-dot"></span></div>', '<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+esc(avatarUrl)+'"><span class="invite-status-dot"></span></div>',
'</div>', '</div>',
'<div class="invite-header-content">', '<div class="invite-header-content">',
'<div class="invite-title">Chat with us for pipe specs, MOQ, delivery timing and quotations.</div>', '<div class="invite-title">Chat with us for products, quotations and delivery.</div>',
'</div>', '</div>',
'<div class="invite-header-actions">', '<div class="invite-header-actions">',
'<button class="invite-close-btn" aria-label="Close">', '<button class="invite-close-btn" aria-label="Close">',
@@ -467,13 +467,13 @@
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) { if (isMobile) {
toggleBtn.innerHTML = ` toggleBtn.innerHTML = `
<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"><span class="mobile-dot"></span></div> <div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${esc(avatarUrl)}"><span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span> <span class="mobile-label">Chat with us</span>
`; `;
} else { } else {
toggleBtn.innerHTML = ` toggleBtn.innerHTML = `
<div class="toggle-avatar-wrap"> <div class="toggle-avatar-wrap">
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"></div> <div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${esc(avatarUrl)}"></div>
<div class="toggle-avatar-dot"></div> <div class="toggle-avatar-dot"></div>
</div> </div>
<div style="color:white;"> <div style="color:white;">
+42 -1
View File
@@ -6,8 +6,22 @@
- 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载); - 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载);
- ?site=<slug> 站点覆盖(空域名站点如 es 靠嵌入代码 data-site 接入), - ?site=<slug> 站点覆盖(空域名站点如 es 靠嵌入代码 data-site 接入),
未知 site 回退租户默认门面; 未知 site 回退租户默认门面;
- 头像按租户解析 seed_profiles/<slug>/assets/,两租户各归各。 - 头像按租户解析 seed_profiles/<slug>/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", BRAND_EXPECTED = {"slug", "title", "color", "avatar", "welcome", "topics",
"placeholder", "whatsapp", "email", "privacy_url"} "placeholder", "whatsapp", "email", "privacy_url"}
@@ -44,3 +58,30 @@ def test_avatar_served_per_tenant(app_client):
r_jc = app_client.get("/api/public/avatar") # base_url = jiancai 域 r_jc = app_client.get("/api/public/avatar") # base_url = jiancai 域
assert r_jc.status_code == 200 assert r_jc.status_code == 200
assert r.content != r_jc.content 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")
# 穿越终点: <tmp>/seed/jiancai/assets 起三个 ".." → <tmp>/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 # 落静态兜底头像
+62 -7
View File
@@ -8,6 +8,7 @@
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
@@ -72,15 +73,47 @@ def test_brand_values_live_in_seed_profiles():
assert ab["topics"] == ["Get Quotation", "Distributor Cooperation"] assert ab["topics"] == ["Get Quotation", "Distributor Cooperation"]
def _brand_identity_values():
"""复审动态化: 从全部 seed_profiles/*/tenant.json(租户默认门面 + 各站点
覆盖)收集品牌身份值作禁词表——第三租户落地即自动纳管,无需改本测试。
只收身份字段(title/whatsapp/email/privacy_url/welcome/topics);
color 与 avatar 文件名是中性 UI 默认(widget.js 的 DEFAULT_COLOR=
#066fc0 恰等于 jiancai 品牌色、DEFAULT_AVATAR_PATH 兜底文件同名),
收进来会误伤合法兜底。privacy_url 额外拆主机名(含去 www. 裸域名),
whatsapp 额外收去 + 变体(客户端硬编码裸号也算残留)。"""
values = set()
for tenant_file in sorted(SEED.glob("*/tenant.json")):
cfg = json.loads(tenant_file.read_text(encoding="utf-8"))
brands = [cfg.get("config", {}).get("brand") or {}]
brands += [s.get("brand") or {} for s in cfg.get("sites", [])]
for b in brands:
for key in ("title", "whatsapp", "email", "privacy_url", "welcome"):
v = str(b.get(key) or "").strip()
if v:
values.add(v)
values.add(v.lstrip("+"))
for t in b.get("topics") or []:
if str(t).strip():
values.add(str(t).strip())
host = urlparse(str(b.get("privacy_url") or "")).hostname or ""
if host:
values.add(host)
if host.startswith("www."):
values.add(host[4:])
assert values, "禁词表收集为空——seed_profiles 结构变了?"
return values
def test_no_brand_copy_left_in_client_files(): def test_no_brand_copy_left_in_client_files():
"""等价迁移锚:三份客户端文件不再内嵌任何租户品牌值(联系号/邮箱/品牌名/ """等价迁移锚:四份客户端文件不再内嵌任何租户品牌值(联系号/邮箱/品牌名/
隐私 URL),品牌只经 /api/public/brand 下发。""" 隐私 URL/welcome/topics),品牌只经 /api/public/brand 下发。禁词表动态
for rel in ("widget.js", "js/chat.js", "chat.html"): 收集自 seed_profiles(见 _brand_identity_values)。"""
forbidden = _brand_identity_values()
for rel in ("widget.js", "js/chat.js", "chat.html", "widget-test.html"):
src = _read(rel) src = _read(rel)
assert "HESUPIPE" not in src, f"{rel} 残留品牌名" for v in forbidden:
assert "8615937108317" not in src and "8619037179250" not in src, f"{rel} 残留联系号" assert v not in src, f"{rel} 残留品牌值: {v!r}"
assert "hesuu.com" not in src and "borderlesscar.com" not in src, f"{rel} 残留邮箱域名"
assert "hesupipe.com" not in src, f"{rel} 残留隐私 URL 域名"
# ---- 服务端配套:会话打站点标 --------------------------------------------------- # ---- 服务端配套:会话打站点标 ---------------------------------------------------
@@ -125,3 +158,25 @@ def test_rest_session_site_query_recorded(app_client, tmp_path):
def test_rest_session_without_site_no_tag(app_client, tmp_path): def test_rest_session_without_site_no_tag(app_client, tmp_path):
sid = app_client.post("/api/chat/session", json={}).json()["session_id"] sid = app_client.post("/api/chat/session", json={}).json()["session_id"]
assert "site" not in _read_client_info(tmp_path / "jiancai.db", sid) assert "site" not in _read_client_info(tmp_path / "jiancai.db", sid)
# ---- 复审: 专用 site 通道优先级(WS 与 REST 对齐) -------------------------------
def test_ws_dedicated_site_wins_over_self_reported(app_client, tmp_path):
"""WS init 同时带专用 site 字段与 client_info 自报 site 时,专用字段赢
(与 REST ?site= 覆盖 req.client_info 的优先级对齐——专用通道是嵌入
代码显式声明,可信度高于任意自报 dict)。"""
with app_client.websocket_connect("/ws/chat") as ws:
ws.send_text(json.dumps({"source": "widget", "site": "es",
"client_info": {"site": "main"}}))
sid = ws.receive_json()["session_id"]
assert _read_client_info(tmp_path / "jiancai.db", sid).get("site") == "es"
def test_rest_site_query_wins_over_body_client_info(app_client, tmp_path):
# 同上语义的 REST 钉子: ?site= 覆盖请求体 client_info 自报的 site
sid = app_client.post(
"/api/chat/session?site=es", json={"client_info": {"site": "main"}}
).json()["session_id"]
assert _read_client_info(tmp_path / "jiancai.db", sid).get("site") == "es"