From 79b916f44cce60eb5e44e4a45bb829a515c5573a Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 5 Aug 2026 00:10:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=20show=5Fhtml?= =?UTF-8?q?=20=E6=B5=81=E5=BC=8F=E6=B8=B2=E6=9F=93=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E5=9B=BA=E5=8D=A1=E7=89=87=E6=B2=99=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderMarkdownText 透传 isStreaming:恢复 js=off 实时渲染与 js=on 渲染中占位(回归根因:MarkdownRenderer 未透传流式标志) - js=on 沙箱加固:srcdoc 强制注入 CSP meta(connect-src 'none' 等)与滚动守卫脚本(scrollIntoView/focus 内部化),iframe 加 allow="fullscreen 'none'" - 卡片尺寸从 JS 内联像素改为 CSS min()+aspect-ratio 自适应:流式/历史宽度一致,resize 自动跟随 - 修复卡片 wrapper 叠加 margin 导致底部圆角被 overflow:hidden 裁切 --- static/src/app/bootstrap.ts | 324 ++++++++++-------- .../src/components/chat/MarkdownRenderer.vue | 3 +- static/src/composables/useMarkdownRenderer.ts | 6 +- .../styles/components/chat/_chat-area.scss | 25 ++ 4 files changed, 216 insertions(+), 142 deletions(-) diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index 9fee17b8..9e228999 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -168,14 +168,8 @@ const SHOW_HTML_RATIO_VALUES: Record = { '4:3': 4 / 3, '3:4': 3 / 4 }; -// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应) -const SHOW_HTML_RATIO_BASE_SIZE: Record = { - '1:1': { width: 620, height: 620 }, - '16:9': { width: 620, height: 349 }, - '9:16': { width: 620, height: 1102 }, - '4:3': { width: 620, height: 465 }, - '3:4': { width: 620, height: 827 } -}; +// 卡片尺寸的基准像素与上限约束由 CSS 表达(见 _chat-area.scss 的 show_html[data-rendered] 规则), +// 此处仅保留比例数值用于写入 --show-html-ar CSS 变量。 function normalizeShowHtmlRatio(raw: string | null) { if (!raw) return '1:1'; @@ -224,34 +218,7 @@ function readShowHtmlJsMode(node: Element) { return normalizeShowHtmlJsMode(node.getAttribute('js')); } -function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) { - const ratio = SHOW_HTML_RATIO_VALUES[ratioKey] || 1; - const base = SHOW_HTML_RATIO_BASE_SIZE[ratioKey] || SHOW_HTML_RATIO_BASE_SIZE['1:1']; - const windowW = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 320); - const windowH = Math.max(360, window.innerHeight || document.documentElement.clientHeight || 360); - const parentRect = node.parentElement?.getBoundingClientRect(); - const flowRect = - node.closest('.messages-flow')?.getBoundingClientRect() || - node.closest('.message-text')?.getBoundingClientRect() || - node.closest('.text-content')?.getBoundingClientRect() || - node.closest('.messages-area')?.getBoundingClientRect() || - null; - const availableWidth = Math.max( - 220, - Math.floor(Math.min(flowRect?.width || windowW, parentRect?.width || windowW) - 8) - ); - // 移除“二次约束”后:窗口/容器足够大时,严格使用 prompt 约定的基准像素; - // 仅在物理空间不足时按比例等比缩小(不再做额外的最小/最大高度钳制)。 - // 最大卡片约束:上限按 1200x900(窗口不足时仍按可用空间缩放) - const limitWidth = Math.max(220, Math.min(availableWidth, Math.floor(windowW * 0.96), 1200)); - const limitHeight = Math.max(180, Math.min(Math.floor(windowH * 0.9), 900)); - const widthScale = limitWidth / base.width; - const heightScale = limitHeight / base.height; - const scale = Math.min(1, widthScale, heightScale); - const widthPx = Math.max(220, Math.round(base.width * scale)); - const heightPx = Math.round(widthPx / ratio); - return { widthPx, heightPx, ratio }; -} + function decodeBase64Utf8(input: string) { if (!input) return ''; @@ -326,15 +293,171 @@ function sanitizeShowHtmlContent(rawHtml: string) { return doc.body.innerHTML; } +/** + * show_html js=on 沙箱 CSP(通过 srcdoc 内 交付,必须位于任何资源引用之前)。 + * - connect-src 'none':禁 fetch/XHR/WebSocket/EventSource,杜绝数据外发与内网探测 + * - script/style/img/font/media 允许 https::保留卡片引用 CDN 资源与外链图的能力 + * (残余风险:GET 类资源加载可作为信标外发少量数据,已与需求方确认接受) + * - frame/child/worker-src 'none':禁嵌套远程框架与 Worker;object/form/base 收紧 + * 注:iframe 的 csp 属性对 srcdoc 的支持仍是未决规范(w3c/webappsec-csp#492),故走 meta 交付。 + */ +const SHOW_HTML_IFRAME_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline' https:", + "style-src 'unsafe-inline' https:", + "img-src data: blob: https:", + "font-src data: https:", + "media-src data: blob: https:", + "connect-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "object-src 'none'" +].join('; '); + +/** + * show_html js=on 沙箱守卫脚本(注入 srcdoc 最前,先于任何卡片脚本执行)。 + * 解决问题:iframe 内 scrollIntoView()/focus() 的滚动副作用会穿透 iframe 边界滚动宿主页面 + * (CSSOM View 规范行为,sandbox 属性与 CSS 均无法声明式禁止,见 w3c/csswg-drafts#7134)。 + * 策略:重写相关 API,使滚动只发生在本 iframe 文档内部;拦截同文档锚点导航的默认滚动; + * 对同源子 frame 递归修补以收窄“干净原型”绕过窗口。 + * 已知边界:不防“针对本守卫的确定性绕过”(威胁模型为提示注入生成的一般恶意内容)。 + */ +const SHOW_HTML_IFRAME_GUARD_SCRIPT = `(function () { + 'use strict'; + if (window.__astrionSandboxGuard) return; + window.__astrionSandboxGuard = true; + + function scrollingContainerList(el) { + var list = []; + var node = el.parentElement; + while (node && node !== document.body && node !== document.documentElement) { + var s = window.getComputedStyle(node); + if (/(auto|scroll|overlay)/.test(String(s.overflow) + String(s.overflowY) + String(s.overflowX))) { + list.push(node); + } + node = node.parentElement; + } + list.push(document.scrollingElement || document.documentElement); + return list; + } + + function scrollOneContainer(container, el, block) { + var isRoot = container === (document.scrollingElement || document.documentElement); + var eRect = el.getBoundingClientRect(); + var viewH = isRoot ? window.innerHeight : container.clientHeight; + var top = isRoot ? eRect.top : eRect.top - container.getBoundingClientRect().top; + var bottom = top + eRect.height; + var delta = 0; + if (block === 'start') delta = top; + else if (block === 'end') delta = bottom - viewH; + else if (block === 'center') delta = top - (viewH - eRect.height) / 2; + else if (top < 0) delta = top; + else if (bottom > viewH) delta = Math.min(bottom - viewH, top); + if (delta) container.scrollTop += delta; + } + + function localScrollIntoView(el, arg) { + var block = 'start'; + if (arg === false) block = 'end'; + else if (arg && typeof arg === 'object' && typeof arg.block === 'string') block = arg.block; + var containers = scrollingContainerList(el); + for (var i = 0; i < containers.length; i++) { + scrollOneContainer(containers[i], el, block); + } + } + + Element.prototype.scrollIntoView = function (arg) { localScrollIntoView(this, arg); }; + if ('scrollIntoViewIfNeeded' in Element.prototype) { + Element.prototype.scrollIntoViewIfNeeded = function () { localScrollIntoView(this, { block: 'nearest' }); }; + } + + var origFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options) { + var opts = options && typeof options === 'object' + ? Object.assign({}, options, { preventScroll: true }) + : { preventScroll: true }; + origFocus.call(this, opts); + localScrollIntoView(this, { block: 'nearest' }); + }; + + document.addEventListener('click', function (ev) { + var t = ev.target; + var anchor = t && t.closest ? t.closest('a[href^="#"]') : null; + if (!anchor) return; + var id = (anchor.getAttribute('href') || '').slice(1); + ev.preventDefault(); + if (!id) return; + var target = document.getElementById(id); + if (target) { + // 先摘掉 id 再更新 hash,避免浏览器默认锚点滚动穿透到宿主页面 + target.removeAttribute('id'); + try { window.location.hash = id; } catch (err) { /* opaque origin 下忽略 */ } + target.setAttribute('id', id); + localScrollIntoView(target, { block: 'start' }); + } else { + try { window.location.hash = id; } catch (err) { /* ignore */ } + } + }, true); + + function patchFrame(win) { + try { + win.Element.prototype.scrollIntoView = Element.prototype.scrollIntoView; + win.HTMLElement.prototype.focus = HTMLElement.prototype.focus; + } catch (err) { /* 跨源子 frame 跳过 */ } + } + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var added = mutations[i].addedNodes; + for (var j = 0; j < added.length; j++) { + var n = added[j]; + if (!n || n.nodeType !== 1) continue; + var frames = []; + if (n.tagName === 'IFRAME' || n.tagName === 'FRAME') frames.push(n); + if (n.querySelectorAll) { + var found = n.querySelectorAll('iframe,frame'); + for (var k = 0; k < found.length; k++) frames.push(found[k]); + } + for (var m = 0; m < frames.length; m++) { + if (frames[m].contentWindow) patchFrame(frames[m].contentWindow); + } + } + } + }).observe(document.documentElement, { childList: true, subtree: true }); +})();`; + +/** 生成注入内容:CSP meta 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */ +function buildShowHtmlSandboxInjection() { + return ``; +} + +/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 最前(无 head 则补一个) */ +function injectShowHtmlSandboxGuards(doc: string) { + const injection = buildShowHtmlSandboxInjection(); + const headMatch = /]*>/i.exec(doc); + if (headMatch) { + const idx = headMatch.index + headMatch[0].length; + return doc.slice(0, idx) + injection + doc.slice(idx); + } + const htmlMatch = /]*>/i.exec(doc); + if (htmlMatch) { + const idx = htmlMatch.index + htmlMatch[0].length; + return doc.slice(0, idx) + `${injection}` + doc.slice(idx); + } + return injection + doc; +} + function buildShowHtmlIframeSrcdoc(rawHtml: string) { const content = (rawHtml || '').trim(); if (!content) { return ''; } - // 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。 + // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。 if (/]/i.test(content)) { - return content; + return injectShowHtmlSandboxGuards(content); } return ` @@ -342,6 +465,7 @@ function buildShowHtmlIframeSrcdoc(rawHtml: string) { + ${buildShowHtmlSandboxInjection()}