.html/.htm/.xhtml op previews (read / write / edit, non-error) gain a Render/Raw toggle in the preview pane head: the render view loads the SAVED file through the same /sidebar/html route the editor's html viewer uses, so relative assets (./style.css, img/x.png) resolve inside the route and a segmented read still renders the whole document (the route serves the file, not the op snapshot — the file-trace-style srcDoc approach would render a cut-off document whose scripts never run). The frame is ALWAYS sandboxed (HTML_IFRAME_SANDBOX, moved to the shared html-preview.ts module — opaque origin, no allow-same-origin, no top-navigation) plus the route's CSP sandbox header as defense-in-depth; unlike the editor viewer this surface has no no-sandbox escape hatch. The contract is pinned by new sandbox-spec tests on the extracted HtmlRenderPreview component and the pane toggle (off by default; absent for non-html and error targets). Locales: zh/en + all 19 shipped third-language overrides (key-set-equal gate green). Typecheck + eslint clean; sandbox/redact/diff-rows/frontmatter suites 36/36 green. The full-suite host-side failures (fs-operations / pty smoke / git / install-powershell) are pre-existing on the base branch (verified via stash) and unrelated.
254 lines
12 KiB
TypeScript
254 lines
12 KiB
TypeScript
/**
|
|
* Sandbox-contract tests for the two built-in web surfaces (HTML preview
|
|
* iframe and the browser tab iframe). The iframe sandbox — opaque origin,
|
|
* no allow-same-origin, no top-navigation — is the PRIMARY security
|
|
* boundary of both features; these tests pin the exact attribute so a
|
|
* refactor cannot silently widen it. The side card settings can drop the
|
|
* sandbox per-feature (warned); those paths render the warning bar and no
|
|
* sandbox attribute.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
import { renderToString } from 'react-dom/server'
|
|
import { createElement } from 'react'
|
|
import './browser-globals.ts'
|
|
import type { Context } from '../src/context-types.ts'
|
|
import { TextEditor } from '../src/client/TextEditor.tsx'
|
|
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
|
|
import { DiffPane, HtmlRenderPreview } from '../src/client/changes/DiffPane.tsx'
|
|
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX, iframeSandboxFor } from '../src/client/BrowserView.tsx'
|
|
import { createSidebarStore } from '../src/client/state.ts'
|
|
import type { FileViewerProps } from '../src/client/service.ts'
|
|
|
|
const CTX = {} as Context
|
|
|
|
// The copy assertions below pin the zh strings: force the zh locale (the
|
|
// test environment's navigator may be the real Node one with an en locale).
|
|
beforeEach(() => {
|
|
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
|
})
|
|
|
|
function viewerProps(store: ReturnType<typeof createSidebarStore>, overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
|
return {
|
|
ctx: CTX,
|
|
store,
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
path: '/p/a/index.html',
|
|
title: 'index.html',
|
|
viewerId: 'html',
|
|
content: '<h1>hi</h1>',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('HTML preview iframe sandbox', () => {
|
|
it('renders the preview iframe with the exact sandbox tokens and no same-origin / top-navigation', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
// The sandbox tokens are exactly the exported constant...
|
|
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
|
// ...which must never contain the dangerous tokens.
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
|
// Cross-origin framing by construction: route-src (never srcdoc).
|
|
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
|
expect(iframe).not.toContain('srcdoc=')
|
|
// Referrer + permissions policy stay locked even when sandboxed.
|
|
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
|
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
|
expect(iframe).toContain('allow=""')
|
|
})
|
|
|
|
it('renders the live sandbox status row (green on + temporary unlock action)', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
// Sandbox ON: the green status + the one-tap temporary unlock button.
|
|
expect(html).toContain('沙箱模式:已启用')
|
|
expect(html).toContain('临时解锁(不安全)')
|
|
// No restore action while the sandbox is on.
|
|
expect(html).not.toContain('恢复沙箱')
|
|
})
|
|
|
|
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
|
const store = createSidebarStore()
|
|
store.setPrefs({ ...store.getPrefs(), htmlViewerNoSandbox: true })
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).not.toContain('sandbox=')
|
|
// The red persistent warning copy is rendered; the temporary-unlock
|
|
// action is NOT offered (re-enabling is the settings page's job).
|
|
expect(html).toContain('沙箱已关闭')
|
|
expect(html).not.toContain('临时解锁(不安全)')
|
|
expect(html).not.toContain('恢复沙箱')
|
|
})
|
|
|
|
it('starts unsandboxed (red, restorable) when the default-unsafe pref is on', () => {
|
|
const store = createSidebarStore()
|
|
store.setPrefs({ ...store.getPrefs(), htmlViewerDefaultUnsafe: true })
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).not.toContain('sandbox=')
|
|
// The red warning + the one-tap restore (this is the LOCAL state).
|
|
expect(html).toContain('沙箱已关闭')
|
|
expect(html).toContain('恢复沙箱')
|
|
expect(html).not.toContain('临时解锁(不安全)')
|
|
})
|
|
|
|
it('markdown preview keeps rendering markdown, not an iframe', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(TextEditor, viewerProps(store, {
|
|
viewerId: 'markdown',
|
|
path: '/p/readme.md',
|
|
content: '# hi',
|
|
})))
|
|
expect(html).not.toContain('<iframe')
|
|
expect(html).not.toContain('/sidebar/html/')
|
|
// The markdown is rendered into markup, not framed.
|
|
expect(html).toContain('<h1')
|
|
})
|
|
})
|
|
|
|
describe('changes tab HTML render preview sandbox', () => {
|
|
function opProps(path: string) {
|
|
return {
|
|
target: {
|
|
kind: 'op' as const,
|
|
path,
|
|
op: { callId: 'c1', kind: 'read' as const, path, time: 0, running: false, isError: false, read: '<content>1: <html><body>hi</body></html></content>' },
|
|
},
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
height: 300,
|
|
onHeightCommit: () => {},
|
|
onClose: () => {},
|
|
onExpand: () => {},
|
|
}
|
|
}
|
|
|
|
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
|
|
const html = renderToString(createElement(HtmlRenderPreview, { src: '/sidebar/html/s1/p/a/index.html', title: '/p/a/index.html' }))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
// The sandbox tokens are exactly the shared constant — and unlike the
|
|
// editor viewer this surface has NO no-sandbox escape hatch.
|
|
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
|
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
|
// Cross-origin framing by construction: route-src (never srcdoc).
|
|
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
|
expect(iframe).not.toContain('srcdoc=')
|
|
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
|
expect(iframe).toContain('allow=""')
|
|
})
|
|
|
|
it('html op targets show the render toggle (off by default, no iframe); non-html and error targets show none', () => {
|
|
const htmlOpHtml = renderToString(createElement(DiffPane, opProps('/p/a/index.html')))
|
|
expect(htmlOpHtml).toContain('渲染')
|
|
expect(htmlOpHtml).not.toContain('<iframe')
|
|
const md = renderToString(createElement(DiffPane, opProps('/p/a/readme.md')))
|
|
expect(md).not.toContain('渲染')
|
|
const errorProps = opProps('/p/a/index.html')
|
|
const html = renderToString(createElement(DiffPane, {
|
|
...errorProps,
|
|
target: { ...errorProps.target, op: { ...errorProps.target.op, isError: true, errorText: 'boom' } },
|
|
}))
|
|
expect(html).not.toContain('渲染')
|
|
})
|
|
})
|
|
|
|
describe('browser tab iframe sandbox', () => {
|
|
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
|
|
return {
|
|
ctx: CTX,
|
|
store,
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
tab: { id: 'browser:1', type: 'browser', title: 'Browser', ...(path !== undefined ? { path } : {}) },
|
|
visible: true,
|
|
}
|
|
}
|
|
|
|
it('renders the start page before any navigation (no iframe, no auto-load)', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(BrowserView, tabProps(store)))
|
|
expect(html).not.toContain('<iframe')
|
|
expect(html).toContain('输入网址开始浏览')
|
|
})
|
|
|
|
it('sandboxes the iframe without same-origin / top-navigation', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).toContain(`sandbox="${BROWSER_IFRAME_SANDBOX}"`)
|
|
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
|
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
|
expect(iframe).toContain('src="https://example.com/"')
|
|
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
|
expect(iframe).toContain('allow=""')
|
|
})
|
|
|
|
it('never grants allow-same-origin to the GUI\'s own origin, even when allowlisted', () => {
|
|
// A bare-host allowlist entry covers every port, so an allowlisted GUI
|
|
// host also matches the GUI's exact origin — but a page at the GUI's
|
|
// origin must keep the opaque-origin sandbox: allow-same-origin there
|
|
// would make it same-origin with its parent and hand it GUI storage/API.
|
|
const allowlist = 'localhost'
|
|
const guiOrigin = 'http://localhost:5173'
|
|
expect(iframeSandboxFor('http://localhost:5173/', allowlist, guiOrigin)).toBe(BROWSER_IFRAME_SANDBOX)
|
|
expect(iframeSandboxFor('http://localhost:5173/', allowlist, guiOrigin)).not.toContain('allow-same-origin')
|
|
// Other allowlisted loopback origins still get the same-origin token.
|
|
const sandbox = iframeSandboxFor('http://localhost:4000/', allowlist, guiOrigin)
|
|
expect(sandbox).not.toBe(BROWSER_IFRAME_SANDBOX)
|
|
expect(sandbox).toContain('allow-same-origin')
|
|
// A different (non-GUI) origin with no allowlist stays opaque.
|
|
expect(iframeSandboxFor('https://example.com/', '', guiOrigin)).toBe(BROWSER_IFRAME_SANDBOX)
|
|
})
|
|
|
|
it('renders the live sandbox status row with the temporary unlock action', () => {
|
|
const store = createSidebarStore()
|
|
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
|
expect(html).toContain('沙箱模式:已启用')
|
|
expect(html).toContain('临时解锁(不安全)')
|
|
})
|
|
|
|
it('offers the open-in-browser action once a URL is loaded (disabled before navigation)', () => {
|
|
const store = createSidebarStore()
|
|
// No URL yet: the external-open action is disabled.
|
|
const start = renderToString(createElement(BrowserView, tabProps(store)))
|
|
expect(start).toContain('aria-label="在浏览器中打开"')
|
|
expect(start).toContain('title="在浏览器中打开" disabled=""')
|
|
// With a URL: enabled.
|
|
const loaded = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
|
expect(loaded).toContain('aria-label="在浏览器中打开"')
|
|
expect(loaded).not.toContain('title="在浏览器中打开" disabled=""')
|
|
})
|
|
|
|
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
|
const store = createSidebarStore()
|
|
store.setPrefs({ ...store.getPrefs(), browserNoSandbox: true })
|
|
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
|
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
|
expect(iframe).toBeDefined()
|
|
expect(iframe).not.toContain('sandbox=')
|
|
expect(html).toContain('沙箱已关闭')
|
|
expect(html).not.toContain('临时解锁(不安全)')
|
|
expect(html).not.toContain('恢复沙箱')
|
|
})
|
|
})
|
|
|
|
describe('browser embed-refusal panel', () => {
|
|
it('explains the refusal with the host, the reason, and both actions', () => {
|
|
const html = renderToString(createElement(BrowserEmbedBlocked, {
|
|
url: 'https://arxiv.org/abs/2401.10001',
|
|
onOpenInBrowser: () => {},
|
|
onLoadAnyway: () => {},
|
|
}))
|
|
expect(html).toContain('arxiv.org 拒绝了嵌入请求')
|
|
expect(html).toContain('X-Frame-Options / frame-ancestors')
|
|
expect(html).toContain('在浏览器中打开')
|
|
expect(html).toContain('仍然加载')
|
|
})
|
|
})
|