diff --git a/apps/desktop/src/components/ui/sandboxed-frame.test.tsx b/apps/desktop/src/components/ui/sandboxed-frame.test.tsx
index ebbc18cae0..936b2b3554 100644
--- a/apps/desktop/src/components/ui/sandboxed-frame.test.tsx
+++ b/apps/desktop/src/components/ui/sandboxed-frame.test.tsx
@@ -7,46 +7,32 @@ import { SANDBOXED_FRAME_DEFAULT_SANDBOX, SandboxedFrame, sanitizeFrameSandbox }
afterEach(cleanup)
describe('sanitizeFrameSandbox', () => {
- it('defaults to the opaque-origin posture for an empty or missing sandbox', () => {
- expect(sanitizeFrameSandbox(undefined)).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
- expect(sanitizeFrameSandbox('')).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
- expect(sanitizeFrameSandbox(' ')).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
- })
-
- it('keeps safe tokens and dedupes them', () => {
- expect(sanitizeFrameSandbox('allow-scripts allow-forms')).toBe('allow-scripts allow-forms')
- expect(sanitizeFrameSandbox('allow-scripts allow-scripts allow-forms')).toBe('allow-scripts allow-forms')
- })
-
- it('is case-insensitive, because the attribute is (a mixed-case escape used to pass)', () => {
- // HTML: "an unordered set of unique space-separated tokens that are ASCII
- // case-insensitive", and Chromium lower-cases each token before matching.
- expect(sanitizeFrameSandbox('ALLOW-SAME-ORIGIN allow-scripts')).toBe('allow-scripts')
- expect(sanitizeFrameSandbox('Allow-Top-Navigation allow-forms')).toBe('allow-forms')
- expect(sanitizeFrameSandbox('ALLOW-SCRIPTS Allow-Forms')).toBe('allow-scripts allow-forms')
- })
-
- it('drops a token it does not know instead of forwarding it', () => {
- // An allowlist, not a blocklist: a token nobody has heard of (or a future
- // one) must not reach the attribute on the strength of being unlisted.
+ it('strips every realm-escaping or unknown token, case-insensitively, and never emits an empty attribute', () => {
+ // HTML: sandbox is "an unordered set of unique space-separated tokens that
+ // are ASCII case-insensitive"; Chromium lower-cases each token before
+ // matching, so `ALLOW-SAME-ORIGIN` once sailed past a case-sensitive check.
+ expect(sanitizeFrameSandbox('allow-scripts allow-same-origin')).toBe('allow-scripts')
+ expect(sanitizeFrameSandbox('ALLOW-SAME-ORIGIN Allow-Top-Navigation allow-scripts')).toBe('allow-scripts')
expect(sanitizeFrameSandbox('allow-scripts allow-invented-thing')).toBe('allow-scripts')
- expect(sanitizeFrameSandbox('allow-invented-thing')).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
+ // A frame with NO sandbox attribute is fully privileged — an emptied set
+ // must fall back to the default posture, not to nothing.
+ for (const escape of [
+ undefined,
+ ' ',
+ 'allow-same-origin',
+ 'allow-top-navigation allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox',
+ 'allow-modals allow-storage-access-by-user-activation'
+ ]) {
+ expect(sanitizeFrameSandbox(escape)).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
+ }
})
- it('allows the safe tokens a real embed asks for', () => {
+ it('keeps the allowlisted tokens a real embed asks for, deduped', () => {
+ expect(sanitizeFrameSandbox('allow-scripts allow-scripts allow-forms')).toBe('allow-scripts allow-forms')
expect(sanitizeFrameSandbox('allow-downloads allow-forms allow-presentation')).toBe(
'allow-downloads allow-forms allow-presentation'
)
})
-
- it('strips every realm-escaping token, falling back to the default when nothing is left', () => {
- expect(sanitizeFrameSandbox('allow-scripts allow-same-origin')).toBe('allow-scripts')
- expect(sanitizeFrameSandbox('allow-top-navigation allow-popups')).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
- expect(sanitizeFrameSandbox('allow-same-origin')).toBe(SANDBOXED_FRAME_DEFAULT_SANDBOX)
- expect(sanitizeFrameSandbox('allow-modals allow-storage-access-by-user-activation')).toBe(
- SANDBOXED_FRAME_DEFAULT_SANDBOX
- )
- })
})
describe('SandboxedFrame', () => {
@@ -61,22 +47,20 @@ describe('SandboxedFrame', () => {
expect(frame.getAttribute('title')).toBe('Feed')
})
- it('keeps its no-referrer / lazy posture when a caller tries to override it', () => {
+ it('keeps its posture when a caller tries to re-open it through props', () => {
const { container } = render(
-
+
)
-
const frame = container.querySelector('iframe')!
+ expect(frame.getAttribute('sandbox')).toBe('allow-scripts')
expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(frame.getAttribute('loading')).toBe('lazy')
})
-
- it('refuses an allow-same-origin / popup escape even when asked for one', () => {
- const { container } = render(
-
- )
-
- expect(container.querySelector('iframe')!.getAttribute('sandbox')).toBe('allow-scripts')
- })
})
diff --git a/apps/desktop/src/components/ui/sandboxed-frame.tsx b/apps/desktop/src/components/ui/sandboxed-frame.tsx
index e5b66baa2b..b08ee9af2c 100644
--- a/apps/desktop/src/components/ui/sandboxed-frame.tsx
+++ b/apps/desktop/src/components/ui/sandboxed-frame.tsx
@@ -46,7 +46,7 @@ export function sanitizeFrameSandbox(sandbox: string | undefined): string {
}
export interface SandboxedFrameProps extends Omit, 'src'> {
- /** Absolute `http(s):` URL to embed. */
+ /** Absolute `http(s):` (or `data:`) URL to embed. */
src: string
/** Accessible title (required — an untitled frame is unlabelled in the a11y tree). */
title: string
diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md
index ed1b1cb6e2..1897b11cf9 100644
--- a/website/docs/developer-guide/desktop-plugin-sdk.md
+++ b/website/docs/developer-guide/desktop-plugin-sdk.md
@@ -700,11 +700,38 @@ Migrations for the plugins that motivated this slot:
Use the SDK's `` for any external web content
(reader views, dashboards, docs). It renders a sandboxed iframe with the app's
guest-content posture: opaque origin, `allow-scripts` by default, `no-referrer`,
-lazy loading. Realm-escaping tokens (`allow-same-origin`, top-navigation,
-popups, modals) are stripped even if passed — the opaque origin IS the
-containment. Never mount a raw Electron ``: it lands on the app's
+lazy loading. Never mount a raw Electron ``: it lands on the app's
`persist:` preview partition, sharing the app's cookies and storage.
+```ts
+interface SandboxedFrameProps extends Omit, 'src'> {
+ src: string // http(s): or data: URL to embed
+ title: string // required — an untitled frame is unlabelled in the a11y tree
+ sandbox?: string // extra tokens; filtered through sanitizeFrameSandbox
+}
+SANDBOXED_FRAME_DEFAULT_SANDBOX = 'allow-scripts'
+sanitizeFrameSandbox(sandbox?: string): string
+```
+
+*Arbitration (allowlist, not blocklist):* the only tokens a caller may add are
+`allow-scripts`, `allow-forms`, `allow-downloads`, `allow-pointer-lock`,
+`allow-orientation-lock`, `allow-presentation`. Everything else —
+`allow-same-origin`, `allow-top-navigation*`, `allow-popups*`, `allow-modals`,
+`allow-storage-access-by-user-activation`, and any token the primitive does not
+know — is dropped case-insensitively even if passed; an emptied set falls back
+to the default posture, because a frame with **no** `sandbox` attribute is
+fully privileged. `loading="lazy"` and `referrerPolicy="no-referrer"` cannot be
+overridden through props. The opaque origin IS the containment: guest content
+cannot reach the app, its storage, or the preload bridge.
+
+*Teardown:* it is a plain React element — unmounting your pane/page removes the
+frame and its realm; nothing is registered app-side.
+
+Migration for **rss-reader** (#115972): replace the stubbed `/preview` → 501 →
+`host.openWorkspace('rss-browser')` → empty `RssBrowserFrame` → `ctx.os.openExternal`
+chain with `` inside
+the workspace page; drop the leftover `.rss-browser-frame-host webview` CSS.
+
### Transcript directives — inline components the model addresses
`TRANSCRIPT_DIRECTIVE_AREA` makes the transcript itself a contribution area.
@@ -1472,7 +1499,7 @@ pipeline as a trust boundary.
| Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` |
| Theming | `useTheme`, `requestTheme`, `setAccentOverride`, `$accentOverride`, `retintTheme`, `themeHue`, `DesktopTheme`, `DesktopThemeColors`, plus OKLCH math (`hexToOklch`, `oklchToHex`, `oklchToSrgb255`, `mixOklab`, `maxChroma`, `hueDelta`, `normalizeHex`) and sRGB measures (`contrastRatio` — `number | null`, null for unparseable input — `readableOn`) |
-| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText` |
+| UI kit | `Button`, `Input`, `Textarea`, `Select*`, `Switch`, `Checkbox`, `SegmentedControl`, `Tabs*`, `Dialog*`, `ConfirmDialog`, `DropdownMenu*`, `ContextMenu*`, `Popover*`, `Tip`/`Tooltip*`, `Badge`, `Kbd`/`KbdGroup`, `SearchField`, `ScrollArea`, `Separator`, `Skeleton`, `GlyphSpinner`, `Loader`, `EmptyState`, `ErrorState`, `CopyButton`, `StatusDot`, `LogView`, `Codicon`, `DecodeText`, `SandboxedFrame` |
| Helpers | `cn`, `icons`, `haptic`, `useI18n`, `profileColor`, `profileColorSoft`, `relativeTime`, `fmtDateTime`, `fmtDayTime`, `coarseElapsed`, `evaluateRuntimeReadiness` |
The canonical, always-current export list is `apps/desktop/src/sdk/index.ts`.