Skip to content

Commit ddce89f

Browse files
committed
fix(media): keep script-capable sources out of links and frames
Addresses findings 2, 3 and 4 of #142. 2. The Download link reused the media-element allowlist, which lets a data:image/svg+xml through: safe in an <img>, but as a target="_blank" href it opens an SVG document that can run script. The link now uses a "link" sink (the old "frame" sink, renamed now that no frame uses it) that takes only http:, https: and blob:. A data: image still shows; it is just not offered for download. 3. PDFs rendered in an unsandboxed <iframe>, so a URL labelled as a PDF that serves HTML ran script, same-origin for blob: and same-origin http(s), and could navigate the top page. A short spike, run in Playwright's new-headless Chromium and in installed Chrome stable, showed Chrome refuses its PDF viewer in any sandboxed frame. It shows the "blocked" page for sandbox="", "allow-scripts", "allow-same-origin" and even "allow-scripts allow-same-origin", which would also void the sandbox for a same-origin URL. <object>/<embed> were not tried: per the HTML spec they follow the response's Content-Type, so they would not refuse HTML either. Proving the bytes are a PDF would mean fetching the URL, which needs CORS and goes against the package's no-fetching rule. So a PDF now gets the "No preview for this file." state with its Download link, which in Chrome opens the native viewer in a new tab. 4. When the open item fell off a shortened list, the index read as closed but stayed stored, so the viewer reopened when the list grew back. An effect keyed on falling out of range now commits the close: uncontrolled state clears, and a controlled caller gets one onIndexChange(null) even if it keeps passing the stale index.
1 parent 9f97ae9 commit ddce89f

7 files changed

Lines changed: 75 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
200200
| `ToolApproval` | Approve, approve always or deny a tool call waiting on a person |
201201
| `GeneratedImages` | Images an agent made, with placeholders while generating |
202202
| `Sources` | Numbered citations for an answer |
203-
| `MediaViewer` | Pictures, video, audio, PDFs and text full size; `useMediaViewer` |
203+
| `MediaViewer` | Pictures, video, audio and text full size; `useMediaViewer` |
204204
| `Attachments` | Files as a stack, tiles or a list; `useAttachments` |
205205
| `AttachmentDropZone` | Area that takes dropped files and folders; `useFileDrop` |
206206
| `PromptInput` | The message box: `PromptInputTextarea`, `PromptInputSubmit`, `PromptInputSuggestions`; `usePromptInput` |

‎src/hooks/use-media-viewer.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ describe("useMediaViewer", () => {
6565
hook.unmount();
6666
});
6767

68+
test("an item falling off a shortened list closes it for good", () => {
69+
let items: readonly string[] = ITEMS;
70+
const hook = renderHook(() =>
71+
useMediaViewer<string>({ items, defaultIndex: 2 }),
72+
);
73+
items = ["a"];
74+
hook.rerender();
75+
expect(hook.result.current.index).toBe(null);
76+
items = ITEMS;
77+
hook.rerender();
78+
expect(hook.result.current.index).toBe(null);
79+
hook.unmount();
80+
});
81+
82+
test("controlled, an item falling off the list reports one close", () => {
83+
let items: readonly string[] = ITEMS;
84+
const changes: (number | null)[] = [];
85+
const hook = renderHook(() =>
86+
useMediaViewer<string>({
87+
items,
88+
index: 2,
89+
onIndexChange: (index) => changes.push(index),
90+
}),
91+
);
92+
items = ["a"];
93+
hook.rerender();
94+
hook.rerender();
95+
expect(hook.result.current.index).toBe(null);
96+
expect(changes).toEqual([null]);
97+
hook.unmount();
98+
});
99+
68100
test("controlled, it reports changes and follows the value it is given", () => {
69101
const changes: (number | null)[] = [];
70102
const hook = viewer({

‎src/hooks/use-media-viewer.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef } from "react";
1+
import { useCallback, useEffect, useRef } from "react";
22
import type { RefObject } from "react";
33

44
import { useControllableState } from "./use-controllable-state.js";
@@ -37,9 +37,9 @@ export type MediaViewerState<T> = {
3737

3838
/**
3939
* Which item of a set is open in a viewer. Stepping stops at either end
40-
* rather than wrapping, and an index outside `items` reads as closed, so a
40+
* rather than wrapping, and an index outside `items` closes the viewer, so a
4141
* list that shrinks under an open viewer closes it rather than showing
42-
* nothing.
42+
* nothing, and growing back does not reopen it.
4343
*/
4444
export function useMediaViewer<T>({
4545
items,
@@ -62,6 +62,13 @@ export function useMediaViewer<T>({
6262
rawIndex < count
6363
? rawIndex
6464
: null;
65+
const outOfRange = rawIndex !== null && index === null;
66+
67+
// Commits the close an out-of-range index already reads as. Keyed on
68+
// falling out alone, so a controlled caller hears it once.
69+
useEffect(() => {
70+
if (outOfRange) setIndex(null);
71+
}, [outOfRange]);
6572

6673
const open = useCallback(
6774
(next: number, element?: HTMLElement | null) => {

‎src/lib/media-src.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe("safeMediaSrc", () => {
1010
"blob:https://example.com/0f6c1b52",
1111
]) {
1212
expect(safeMediaSrc(src, "media")).toBe(src);
13-
expect(safeMediaSrc(src, "frame")).toBe(src);
13+
expect(safeMediaSrc(src, "link")).toBe(src);
1414
}
1515
});
1616

@@ -25,16 +25,18 @@ describe("safeMediaSrc", () => {
2525
);
2626
});
2727

28-
test("a frame never takes a data: URL", () => {
29-
expect(safeMediaSrc("data:image/png;base64,AAAA", "frame")).toBe(undefined);
30-
expect(safeMediaSrc("data:application/pdf;base64,AAAA", "frame")).toBe(
28+
test("a link never takes a data: URL", () => {
29+
expect(safeMediaSrc("data:image/svg+xml;base64,PHN2Zy8+", "link")).toBe(
30+
undefined,
31+
);
32+
expect(safeMediaSrc("data:application/pdf;base64,AAAA", "link")).toBe(
3133
undefined,
3234
);
3335
});
3436

3537
test("script and relative URLs are refused", () => {
3638
expect(safeMediaSrc("javascript:alert(1)", "media")).toBe(undefined);
37-
expect(safeMediaSrc("JavaScript:alert(1)", "frame")).toBe(undefined);
39+
expect(safeMediaSrc("JavaScript:alert(1)", "link")).toBe(undefined);
3840
expect(safeMediaSrc("/files/a.png", "media")).toBe(undefined);
3941
});
4042
});

‎src/lib/media-src.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isSafeUrl } from "./url.js";
22

33
/** Where a media sink may load from. */
4-
export type MediaSink = "media" | "frame";
4+
export type MediaSink = "media" | "link";
55

66
const NETWORK_PROTOCOLS: readonly string[] = ["http:", "https:", "blob:"];
77

@@ -12,9 +12,9 @@ const DATA_MEDIA_TYPE = /^data:(image|video|audio)\/[a-z0-9.+-]+[;,]/i;
1212
* `src` back if a media sink may load it, otherwise `undefined`.
1313
*
1414
* Image, video and audio elements (`"media"`) also accept a `data:` URL of
15-
* their own kind, since they never run what they load. A frame (`"frame"`)
16-
* takes only `http:`, `https:` and `blob:`: a `data:` document in a frame
17-
* runs script.
15+
* their own kind, since they never run what they load. A link (`"link"`)
16+
* takes only `http:`, `https:` and `blob:`: it opens what it points at as a
17+
* document, and a `data:` document, an SVG image among them, runs script.
1818
*/
1919
export function safeMediaSrc(src: string, sink: MediaSink): string | undefined {
2020
if (sink === "media" && DATA_MEDIA_TYPE.test(src)) return src;

‎src/ui/media-viewer.test.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,14 @@ describe("MediaViewer", () => {
237237
download: true,
238238
},
239239
{
240+
// Chrome shows no PDF in a sandboxed frame, and an unsandboxed one
241+
// runs whatever the URL serves, so a PDF is a download only.
240242
item: {
241243
name: "report.pdf",
242244
mediaType: "application/pdf",
243245
src: "https://media.example.com/report.pdf",
244246
},
245-
slot: "media-viewer-page",
247+
slot: "media-viewer-empty",
246248
download: true,
247249
},
248250
{
@@ -256,7 +258,7 @@ describe("MediaViewer", () => {
256258
download: true,
257259
},
258260
{
259-
// A data: document would run script in the frame.
261+
// A data: document would run script when opened.
260262
item: {
261263
name: "inline.pdf",
262264
mediaType: "application/pdf",
@@ -265,6 +267,16 @@ describe("MediaViewer", () => {
265267
slot: "media-viewer-empty",
266268
download: false,
267269
},
270+
{
271+
// Fine in an <img>, but as a link an SVG document runs script.
272+
item: {
273+
name: "inline.svg",
274+
mediaType: "image/svg+xml",
275+
src: "data:image/svg+xml;base64,PHN2Zy8+",
276+
},
277+
slot: "media-viewer-image",
278+
download: false,
279+
},
268280
{
269281
item: {
270282
name: "script.pdf",

‎src/ui/media-viewer.tsx‎

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export type MediaViewerItem = {
1616
readonly mediaType: string;
1717
/**
1818
* Where to load it from: `http:`, `https:`, `blob:`, or a `data:` image,
19-
* video or audio. Leave it out for an item that cannot be loaded; the
20-
* viewer then says there is no preview and offers no download.
19+
* video or audio (shown, but not offered for download). Leave it out for
20+
* an item that cannot be loaded; the viewer then says there is no preview
21+
* and offers no download.
2122
*/
2223
readonly src?: string;
2324
/** What an image shows. Defaults to `name`. */
@@ -40,15 +41,15 @@ const CONTROL =
4041
"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";
4142

4243
/**
43-
* Pictures, video, audio, PDFs and text full size, over everything, on the
44+
* Pictures, video, audio and text full size, over everything, on the
4445
* native modal `<dialog>`: it holds focus and closes on Escape, and a click
4546
* outside the media closes it too. The page behind it does not scroll. With
4647
* more than one item, the arrow keys and the Previous and Next buttons step
4748
* through them, stopping at either end. Download opens a new tab where the
4849
* browser will not download the file. On close, focus goes back to the
4950
* element given to `viewer.open`, or else to whatever had it when the viewer
50-
* opened. Escape pressed inside a PDF goes to the browser's PDF viewer, not
51-
* the dialog; Tab leaves the PDF and reaches Close.
51+
* opened. A PDF has no preview, only Download: Chrome will not show one in
52+
* a sandboxed frame, and an unsandboxed frame runs whatever its URL serves.
5253
*
5354
* Renders nothing while `viewer` is closed. A `ref` gets the `<dialog>`
5455
* while it is open.
@@ -142,7 +143,7 @@ function MediaViewerDialog({
142143
const count = viewer.items.length;
143144
const many = count > 1;
144145
const position = many ? `${index + 1} of ${count}` : null;
145-
const download = safeItemSrc(item, "media");
146+
const download = safeItemSrc(item, "link");
146147

147148
return (
148149
<dialog
@@ -344,18 +345,6 @@ function MediaViewerContent({ item }: { readonly item: MediaViewerItem }) {
344345
/>
345346
);
346347
}
347-
const page = safeItemSrc(item, "frame");
348-
if (kind === "pdf" && page !== undefined) {
349-
return (
350-
<iframe
351-
key={page}
352-
src={page}
353-
title={item.name}
354-
data-slot="media-viewer-page"
355-
className={PAGE_FRAME}
356-
/>
357-
);
358-
}
359348
return (
360349
<p
361350
data-slot="media-viewer-empty"

0 commit comments

Comments
 (0)