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`.