diff --git a/README.md b/README.md index 07529823..d918cac5 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | `ToolApproval` | Approve, approve always or deny a tool call waiting on a person | | `GeneratedImages` | Images an agent made, with placeholders while generating | | `Sources` | Numbered citations for an answer | -| `MediaViewer` | Pictures, video, audio, PDFs and text full size; `useMediaViewer` | +| `MediaViewer` | Pictures, video, audio and text full size; `useMediaViewer` | | `Attachments` | Files as a stack, tiles or a list; `useAttachments` | | `AttachmentDropZone` | Area that takes dropped files and folders; `useFileDrop` | | `PromptInput` | The message box: `PromptInputTextarea`, `PromptInputSubmit`, `PromptInputSuggestions`; `usePromptInput` | diff --git a/src/lib/media-src.test.ts b/src/lib/media-src.test.ts index c0f68d56..4f40a90b 100644 --- a/src/lib/media-src.test.ts +++ b/src/lib/media-src.test.ts @@ -10,7 +10,7 @@ describe("safeMediaSrc", () => { "blob:https://example.com/0f6c1b52", ]) { expect(safeMediaSrc(src, "media")).toBe(src); - expect(safeMediaSrc(src, "frame")).toBe(src); + expect(safeMediaSrc(src, "link")).toBe(src); } }); @@ -25,16 +25,18 @@ describe("safeMediaSrc", () => { ); }); - test("a frame never takes a data: URL", () => { - expect(safeMediaSrc("data:image/png;base64,AAAA", "frame")).toBe(undefined); - expect(safeMediaSrc("data:application/pdf;base64,AAAA", "frame")).toBe( + test("a link never takes a data: URL", () => { + expect(safeMediaSrc("data:image/svg+xml;base64,PHN2Zy8+", "link")).toBe( + undefined, + ); + expect(safeMediaSrc("data:application/pdf;base64,AAAA", "link")).toBe( undefined, ); }); test("script and relative URLs are refused", () => { expect(safeMediaSrc("javascript:alert(1)", "media")).toBe(undefined); - expect(safeMediaSrc("JavaScript:alert(1)", "frame")).toBe(undefined); + expect(safeMediaSrc("JavaScript:alert(1)", "link")).toBe(undefined); expect(safeMediaSrc("/files/a.png", "media")).toBe(undefined); }); }); diff --git a/src/lib/media-src.ts b/src/lib/media-src.ts index 82670f9b..96ebab17 100644 --- a/src/lib/media-src.ts +++ b/src/lib/media-src.ts @@ -1,7 +1,7 @@ import { isSafeUrl } from "./url.js"; /** Where a media sink may load from. */ -export type MediaSink = "media" | "frame"; +export type MediaSink = "media" | "link"; const NETWORK_PROTOCOLS: readonly string[] = ["http:", "https:", "blob:"]; @@ -12,9 +12,9 @@ const DATA_MEDIA_TYPE = /^data:(image|video|audio)\/[a-z0-9.+-]+[;,]/i; * `src` back if a media sink may load it, otherwise `undefined`. * * Image, video and audio elements (`"media"`) also accept a `data:` URL of - * their own kind, since they never run what they load. A frame (`"frame"`) - * takes only `http:`, `https:` and `blob:`: a `data:` document in a frame - * runs script. + * their own kind, since they never run what they load. A link (`"link"`) + * takes only `http:`, `https:` and `blob:`: it opens what it points at as a + * document, and a `data:` document, an SVG image among them, runs script. */ export function safeMediaSrc(src: string, sink: MediaSink): string | undefined { if (sink === "media" && DATA_MEDIA_TYPE.test(src)) return src; diff --git a/src/ui/media-viewer.test.tsx b/src/ui/media-viewer.test.tsx index c13fd607..c809e34e 100644 --- a/src/ui/media-viewer.test.tsx +++ b/src/ui/media-viewer.test.tsx @@ -237,12 +237,14 @@ describe("MediaViewer", () => { download: true, }, { + // Chrome shows no PDF in a sandboxed frame, and an unsandboxed one + // runs whatever the URL serves, so a PDF is a download only. item: { name: "report.pdf", mediaType: "application/pdf", src: "https://media.example.com/report.pdf", }, - slot: "media-viewer-page", + slot: "media-viewer-empty", download: true, }, { @@ -256,7 +258,7 @@ describe("MediaViewer", () => { download: true, }, { - // A data: document would run script in the frame. + // A data: document would run script when opened. item: { name: "inline.pdf", mediaType: "application/pdf", @@ -265,6 +267,16 @@ describe("MediaViewer", () => { slot: "media-viewer-empty", download: false, }, + { + // Fine in an , but as a link an SVG document runs script. + item: { + name: "inline.svg", + mediaType: "image/svg+xml", + src: "data:image/svg+xml;base64,PHN2Zy8+", + }, + slot: "media-viewer-image", + download: false, + }, { item: { name: "script.pdf", diff --git a/src/ui/media-viewer.tsx b/src/ui/media-viewer.tsx index 341a46ae..e78163bb 100644 --- a/src/ui/media-viewer.tsx +++ b/src/ui/media-viewer.tsx @@ -16,8 +16,9 @@ export type MediaViewerItem = { readonly mediaType: string; /** * Where to load it from: `http:`, `https:`, `blob:`, or a `data:` image, - * video or audio. Leave it out for an item that cannot be loaded; the - * viewer then says there is no preview and offers no download. + * video or audio (shown, but not offered for download). Leave it out for + * an item that cannot be loaded; the viewer then says there is no preview + * and offers no download. */ readonly src?: string; /** What an image shows. Defaults to `name`. */ @@ -40,15 +41,15 @@ const CONTROL = "grid size-9 shrink-0 place-items-center rounded-full bg-popover text-popover-foreground shadow-sm transition-colors hover:bg-muted [&_svg]:size-4"; /** - * Pictures, video, audio, PDFs and text full size, over everything, on the + * Pictures, video, audio and text full size, over everything, on the * native modal ``: it holds focus and closes on Escape, and a click * outside the media closes it too. The page behind it does not scroll. With * more than one item, the arrow keys and the Previous and Next buttons step * through them, stopping at either end. Download opens a new tab where the * browser will not download the file. On close, focus goes back to the * element given to `viewer.open`, or else to whatever had it when the viewer - * opened. Escape pressed inside a PDF goes to the browser's PDF viewer, not - * the dialog; Tab leaves the PDF and reaches Close. + * opened. A PDF has no preview, only Download: Chrome will not show one in + * a sandboxed frame, and an unsandboxed frame runs whatever its URL serves. * * Renders nothing while `viewer` is closed. A `ref` gets the `` * while it is open. @@ -142,7 +143,7 @@ function MediaViewerDialog({ const count = viewer.items.length; const many = count > 1; const position = many ? `${index + 1} of ${count}` : null; - const download = safeItemSrc(item, "media"); + const download = safeItemSrc(item, "link"); return ( ); } - const page = safeItemSrc(item, "frame"); - if (kind === "pdf" && page !== undefined) { - return ( -