From c3f775d73a803491c1c442a0d0990eecaa5c6fad Mon Sep 17 00:00:00 2001 From: xiaolfeng Date: Wed, 23 Sep 2026 13:41:36 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(=E9=A2=84=E8=A7=88):=20=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E6=B2=99=E7=9B=92=20iframe=20=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E6=96=B0=E7=AA=97=E5=8F=A3=E5=B9=B6=E6=94=BE=E8=A1=8C=20allow-?= =?UTF-8?q?popups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: 预览沙盒原本只配置 allow-scripts,当预览内容含 target="_blank" 或 window.open 时会被浏览器拦截并报错 内容: - 前端 PreviewFrame 组件在 iframe sandbox 属性中追加 allow-popups - 后端 serve.go 与 preview.go 在直出 HTML/SVG 响应头 CSP sandbox 指令中追加 allow-popups - 补充前端单元测试断言沙盒属性正确性 影响: 预览中的外部超链接与弹窗可正常在新窗口中打开,不破坏原有 opaque origin 安全隔离 --- internal/handler/preview.go | 2 +- internal/handler/serve.go | 2 +- web/src/components/interact/primitives/preview-frame.test.tsx | 1 + web/src/components/interact/primitives/preview-frame.tsx | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/handler/preview.go b/internal/handler/preview.go index 08b6d4b..531e745 100644 --- a/internal/handler/preview.go +++ b/internal/handler/preview.go @@ -110,7 +110,7 @@ func (h *PreviewHandler) GetFile(ctx *gin.Context) { // 对可执行 MIME 施加 sandbox,防止上传的恶意 HTML/SVG 在 Lumina 同源下执行脚本窃取令牌 // 存储的 HTML MIME 为常量 PreviewMimeHTML("text/html; charset=utf-8"),故用 HasPrefix 匹配 if mime := strings.ToLower(file.MimeType); strings.HasPrefix(mime, "text/html") || mime == "image/svg+xml" || strings.Contains(mime, "javascript") { - ctx.Header("Content-Security-Policy", "sandbox allow-scripts") + ctx.Header("Content-Security-Policy", "sandbox allow-scripts allow-popups") } ctx.Data(http.StatusOK, file.MimeType, []byte(file.Content)) } diff --git a/internal/handler/serve.go b/internal/handler/serve.go index 4c70051..b92c044 100644 --- a/internal/handler/serve.go +++ b/internal/handler/serve.go @@ -70,7 +70,7 @@ func writeServedFile(ctx *gin.Context, filename, mimeType, content string) { } if strings.HasPrefix(mime, "text/html") || mime == "image/svg+xml" || strings.Contains(mime, "javascript") { - ctx.Header("Content-Security-Policy", "sandbox allow-scripts") + ctx.Header("Content-Security-Policy", "sandbox allow-scripts allow-popups") } if strings.HasPrefix(mime, "text/html") { body = injectNavigateShim(body) diff --git a/web/src/components/interact/primitives/preview-frame.test.tsx b/web/src/components/interact/primitives/preview-frame.test.tsx index 611f368..4436f1f 100644 --- a/web/src/components/interact/primitives/preview-frame.test.tsx +++ b/web/src/components/interact/primitives/preview-frame.test.tsx @@ -36,6 +36,7 @@ describe('PreviewSupplement', () => { const iframe = screen.getByTitle('前端预览') expect(iframe).toBeTruthy() expect(iframe.tagName.toLowerCase()).toBe('iframe') + expect(iframe.getAttribute('sandbox')).toBe('allow-scripts allow-popups') }) }) diff --git a/web/src/components/interact/primitives/preview-frame.tsx b/web/src/components/interact/primitives/preview-frame.tsx index d003320..394dddd 100644 --- a/web/src/components/interact/primitives/preview-frame.tsx +++ b/web/src/components/interact/primitives/preview-frame.tsx @@ -13,7 +13,7 @@ import { previewKindFromFilename } from '#/lib/preview-file' * 原生解析 / -`; +` } -export function SandboxFrame({ content, css, className, title }: SandboxFrameProps) { - const frameRef = useRef(null); - const [mounted, setMounted] = useState(false); - const [height, setHeight] = useState(0); - const [loaded, setLoaded] = useState(false); - const [themeVars, setThemeVars] = useState(() => - typeof document !== 'undefined' ? collectThemeVariables() : '' - ); +export function SandboxFrame({ + content, + css, + className, + title, +}: SandboxFrameProps) { + const frameRef = useRef(null) + const [mounted, setMounted] = useState(false) + const [height, setHeight] = useState(0) + const [loaded, setLoaded] = useState(false) + const [themeVars, setThemeVars] = useState(() => + typeof document !== 'undefined' ? collectThemeVariables() : '', + ) - // 首次挂载后再渲染 iframe,规避 SSR 下 srcDoc 序列化与 document 访问问题 - useEffect(() => { - setMounted(true); - }, []); + // 首次挂载后再渲染 iframe,规避 SSR 下 srcDoc 序列化与 document 访问问题 + useEffect(() => { + setMounted(true) + }, []) - // 主题切换(.dark 类增删)时重新收集变量 → srcDoc 重算 → iframe 重载 - useEffect(() => { - if (typeof MutationObserver === 'undefined') return; - const observer = new MutationObserver(() => setThemeVars(collectThemeVariables())); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); - }, []); + // 主题切换(.dark 类增删)时重新收集变量 → srcDoc 重算 → iframe 重载 + useEffect(() => { + if (typeof MutationObserver === 'undefined') return + const observer = new MutationObserver(() => + setThemeVars(collectThemeVariables()), + ) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + return () => observer.disconnect() + }, []) - // 监听 iframe 高度回传 —— 用 e.source 校验来源(sandbox 下 origin 恒为 "null",不可靠) - useEffect(() => { - const onMessage = (e: MessageEvent) => { - const frame = frameRef.current; - if (!frame || e.source !== frame.contentWindow) return; - const data = e.data; - if (data && data.type === HEIGHT_MSG_TYPE && typeof data.height === 'number') { - setHeight(data.height); - if (data.height > 0) setLoaded(true); - } - }; - window.addEventListener('message', onMessage); - return () => window.removeEventListener('message', onMessage); - }, []); + // 监听 iframe 高度回传 —— 用 e.source 校验来源(sandbox 下 origin 恒为 "null",不可靠) + useEffect(() => { + const onMessage = (e: MessageEvent) => { + const frame = frameRef.current + if (!frame || e.source !== frame.contentWindow) return + const data = e.data + if ( + data && + data.type === HEIGHT_MSG_TYPE && + typeof data.height === 'number' + ) { + setHeight(data.height) + if (data.height > 0) setLoaded(true) + } + } + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }, []) - const doc = useMemo( - () => buildDoc(content, css ?? '', themeVars), - [content, css, themeVars] - ); + const doc = useMemo( + () => buildDoc(content, css ?? '', themeVars), + [content, css, themeVars], + ) - useEffect(() => { - setLoaded(false); - setHeight(0); - }, [doc]); + useEffect(() => { + setLoaded(false) + setHeight(0) + }, [doc]) - return ( -
- {(!mounted || !loaded) && ( -
-

正在加载中

-
- )} - {mounted ? ( -