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)
# Task 10: 客户端自报 site(空域名站点如 es 靠嵌入代码 data-site
# 接入)记入 client_info 作会话站点标——只加记录,不参与业务逻辑
# 接入)作会话站点标——只加记录,不参与业务逻辑;写入点在下方
# 新建会话处(优先级对齐 REST,见那里注释)
site_slug = str(init_msg.get("site") or "").strip()
if site_slug:
client_info["site"] = site_slug
if "session_id" in init_msg:
conv = await manager.get_by_session(init_msg["session_id"])
@@ -84,6 +83,11 @@ async def ws_chat(websocket: WebSocket):
# 无 XFF 时保留客户端自报,user_agent 等其他字段不受影响
if 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(
source=source,
client_info=merged_info,
+5
View File
@@ -76,6 +76,11 @@ async def avatar(request: Request):
# "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径
# 逃逸面(单成分文件名不可能越出 assets/)
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
if candidate is not None and candidate.is_file():
return FileResponse(
+1 -1
View File
@@ -30,7 +30,7 @@
<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 class="help-description">Ask about products, quotations, MOQ, shipping and delivery.</div>
</div>
<div class="help-actions">
<!-- Task 10: 联系按钮/标题/欢迎语/话题/隐私链接由 chat.js 从
+6 -10
View File
@@ -86,9 +86,12 @@ var chatBrandReady = (function () {
var em = contactHref(emailAttr || b.email || "", "mail");
if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; }
if (b.placeholder) inputEl.placeholder = b.placeholder;
var firstMsg = document.querySelector("#messages .message");
var bubble = firstMsg && firstMsg.querySelector(".bubble");
if (b.welcome) {
var bubble = document.querySelector("#messages .message .bubble");
if (bubble) bubble.textContent = b.welcome;
} else if (firstMsg) {
firstMsg.style.display = "none"; // 空 welcome 不渲染空气泡(与挂件行为对齐)
}
if (Array.isArray(b.topics) && b.topics.length) {
var grid = document.querySelector(".popular-topics-grid");
@@ -286,15 +289,8 @@ var chatBrandReady = (function () {
}
});
// Popular topics buttons(出厂模板的兜底话题;brand 下发后由 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);
});
});
// (出厂模板话题区为空——话题按钮只由 applyBrand 重建并自带绑定,
// 无静态兜底绑定段)
// Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或
// 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串
+8 -6
View File
@@ -39,11 +39,10 @@
<p><strong>Expected behavior:</strong></p>
<ul class="test-list">
<li>Invite card appears after 5 seconds</li>
<li>Invite card has blue gradient header with robot emoji</li>
<li>Three topic buttons: "Get Quotation", "Pipe Specifications" and "Delivery &amp; Shipping"</li>
<li>Invite card has brand-color gradient header with robot emoji</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>Click "Get Quotation" → invite closes → chat opens → message auto-sends</li>
<li>Click "Pipe Specifications" → invite closes → chat opens → message auto-sends</li>
<li>Click a topic button → invite closes → chat opens → message auto-sends</li>
<li>Click "Start Chat" → invite closes → chat opens</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>
@@ -55,7 +54,7 @@
<div class="info">
<h2>Popular Topics in Chat Panel</h2>
<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>
</ul>
</div>
@@ -108,7 +107,7 @@
"data-mobile": "true",
"data-debug": "true",
"data-whatsapp": "https://wa.me/8613800138000",
"data-email": "mailto:demo@hesupipe.com"
"data-email": "mailto:demo@example.com"
};
var rebuildLoading = false, rebuildPending = null;
@@ -116,6 +115,9 @@
// Task 10: host id 已租户化(cc-widget-host-<slug>),按前缀清扫
var hosts = document.querySelectorAll('[id^="cc-widget-host-"]');
for (var i = 0; i < hosts.length; i++) hosts[i].remove();
// 复审: 清掉旧挂件的 debug 钩子——waitForWidget 轮询它,旧钩子残留
// 会让重建中的断言误读"新挂件已挂载"
try { delete window.__chatWidgetDebug; } catch (e) {}
}
function rebuildWidget(extra) {
if (rebuildLoading) { rebuildPending = extra || {}; return; } // 快速连点:只记最新意图,当前加载完接力重建
+6 -6
View File
@@ -388,7 +388,7 @@
panel.className = "chat-panel";
panel.innerHTML = [
'<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="actions">',
'<button class="action-btn" aria-label="Minimize">−</button>',
@@ -396,7 +396,7 @@
'</div></div>',
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
'<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 class="chat-separator"></div>',
'<div class="panel-messages" id="w-msgs">',
@@ -416,10 +416,10 @@
'<div class="invite-panel hide">',
'<div class="invite-header">',
'<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 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 class="invite-header-actions">',
'<button class="invite-close-btn" aria-label="Close">',
@@ -467,13 +467,13 @@
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) {
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>
`;
} else {
toggleBtn.innerHTML = `
<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>
<div style="color:white;">
+42 -1
View File
@@ -6,8 +6,22 @@
- 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载);
- ?site=<slug> 站点覆盖(空域名站点如 es 靠嵌入代码 data-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",
"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 域
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")
# 穿越终点: <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 json
from pathlib import Path
from urllib.parse import urlparse
from sqlalchemy import select
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"]
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():
"""等价迁移锚:三份客户端文件不再内嵌任何租户品牌值(联系号/邮箱/品牌名/
隐私 URL),品牌只经 /api/public/brand 下发。"""
for rel in ("widget.js", "js/chat.js", "chat.html"):
"""等价迁移锚:四份客户端文件不再内嵌任何租户品牌值(联系号/邮箱/品牌名/
隐私 URL/welcome/topics),品牌只经 /api/public/brand 下发。禁词表动态
收集自 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)
assert "HESUPIPE" not in src, f"{rel} 残留品牌名"
assert "8615937108317" not in src and "8619037179250" not in src, f"{rel} 残留联系号"
assert "hesuu.com" not in src and "borderlesscar.com" not in src, f"{rel} 残留邮箱域名"
assert "hesupipe.com" not in src, f"{rel} 残留隐私 URL 域名"
for v in forbidden:
assert v not in src, f"{rel} 残留品牌值: {v!r}"
# ---- 服务端配套:会话打站点标 ---------------------------------------------------
@@ -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):
sid = app_client.post("/api/chat/session", json={}).json()["session_id"]
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"