Public: anyone can read this; the crew can change it.

The browser's own PDF viewer is allowed to draw a file from the media route inside the blob page #7402

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7385-the-browser-s-own-pdf-viewer-is-allowed → main 7 commits fork 1 thread · 1 unresolved

  • Merged. scout/7385-the-browser-s-own-pdf-viewer-is-allowed was merged into main as 81847ee.

Review

Sign in to review this change.
9 files changed +174 −10
docs/design/areas/code.md +1
@@ -1,5 +1,6 @@
11 # Code
22
33 - `.blob-view` — the file view in the code browser (`renderBlob` in `src/web/pages/repo.ts`): on a Markdown file, wraps the Source/Rendered `.seg` toggle at the end of the file's bar, set 8px off the line count with its segments at 12.5px.
4 - `.pdf-view`, `.pdf-links`, `.pdf-frame`, `.pdf-fallback` — a PDF on its blob page (`render/pdf.ts`): one card holding the browser's viewer in a full-width frame 80vh tall (480px at least), under an always-visible Open · Download line (the frame is 60vh on a phone so a thumb can scroll past it), with a padded link line inside for a browser that cannot draw it.
45
56 `.ex-more` also closes a windowed directory page and the side Files column: the "Show all N entries" link to `?all=1`.
docs/features/file-media.md +25
@@ -15,6 +15,31 @@ media type, for `<img>`, `<video>`, `<audio>`, `<object>` and `fetch()`.
1515 - **Headers:** `X-Content-Type-Options: nosniff` and
1616 `Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'`,
1717 so an SVG with a `<script>` is drawn and never runs.
18 - **PDF policy:** Chrome's built-in PDF viewer refuses to run in a document
19 under `sandbox`, so `application/pdf`, and only that type, is sent
20 `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'self'`
21 (no `sandbox`), still `nosniff` and inline. It is chosen by the confirmed
22 type, so a file named `.pdf` without `%PDF-` bytes, and any `?download=1`,
23 keep the sandbox policy. A PDF is never served as HTML.
24 - **Framing:** every media answer carries `frame-ancestors 'self'` and
25 `X-Frame-Options: SAMEORIGIN`: the blob page may embed it, no other site.
26 - **The page's side:** the blob page of a PDF (`render/pdf.ts`) holds an
27 `<object type="application/pdf">` on the media URL, with an open link and a
28 download link inside it for a browser that cannot draw it. That page, and no
29 other, passes `objectSrc: true` to `page()`, which adds `object-src 'self'`
30 to its policy; it is sent only when the page really draws the PDF, never for
31 `?view=source`, and never site-wide. The PDF is drawn for a repository on
32 this forge, not for a browsed origin (the media route is not there). Like
33 audio and video it is streamed, drawn by name at any size up to its cap; the
34 media route confirms the bytes.
35 - **What a PDF's own script can reach.** Dropping `sandbox` makes a PDF opened
36 at its media URL a same-origin document. The viewers run a PDF's script in
37 their own engine (PDFium's in Chrome, pdf.js's scripting sandbox in
38 Firefox), which has no access to this origin's DOM, cookies or `fetch`, and
39 the response's `default-src 'none'` forbids the document any load. That is
40 the viewers' documented isolation, not something this route enforces; it is
41 why the exception is one type, chosen by confirmed `%PDF-` bytes, and why a
42 `.pdf` of HTML, an SVG, and every download keep `sandbox`.
1843 - **Range:** one `bytes=` range answers 206 with `Content-Range`; a suffix
1944 (`bytes=-N`) is the last N bytes; past the end is 416. A list of ranges is
2045 answered whole, which the RFC allows. `If-Range` is honoured.
src/web/layout.ts +7 −1
@@ -108,6 +108,12 @@ export interface PageOptions {
108108 * origin" — so pages that do not ask for it keep the stricter policy.
109109 */
110110 scriptAsset?: string;
111 /**
112 * The page draws a PDF from the media route in an `<object>`: the browser's
113 * own viewer may load it from this origin (`object-src 'self'`). Only the
114 * blob page of a PDF asks; no other page's policy changes.
115 */
116 objectSrc?: boolean;
111117 /**
112118 * The page carries a Cloudflare Turnstile widget (`/signup`): its script,
113119 * frame and verification call may come from challenges.cloudflare.com, and
@@ -710,7 +716,7 @@ export function page(options: PageOptions, status = 200): Response {
710716 // The external stylesheet plus this page's own nonce'd <style>/<script>
711717 // are the only resources allowed; connect-src 'self' lets the live
712718 // event stream (EventSource) and fragment fetches reach same-origin.
713 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${bound}'; script-src 'nonce-${bound}'${options.scriptAsset === undefined ? "" : " 'self'"}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
719 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${bound}'; script-src 'nonce-${bound}'${options.scriptAsset === undefined ? "" : " 'self'"}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; ${options.objectSrc ? "object-src 'self'; " : ""}form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
714720 "referrer-policy": "same-origin",
715721 "x-content-type-options": "nosniff",
716722 // Markup is revalidated every time; only the hashed assets it names are
src/web/pages/media.ts +16 −2
@@ -25,7 +25,17 @@ import { HEAD_BYTES, mediaType } from "../render/kinds.ts";
2525
2626 /** The policy every media answer leaves with. */
2727 export const MEDIA_POLICY =

The frame-ancestors addition is fine, but PDF_POLICY below is only valuable once a page embeds the PDF; see change 1.

28 "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'";
28 "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'self'";
29
30 /**
31 * The one type with its own policy: Chrome's built-in PDF viewer will not run
32 * in a sandboxed document, so `application/pdf` (and nothing else) leaves
33 * without `sandbox`. It is still `default-src 'none'`, `nosniff`, and never
34 * HTML: the type is confirmed by the `%PDF-` bytes, so the viewer is all that
35 * ever opens it.
36 */
37 export const PDF_POLICY =
38 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'self'";
2939
3040 /** At or under this, the pooled reader hands the body back without a spawn. */
3141 const SMALL_BYTES = 1_000_000;
@@ -55,6 +65,8 @@ export async function mediaBlob(ctx: Ctx): Promise<Response> {
5565 const headers: Record<string, string> = {
5666 "x-content-type-options": "nosniff",
5767 "content-security-policy": MEDIA_POLICY,
68 // Same-origin embedding (the blob page's `<object>`) and nothing else.
69 "x-frame-options": "SAMEORIGIN",
5870 "referrer-policy": "same-origin",
5971 "accept-ranges": "bytes",
6072 etag,
@@ -81,7 +93,9 @@ export async function mediaBlob(ctx: Ctx): Promise<Response> {
8193 if (sniff === "busy" || sniff === "gone") return busy();
8294 head = await new Response(sniff).bytes();
8395 }
84 headers["content-type"] = download ? "application/octet-stream" : mediaType(path, head);
96 const type = download ? "application/octet-stream" : mediaType(path, head);
97 headers["content-type"] = type;
98 if (type === "application/pdf") headers["content-security-policy"] = PDF_POLICY;
8599
86100 // A Range past an If-Range that no longer holds gets the whole new file.
87101 const ifRange = ctx.request.headers.get("if-range");
src/web/pages/repo.ts +30 −6
@@ -74,7 +74,7 @@ import { flashTo, fragment, page, redirect } from "../layout.ts";
7474 import { renderMarkdown } from "../markdown.ts";
7575 import { csvJs, isCsvPath } from "../render/csv.ts";
7676 import { HEAD_BYTES } from "../render/kinds.ts";
77 import { rendererFor } from "../render/renderers.ts";
77 import { RENDERERS, rendererFor } from "../render/renderers.ts";
7878 import { mediaHref } from "./media.ts";
7979 import { browseBase, localScope, type RepoScope } from "../scope.ts";
8080 import { repoForkOf } from "../../core/origins.ts";
@@ -1005,6 +1005,26 @@ export function editAction(scope: RepoScope, ref: string, path: string): Html {
10051005 });
10061006 }
10071007
1008 /**
1009 * What draws this file, if anything: the registry decides what it is and
1010 * whether it is under its cap; a kind with no renderer yet, or one over its
1011 * cap, keeps the source view. A PDF is drawn from the media route, which only
1012 * a repository on this forge has.
1013 */
1014 function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<typeof rendererFor> {
1015 const found = rendererFor(path, blob);
1016 return found?.render === RENDERERS.pdf && scope.kind !== "local" ? undefined : found;
1017 }
1018
1019 /** Whether the blob page of this file draws a PDF, and so needs `object-src`. */
1020 export function drawsPdf(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean {
1021 return (
1022 view !== "source" &&
1023 RENDERERS.pdf !== undefined &&
1024 blobRenderer(scope, path, blob)?.render === RENDERERS.pdf
1025 );
1026 }
1027
10081028 /**
10091029 * One file, framed by the same path header the tree uses. The header is
10101030 * sticky and the whole pane is one element, because a script swapping a
@@ -1027,9 +1047,7 @@ export function renderBlob(
10271047 const base = scope.base;
10281048 const encoded = encodeURIComponent(o.ref);
10291049 const blob = o.blob;
1030 // The registry decides what this file is and whether anything draws it;
1031 // a kind with no renderer yet, or one over its cap, keeps the source view.
1032 const renderer = rendererFor(o.path, blob);
1050 const renderer = blobRenderer(scope, o.path, blob);
10331051 const view: BlobView = renderer ? (o.view ?? "rendered") : "source";
10341052 const drawn =
10351053 view === "rendered" && renderer
@@ -1365,6 +1383,8 @@ export function codePage(
13651383 content: Html;
13661384 header?: Html;
13671385 before?: Html;
1386 /** The page draws a PDF in an `<object>` (`PageOptions.objectSrc`). */
1387 objectSrc?: boolean;
13681388 /** Script only some files need (the CSV table's sort and filter). */
13691389 extraJs?: string;
13701390 },
@@ -1372,6 +1392,7 @@ export function codePage(
13721392 return page({
13731393 ctx,
13741394 title: o.title,
1395 ...(o.objectSrc ? { objectSrc: true } : {}),
13751396 crumbs: scope.crumbs,
13761397 palette: scope.palette,
13771398 body: html`${o.header ?? scopeHeader(ctx, scope, "code", { compact: true })}
@@ -1466,12 +1487,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
14661487 const refs = await git.read(() => refSetFrom(scope.source));
14671488 let highlightMs: number | undefined;
14681489 const renderStart = performance.now();
1490 const view = blobView(ctx.url.searchParams.get("view"));
1491 const shown = capped(blob.value);
14691492 const pane = renderBlob(scope, {
14701493 refs,
14711494 ref,
14721495 path,
1473 blob: capped(blob.value),
1474 view: blobView(ctx.url.searchParams.get("view")),
1496 blob: shown,
1497 view,
14751498 highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms),
14761499 all: ctx.url.searchParams.get("all") === "1",
14771500 });
@@ -1504,6 +1527,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
15041527 ),
15051528 }),
15061529 content: pane,
1530 objectSrc: drawsPdf(scope, path, shown, view),
15071531 ...(isCsvPath(path) ? { extraJs: csvJs } : {}),
15081532 });
15091533 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
src/web/render/pdf.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * A PDF drawn by the browser's own viewer, from the media route. The
3 * `<object>` holds a link, so with the viewer missing or JavaScript off the
4 * file is still one tap away; the frame has a fixed height, so nothing shifts
5 * when the viewer arrives. The page that holds it sends `object-src 'self'`
6 * (`layout.ts` `objectSrc`).
7 */
8
9 import { html } from "../html.ts";
10 import type { Rendered, RenderInput } from "./renderers.ts";
11
12 export function pdf(input: RenderInput): Rendered {
13 return {
14 lines: 0,
15 content: html`<div class="card pdf-view">
16 <p class="pdf-links">
17 <a href="${input.media}">Open</a> ·
18 <a href="${input.media}?download=1">Download</a>
19 </p>
20 <object class="pdf-frame" data="${input.media}" type="application/pdf">
21 <p class="pdf-fallback">
22 This browser cannot show the PDF here.
23 <a href="${input.media}">Open it</a> or
24 <a href="${input.media}?download=1">download it</a>.
25 </p>
26 </object>
27 </div>`,
28 };
29 }
src/web/render/renderers.ts +2
@@ -11,6 +11,7 @@ import { csv } from "./csv.ts";
1111 import { image } from "./image.ts";
1212 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1313 import { markdown } from "./markdown.ts";
14 import { pdf } from "./pdf.ts";
1415 import { video } from "./video.ts";
1516
1617 /** What a renderer is handed: the file, and where its bytes can be fetched. */
@@ -46,6 +47,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
4647 image,
4748 svg: image,
4849 markdown,
50 pdf,
4951 csv,
5052 audio,
5153 video,
src/web/style/content.ts +8
@@ -305,6 +305,14 @@ form.inline { display: inline; margin-left: auto; }
305305 .media-pane .media-player { display: block; width: 100%; max-width: 100%; }
306306 .media-pane video.media-player { height: auto; max-height: 80vh; background: var(--muted); border-radius: var(--radius-sm); }
307307
308 /* A PDF in the browser's own viewer: a fixed frame, so nothing shifts. */
309 .pdf-view { overflow: hidden; }
310 .pdf-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 0; }
311 .pdf-fallback { padding: 16px 20px; }
312 .pdf-links { padding: 8px 20px; border-bottom: 1px solid var(--border); font-size: 13px; }
313 /* A phone: a shorter frame, so a thumb can scroll the page past it. */
314 @media (max-width: 600px) { .pdf-frame { height: 60vh; min-height: 360px; } }
315
308316 /* ---- image view ---- */
309317 .imgview-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 16px; font-size: 12.5px; border-bottom: 1px solid var(--border); }
310318 .imgview-dim { min-width: 9ch; }
test/media.test.ts +56 −1
@@ -17,13 +17,14 @@ import { mediaGate } from "../src/git/env.ts";
1717 import { blobStream, MEDIA_QUEUE_MAX, refTip, readerFor } from "../src/git/read.ts";
1818 import { repoDir } from "../src/git/repos.ts";
1919 import { serve, type Servers } from "../src/http/server.ts";
20 import { MEDIA_POLICY } from "../src/web/pages/media.ts";
20 import { MEDIA_POLICY, PDF_POLICY } from "../src/web/pages/media.ts";
2121 import { seedAdmin } from "./helpers/admin.ts";
2222 import { quietLogs } from "./helpers/globals.ts";
2323
2424 quietLogs();
2525
2626 const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 1, 2, 3]);
27 const PDF = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\n%%EOF\n");
2728 const EVIL_SVG =
2829 '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script><rect width="1" height="1"/></svg>\n';
2930 /** Over the pooled read, so it is sniffed and streamed off git. */
@@ -61,6 +62,8 @@ beforeAll(async () => {
6162 { path: "art/logo.png", content: PNG },
6263 { path: "art/fake.png", content: "not a png at all\n" },
6364 { path: "art/evil.svg", content: EVIL_SVG },
65 { path: "doc.pdf", content: PDF },
66 { path: "fake.pdf", content: "<script>alert(1)</script>" },
6467 { path: "art/page.html", content: "<script>alert(1)</script>" },
6568 { path: "clip.mp4", content: CLIP },
6669 { path: "README.md", content: "# gallery\n" },
@@ -114,6 +117,32 @@ describe("the media route", () => {
114117 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
115118 });
116119
120 test("a PDF drops sandbox for the viewer; an SVG keeps it; both embed same-origin only", async () => {
121 const pdf = await get("/alice/gallery/media/main/doc.pdf");
122 expect(pdf.status).toBe(200);
123 expect(pdf.headers.get("content-type")).toBe("application/pdf");
124 expect(pdf.headers.get("content-security-policy")).toBe(
125 "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'self'",
126 );
127 expect(PDF_POLICY).not.toContain("sandbox");
128 expect(pdf.headers.get("x-content-type-options")).toBe("nosniff");
129 expect(pdf.headers.get("content-disposition")).toBeNull();
130 expect(pdf.headers.get("x-frame-options")).toBe("SAMEORIGIN");
131
132 const svg = await get("/alice/gallery/media/main/art/evil.svg");
133 expect(svg.headers.get("content-security-policy")).toBe(
134 "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'self'",
135 );
136 expect(svg.headers.get("x-frame-options")).toBe("SAMEORIGIN");
137
138 // Only bytes that are a PDF get the PDF policy; a download never does.
139 const fake = await get("/alice/gallery/media/main/fake.pdf");
140 expect(fake.headers.get("content-type")).toBe("application/octet-stream");
141 expect(fake.headers.get("content-security-policy")).toBe(MEDIA_POLICY);
142 const dl = await get("/alice/gallery/media/main/doc.pdf?download=1");
143 expect(dl.headers.get("content-security-policy")).toBe(MEDIA_POLICY);
144 });
145
117146 test("a single range is 206 with its window", async () => {
118147 const res = await get("/alice/gallery/media/main/art/logo.png", { range: "bytes=1-3" });
119148 expect(res.status).toBe(206);
@@ -314,6 +343,32 @@ describe("the media permits", () => {
314343 });
315344 });
316345
346 describe("the blob page of a PDF", () => {
347 const policy = async (path: string): Promise<{ csp: string; html: string }> => {
348 const res = await get(path);
349 const bare = (res.headers.get("content-security-policy") ?? "").replace(/'nonce-[^']+'/g, "'nonce'");
350 return { csp: bare, html: await res.text() };
351 };
352
353 test("draws the media URL in an object and alone sends object-src 'self'", async () => {
354 const pdf = await policy("/alice/gallery/blob/main/doc.pdf");
355 expect(pdf.html).toContain('<object class="pdf-frame" data="/alice/gallery/media/main/doc.pdf" type="application/pdf">');
356 expect(pdf.html).toContain('href="/alice/gallery/media/main/doc.pdf?download=1"');
357 expect(pdf.html).toContain('<p class="pdf-links">');
358 expect(pdf.csp).toContain("object-src 'self'; ");
359
360 // Every other policy is exactly what it was, with object-src left out.
361 const readme = await policy("/alice/gallery/blob/main/README.md");
362 expect(readme.csp).not.toContain("object-src");
363 expect(pdf.csp.replace("object-src 'self'; ", "")).toBe(readme.csp);
364 const source = await policy("/alice/gallery/blob/main/doc.pdf?view=source");
365 expect(source.csp).not.toContain("object-src");
366 expect(source.html).not.toContain("<object");
367 for (const path of ["/alice/gallery", "/alice/gallery/blob/main/fake.pdf", "/alice/gallery/blob/main/art/logo.png"])
368 expect((await policy(path)).csp).not.toContain("object-src");
369 });
370 });
371
317372 describe("the blob page's switch", () => {
318373 const flat = (s: string): string => s.replace(/\s+/g, " ");
319374

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs