fix(html-preview): deliver the document through srcdoc so a sandboxed frame can load it
The preview showed nothing but the bare word `forbidden` in DSH Desktop. The cause is not this plugin's routes but the host, one layer above them: `DesktopWebServer` wraps EVERY route with `permits()`, and in the default isolated/扩展 mode `ordinaryBrowserEnabled` is false, so a request is served only when it carries the Electron renderer capability (`x-dsh-desktop-renderer`). That header is attached solely to MAIN-frame and SAME-ORIGIN-subframe traffic (`requestComesFromRendererOrigin`), and a frame sandboxed WITHOUT allow-same-origin is an opaque origin at request time — so the preview document request could never be served, while every other sidebar route (all main-frame) worked fine. The fix keeps the sandbox boundary exactly where it was and moves the delivery: the document is fetched by the MAIN frame — which the host does trust — and handed to the frame through `srcdoc`. New shared component HtmlPreviewFrame owns that path plus the sandbox tokens for BOTH surfaces (the editor viewer and the changes tab's op render preview, which had no escape hatch at all), so the two can no longer drift; a failed fetch falls back to handing the frame the route URL, leaving the route's own answer visible instead of a blank pane. `withPreviewBase` injects `<base href="<the file's route directory>">`: a srcdoc document has no URL of its own, so without it `./style.css` would resolve against the GUI page. A document that declares its own base is left alone. Two consequences, both deliberate: - sandboxed: the frame stays opaque, so a self-contained document previews completely while a multi-file one shows its markup with the relative assets missing (those requests are opaque-origin too — inlining them is a separate job); - unlocked (the warned per-surface unlock, or the setting): the srcdoc frame inherits the GUI origin — which is what that escape hatch already meant — and there relative references resolve and load like any other same-origin request, so multi-file previews work in that mode. Verified: typecheck clean; build clean; 1364 passed / 9 skipped (130 files, +13: the pure base-rewrite spec, the delivery spec, and the updated sandbox-contract assertions, which now pin srcdoc + no route src instead of the old route-src shape).
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The one HTML-preview frame both preview surfaces render.
|
||||
*
|
||||
* Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop
|
||||
* the host answers 403 `forbidden` to every request that does not carry the
|
||||
* Electron renderer capability (`x-dsh-desktop-renderer`), and the shell
|
||||
* attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic
|
||||
* (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT
|
||||
* allow-same-origin is an opaque origin at request time, so its document
|
||||
* request never carries the capability and the preview shows nothing but the
|
||||
* bare refusal.
|
||||
*
|
||||
* So the document is fetched by the MAIN frame — which does carry it — and
|
||||
* handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly
|
||||
* as before: the preview still runs in an opaque origin, and the route's own
|
||||
* workspace fence still bounds what can be fetched.
|
||||
*
|
||||
* Two consequences worth knowing:
|
||||
* - sandboxed: an opaque origin cannot make network requests either, so a
|
||||
* self-contained document previews completely while a multi-file one shows
|
||||
* its markup with the relative assets missing (inlining them is a separate
|
||||
* job, not this component's);
|
||||
* - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc
|
||||
* frame inherits the GUI's origin — which is precisely what that escape
|
||||
* hatch already meant — and there its relative references resolve and load
|
||||
* like any other same-origin request.
|
||||
*
|
||||
* A fetch that fails hands the frame the route URL instead, so the route's
|
||||
* own answer (its JSON error, or the refusal) stays visible rather than being
|
||||
* replaced by a blank pane.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts'
|
||||
|
||||
/** One preview frame. */
|
||||
export function HtmlPreviewFrame(props: {
|
||||
/** The `/sidebar/html/<sessionId>/<path…>` route URL of the SAVED file. */
|
||||
url: string
|
||||
/** Whether the opaque-origin sandbox applies (the default for both surfaces). */
|
||||
sandboxed: boolean
|
||||
className?: string
|
||||
title: string
|
||||
}): ReactNode {
|
||||
const { url, sandboxed, className, title } = props
|
||||
const [doc, setDoc] = useState<string | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setDoc(null)
|
||||
setFailed(false)
|
||||
void fetch(url, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
return response.text()
|
||||
})
|
||||
.then((text) => { setDoc(text) })
|
||||
.catch((error: unknown) => {
|
||||
// An abort is this effect's own cleanup, not a failure.
|
||||
if ((error as { name?: string }).name !== 'AbortError') setFailed(true)
|
||||
})
|
||||
return () => { controller.abort() }
|
||||
}, [url])
|
||||
// The delivery path depends only on what we have: the fetched markup, the
|
||||
// route's own answer after a failed fetch, or an empty frame while loading.
|
||||
const delivery = doc !== null
|
||||
? { srcDoc: withPreviewBase(doc, url) }
|
||||
: failed ? { src: url } : { srcDoc: '' }
|
||||
return (
|
||||
<iframe
|
||||
{...delivery}
|
||||
className={className}
|
||||
sandbox={sandboxed ? HTML_IFRAME_SANDBOX : undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from '.
|
||||
import { MdToc } from './md-toc.tsx'
|
||||
import { splitMermaidBlocks } from './mermaid-blocks.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
|
||||
import { HtmlPreviewFrame } from './HtmlPreviewFrame.tsx'
|
||||
import type { EditorToolbarState, FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -545,16 +545,14 @@ export function TextEditor(props: FileViewerProps) {
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
||||
origin when unsandboxed; the route URL keeps the frame
|
||||
cross-origin by construction). The preview shows the SAVED
|
||||
file; the draft is only visible in edit mode. */}
|
||||
<iframe
|
||||
{/* The preview shows the SAVED file; the draft is only visible in
|
||||
edit mode. HtmlPreviewFrame owns both the delivery path
|
||||
(main-frame fetch -> srcdoc, which a sandboxed frame could not
|
||||
reach on its own) and the sandbox tokens. */}
|
||||
<HtmlPreviewFrame
|
||||
className={css.editorHtml}
|
||||
src={htmlUrl(scope, path)}
|
||||
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
url={htmlUrl(scope, path)}
|
||||
sandboxed={!htmlNoSandbox}
|
||||
title={path}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { api, htmlUrl } from '../api.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { baseName } from '../paths.ts'
|
||||
import { resolveSidebarPath } from '../produced-files.ts'
|
||||
import { HTML_IFRAME_SANDBOX } from '../html-preview.ts'
|
||||
import { HtmlPreviewFrame } from '../HtmlPreviewFrame.tsx'
|
||||
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
|
||||
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
|
||||
import { PdfView } from '../PdfView.tsx'
|
||||
@@ -66,19 +66,18 @@ function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** The render view of one html op target: the route-src iframe. Extracted
|
||||
* (and exported) so the always-sandboxed contract is pinned directly by the
|
||||
* sandbox spec — this surface has NO no-sandbox escape hatch. */
|
||||
/** The render view of one html op target. Extracted (and exported) so the
|
||||
* always-sandboxed contract is pinned directly by the sandbox spec — this
|
||||
* surface has NO no-sandbox escape hatch, so it always takes the srcdoc
|
||||
* delivery path. */
|
||||
export function HtmlRenderPreview(props: { src: string; title: string }) {
|
||||
return (
|
||||
<div className={css.htmlPane}>
|
||||
<iframe
|
||||
<HtmlPreviewFrame
|
||||
className={css.htmlFrame}
|
||||
url={props.src}
|
||||
sandboxed
|
||||
title={props.title}
|
||||
src={props.src}
|
||||
sandbox={HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Shared vocabulary of the sidebar's HTML preview surfaces (the editor's
|
||||
* html viewer and the changes tab's op render preview): the sandbox tokens
|
||||
* both preview iframes load with.
|
||||
* both preview iframes load with, plus the document-base rewrite the srcdoc
|
||||
* delivery path needs.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -13,3 +14,47 @@
|
||||
* render preview always stays sandboxed.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
/** Whether a document already declares its own base URL. */
|
||||
const HAS_BASE = /<base[\s/>]/i
|
||||
|
||||
/** Index just past the element a `<base>` must precede to land in <head>. */
|
||||
function insertionPoint(html: string): number {
|
||||
const head = /<head\b[^>]*>/i.exec(html)
|
||||
if (head !== null) return head.index + head[0].length
|
||||
const htmlTag = /<html\b[^>]*>/i.exec(html)
|
||||
if (htmlTag !== null) return htmlTag.index + htmlTag[0].length
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory part of a preview route URL, with its trailing slash.
|
||||
* @param url - the `/sidebar/html/<sessionId>/<path…>` route URL.
|
||||
* @returns the URL up to and including the last `/`.
|
||||
*/
|
||||
export function previewBaseOf(url: string): string {
|
||||
const cut = url.lastIndexOf('/')
|
||||
return cut < 0 ? url : url.slice(0, cut + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a preview document's relative references back at its own route.
|
||||
*
|
||||
* A `srcdoc` document has no URL of its own — the browser resolves its
|
||||
* relative references against the EMBEDDING page's URL, so `./style.css` in a
|
||||
* previewed file would resolve to the GUI's origin and hit whatever route
|
||||
* happens to match. Injecting `<base href="<the file's route directory>">`
|
||||
* keeps relative resolution inside `/sidebar/html/…`, where the file's own
|
||||
* siblings live. A document that declares its own `<base>` is left alone: the
|
||||
* author said what the base is.
|
||||
*
|
||||
* @param html - the previewed document's markup.
|
||||
* @param url - the route URL the document was fetched from.
|
||||
* @returns the markup, with a base element injected when it had none.
|
||||
*/
|
||||
export function withPreviewBase(html: string, url: string): string {
|
||||
if (HAS_BASE.test(html)) return html
|
||||
const href = previewBaseOf(url).replace(/"/g, '"')
|
||||
const at = insertionPoint(html)
|
||||
return `${html.slice(0, at)}<base href="${href}">${html.slice(at)}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* HtmlPreviewFrame delivery tests — the piece that exists ONLY because of the
|
||||
* DSH Desktop renderer fence: the host refuses `/sidebar/html/…` to any
|
||||
* request without the Electron renderer capability, which a frame sandboxed
|
||||
* without allow-same-origin (an opaque origin) can never carry. The document
|
||||
* is therefore fetched by the MAIN frame and handed over through `srcdoc`,
|
||||
* with the sandbox attribute untouched.
|
||||
*
|
||||
* Mounted client-side (createRoot + act) because the delivery path is an
|
||||
* effect; the sandbox-contract assertions live in sandbox-views.spec.tsx.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { HtmlPreviewFrame } from '../src/client/HtmlPreviewFrame.tsx'
|
||||
import { HTML_IFRAME_SANDBOX } from '../src/client/html-preview.ts'
|
||||
import { renderRoot, setupReactAct } from './test-utils.ts'
|
||||
|
||||
setupReactAct()
|
||||
|
||||
const URL_ = '/sidebar/html/s1/p/a/index.html'
|
||||
const DOC = '<html><head><title>t</title></head><body><h1>hi</h1></body></html>'
|
||||
|
||||
/** Stub the global fetch with one canned response. */
|
||||
function stubFetch(text: string, ok = true): void {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status: ok ? 200 : 403, text: async () => text })))
|
||||
}
|
||||
|
||||
/** Flush the fetch's microtasks + the React commit they trigger. */
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => { await new Promise(resolve => { setTimeout(resolve, 0) }) })
|
||||
}
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('HtmlPreviewFrame delivery', () => {
|
||||
it('starts empty, never pointing the frame at the route itself', () => {
|
||||
stubFetch(DOC)
|
||||
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
|
||||
const iframe = r.container.querySelector('iframe')!
|
||||
expect(iframe.getAttribute('srcdoc')).toBe('')
|
||||
expect(iframe.getAttribute('src')).toBe(null)
|
||||
// The boundary is unchanged while the document is in flight.
|
||||
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
|
||||
r.unmount()
|
||||
})
|
||||
|
||||
it('hands the fetched markup to the frame through srcdoc, base-rewritten, sandbox kept', async () => {
|
||||
stubFetch(DOC)
|
||||
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
|
||||
await flush()
|
||||
const iframe = r.container.querySelector('iframe')!
|
||||
const srcdoc = iframe.getAttribute('srcdoc')
|
||||
expect(srcdoc).toContain('<base href="/sidebar/html/s1/p/a/">')
|
||||
expect(srcdoc).toContain('<h1>hi</h1>')
|
||||
// Still no route src: the opaque frame must not be asked to fetch.
|
||||
expect(iframe.getAttribute('src')).toBe(null)
|
||||
expect(iframe.getAttribute('sandbox')).toBe(HTML_IFRAME_SANDBOX)
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
r.unmount()
|
||||
})
|
||||
|
||||
it('drops the sandbox attribute for the warned unlock path (the frame inherits the GUI origin)', async () => {
|
||||
stubFetch(DOC)
|
||||
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: false, title: 'index.html' }))
|
||||
await flush()
|
||||
const iframe = r.container.querySelector('iframe')!
|
||||
expect(iframe.getAttribute('sandbox')).toBe(null)
|
||||
expect(iframe.getAttribute('srcdoc')).toContain('<h1>hi</h1>')
|
||||
r.unmount()
|
||||
})
|
||||
|
||||
it('falls back to the route URL when the fetch fails, so the route still answers', async () => {
|
||||
stubFetch('', false)
|
||||
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'index.html' }))
|
||||
await flush()
|
||||
const iframe = r.container.querySelector('iframe')!
|
||||
expect(iframe.getAttribute('src')).toBe(URL_)
|
||||
expect(iframe.getAttribute('srcdoc')).toBe(null)
|
||||
r.unmount()
|
||||
})
|
||||
|
||||
it('refetches when the previewed file changes', async () => {
|
||||
stubFetch('<h1>one</h1>')
|
||||
const r = renderRoot(createElement(HtmlPreviewFrame, { url: URL_, sandboxed: true, title: 'a.html' }))
|
||||
await flush()
|
||||
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>one</h1>')
|
||||
stubFetch('<h1>two</h1>')
|
||||
r.rerender(createElement(HtmlPreviewFrame, { url: '/sidebar/html/s1/p/a/two.html', sandboxed: true, title: 'b.html' }))
|
||||
await flush()
|
||||
expect(r.container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('<h1>two</h1>')
|
||||
r.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* HTML preview vocabulary tests: the srcdoc delivery path needs the previewed
|
||||
* document's relative references to resolve back into its own
|
||||
* `/sidebar/html/…` route (a srcdoc document has no URL of its own — the
|
||||
* browser would otherwise resolve `./style.css` against the GUI page).
|
||||
*
|
||||
* Pure module, plain node environment.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { previewBaseOf, withPreviewBase } from '../src/client/html-preview.ts'
|
||||
|
||||
const URL_ = '/sidebar/html/s1/p/a/index.html'
|
||||
|
||||
describe('previewBaseOf', () => {
|
||||
it('keeps everything up to the last slash', () => {
|
||||
expect(previewBaseOf(URL_)).toBe('/sidebar/html/s1/p/a/')
|
||||
})
|
||||
|
||||
it('keeps the UNC double slash intact (the route encodes it that way)', () => {
|
||||
expect(previewBaseOf('/sidebar/html/s1//server/share/a.html')).toBe('/sidebar/html/s1//server/share/')
|
||||
})
|
||||
|
||||
it('returns the input unchanged when it has no slash', () => {
|
||||
expect(previewBaseOf('s1')).toBe('s1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withPreviewBase', () => {
|
||||
it('injects the base as the first child of <head>, ahead of the document’s own elements', () => {
|
||||
const out = withPreviewBase('<html><head><title>t</title></head><body></body></html>', URL_)
|
||||
expect(out).toBe('<html><head><base href="/sidebar/html/s1/p/a/"><title>t</title></head><body></body></html>')
|
||||
})
|
||||
|
||||
it('falls back to <html> when the document declares no head', () => {
|
||||
const out = withPreviewBase('<html><body>x</body></html>', URL_)
|
||||
expect(out).toBe('<html><base href="/sidebar/html/s1/p/a/"><body>x</body></html>')
|
||||
})
|
||||
|
||||
it('prepends for a headless fragment', () => {
|
||||
expect(withPreviewBase('<h1>x</h1>', URL_)).toBe('<base href="/sidebar/html/s1/p/a/"><h1>x</h1>')
|
||||
})
|
||||
|
||||
it('leaves a document that declares its own base alone (the author owns it)', () => {
|
||||
const doc = '<head><base href="https://example.com/"></head>'
|
||||
expect(withPreviewBase(doc, URL_)).toBe(doc)
|
||||
})
|
||||
|
||||
it('quotes the injected href', () => {
|
||||
expect(withPreviewBase('<head></head>', '/x/"y/')).toBe('<head><base href="/x/"y/"></head>')
|
||||
})
|
||||
})
|
||||
@@ -51,9 +51,13 @@ describe('HTML preview 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=')
|
||||
// Opaque-origin framing by construction: the document arrives through
|
||||
// srcdoc (fetched by the MAIN frame, the only one the host trusts with
|
||||
// the /sidebar route on DSH Desktop) and the frame is never pointed at
|
||||
// the route itself. Empty until that fetch resolves. (The server
|
||||
// renderer passes srcDoc through camelCase, like referrerPolicy below.)
|
||||
expect(iframe).toContain('srcDoc=""')
|
||||
expect(iframe).not.toContain('src=')
|
||||
// Referrer + permissions policy stay locked even when sandboxed.
|
||||
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
@@ -127,7 +131,7 @@ describe('changes tab HTML render preview sandbox', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('render iframe is ALWAYS sandboxed (no escape hatch) with the route src, never srcdoc', () => {
|
||||
it('render iframe is ALWAYS sandboxed (no escape hatch) and delivered through 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()
|
||||
@@ -136,9 +140,10 @@ describe('changes tab HTML render preview sandbox', () => {
|
||||
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=')
|
||||
// Same delivery path as the editor viewer: main-frame fetch -> srcdoc,
|
||||
// never a route src the opaque frame could not fetch itself.
|
||||
expect(iframe).toContain('srcDoc=""')
|
||||
expect(iframe).not.toContain('src=')
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user