Files
apes-sidebar/tests/sandbox-views.spec.tsx
T
HuanLinOTO 699ce448a1 fix: 处理 Copilot 二轮 review 的 10 条评论
- BrowserView: 即使 host 在白名单,GUI 自身 origin 永不给 allow-same-origin(裸 host 条目覆盖所有端口时可提权)
- intercept: revealInExplorer 相对路径经 resolveSidebarPath 解析后再 revealPaths(交付物路径是相对的)
- intercept: ctx.betterSidebar 改为 ctx.get(#357 纤维链读取约定)
- FileTree/EditorHost/TreePanel/tabs: 把 revealed 贯通到树行渲染 + 高亮类 + scrollIntoView(此前高亮永不渲染)
- state.revealPaths: 重建祖先目录时保留前导分隔符(POSIX/UNC 此前目录永远折叠)
- state.schedulePersist: 仅活动会话写入全局宽度(targeted open 不再用非活动会话的过期宽度覆盖拖拽值)
- browser.probe: GET 回退显式取消 body(否则流式/大响应挂住 socket)
- terminal-font: leading generic 前只放 symbols-only 字体;fully-patched 放 generic 后(避免 hijack 度量字体)
- sidebar.module.css: 恢复拖拽反馈(#130 的 silent resize 与 PR 描述冲突,取 HEAD)
- Sidebar: 剩余 4 处 ctx.betterSidebar 改 ctx.get
- tests: 补 revealPaths/iframeSandboxFor/terminal-font 回归测试 + EditorHost/FileTree 测试补 revealed prop
2026-08-24 18:40:04 +08:00

205 lines
9.6 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, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.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('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('仍然加载')
})
})