fix: 品牌下发审查修复(esc统一/语料中性化/穿越加固/反向断言动态化)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,保证同浏览器多租户互不串
|
||||
|
||||
@@ -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 & 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
@@ -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;">
|
||||
|
||||
@@ -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 # 落静态兜底头像
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user