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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7385-the-browser-s-own-pdf-viewer-is-allowed → main 7 commits fork 1 thread · 1 unresolved
9 files
changed +174
−10
docs/design/areas/code.md +1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | # Code |
| 2 | 2 | |
| 3 | 3 | - `.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. | |
| 4 | 5 | |
| 5 | 6 | `.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()`. | ||
| 15 | 15 | - **Headers:** `X-Content-Type-Options: nosniff` and |
| 16 | 16 | `Content-Security-Policy: sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'`, |
| 17 | 17 | 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`. | |
| 18 | 43 | - **Range:** one `bytes=` range answers 206 with `Content-Range`; a suffix |
| 19 | 44 | (`bytes=-N`) is the last N bytes; past the end is 416. A list of ranges is |
| 20 | 45 | answered whole, which the RFC allows. `If-Range` is honoured. |
src/web/layout.ts +7 −1
| @@ -108,6 +108,12 @@ export interface PageOptions { | ||
| 108 | 108 | * origin" — so pages that do not ask for it keep the stricter policy. |
| 109 | 109 | */ |
| 110 | 110 | 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; | |
| 111 | 117 | /** |
| 112 | 118 | * The page carries a Cloudflare Turnstile widget (`/signup`): its script, |
| 113 | 119 | * frame and verification call may come from challenges.cloudflare.com, and |
| @@ -710,7 +716,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 710 | 716 | // The external stylesheet plus this page's own nonce'd <style>/<script> |
| 711 | 717 | // are the only resources allowed; connect-src 'self' lets the live |
| 712 | 718 | // 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'`, | |
| 714 | 720 | "referrer-policy": "same-origin", |
| 715 | 721 | "x-content-type-options": "nosniff", |
| 716 | 722 | // 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"; | ||
| 25 | 25 | |
| 26 | 26 | /** The policy every media answer leaves with. */ |
| 27 | 27 | export const MEDIA_POLICY = |
| 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'"; | |
| 29 | 39 | |
| 30 | 40 | /** At or under this, the pooled reader hands the body back without a spawn. */ |
| 31 | 41 | const SMALL_BYTES = 1_000_000; |
| @@ -55,6 +65,8 @@ export async function mediaBlob(ctx: Ctx): Promise<Response> { | ||
| 55 | 65 | const headers: Record<string, string> = { |
| 56 | 66 | "x-content-type-options": "nosniff", |
| 57 | 67 | "content-security-policy": MEDIA_POLICY, |
| 68 | // Same-origin embedding (the blob page's `<object>`) and nothing else. | |
| 69 | "x-frame-options": "SAMEORIGIN", | |
| 58 | 70 | "referrer-policy": "same-origin", |
| 59 | 71 | "accept-ranges": "bytes", |
| 60 | 72 | etag, |
| @@ -81,7 +93,9 @@ export async function mediaBlob(ctx: Ctx): Promise<Response> { | ||
| 81 | 93 | if (sniff === "busy" || sniff === "gone") return busy(); |
| 82 | 94 | head = await new Response(sniff).bytes(); |
| 83 | 95 | } |
| 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; | |
| 85 | 99 | |
| 86 | 100 | // A Range past an If-Range that no longer holds gets the whole new file. |
| 87 | 101 | 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"; | ||
| 74 | 74 | import { renderMarkdown } from "../markdown.ts"; |
| 75 | 75 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 76 | 76 | import { HEAD_BYTES } from "../render/kinds.ts"; |
| 77 | import { rendererFor } from "../render/renderers.ts"; | |
| 77 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; | |
| 78 | 78 | import { mediaHref } from "./media.ts"; |
| 79 | 79 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| 80 | 80 | import { repoForkOf } from "../../core/origins.ts"; |
| @@ -1005,6 +1005,26 @@ export function editAction(scope: RepoScope, ref: string, path: string): Html { | ||
| 1005 | 1005 | }); |
| 1006 | 1006 | } |
| 1007 | 1007 | |
| 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 | ||
| 1008 | 1028 | /** |
| 1009 | 1029 | * One file, framed by the same path header the tree uses. The header is |
| 1010 | 1030 | * sticky and the whole pane is one element, because a script swapping a |
| @@ -1027,9 +1047,7 @@ export function renderBlob( | ||
| 1027 | 1047 | const base = scope.base; |
| 1028 | 1048 | const encoded = encodeURIComponent(o.ref); |
| 1029 | 1049 | 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); | |
| 1033 | 1051 | const view: BlobView = renderer ? (o.view ?? "rendered") : "source"; |
| 1034 | 1052 | const drawn = |
| 1035 | 1053 | view === "rendered" && renderer |
| @@ -1365,6 +1383,8 @@ export function codePage( | ||
| 1365 | 1383 | content: Html; |
| 1366 | 1384 | header?: Html; |
| 1367 | 1385 | before?: Html; |
| 1386 | /** The page draws a PDF in an `<object>` (`PageOptions.objectSrc`). */ | |
| 1387 | objectSrc?: boolean; | |
| 1368 | 1388 | /** Script only some files need (the CSV table's sort and filter). */ |
| 1369 | 1389 | extraJs?: string; |
| 1370 | 1390 | }, |
| @@ -1372,6 +1392,7 @@ export function codePage( | ||
| 1372 | 1392 | return page({ |
| 1373 | 1393 | ctx, |
| 1374 | 1394 | title: o.title, |
| 1395 | ...(o.objectSrc ? { objectSrc: true } : {}), | |
| 1375 | 1396 | crumbs: scope.crumbs, |
| 1376 | 1397 | palette: scope.palette, |
| 1377 | 1398 | body: html`${o.header ?? scopeHeader(ctx, scope, "code", { compact: true })} |
| @@ -1466,12 +1487,14 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1466 | 1487 | const refs = await git.read(() => refSetFrom(scope.source)); |
| 1467 | 1488 | let highlightMs: number | undefined; |
| 1468 | 1489 | const renderStart = performance.now(); |
| 1490 | const view = blobView(ctx.url.searchParams.get("view")); | |
| 1491 | const shown = capped(blob.value); | |
| 1469 | 1492 | const pane = renderBlob(scope, { |
| 1470 | 1493 | refs, |
| 1471 | 1494 | ref, |
| 1472 | 1495 | path, |
| 1473 | blob: capped(blob.value), | |
| 1474 | view: blobView(ctx.url.searchParams.get("view")), | |
| 1496 | blob: shown, | |
| 1497 | view, | |
| 1475 | 1498 | highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms), |
| 1476 | 1499 | all: ctx.url.searchParams.get("all") === "1", |
| 1477 | 1500 | }); |
| @@ -1504,6 +1527,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1504 | 1527 | ), |
| 1505 | 1528 | }), |
| 1506 | 1529 | content: pane, |
| 1530 | objectSrc: drawsPdf(scope, path, shown, view), | |
| 1507 | 1531 | ...(isCsvPath(path) ? { extraJs: csvJs } : {}), |
| 1508 | 1532 | }); |
| 1509 | 1533 | 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"; | ||
| 11 | 11 | import { image } from "./image.ts"; |
| 12 | 12 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 13 | 13 | import { markdown } from "./markdown.ts"; |
| 14 | import { pdf } from "./pdf.ts"; | |
| 14 | 15 | import { video } from "./video.ts"; |
| 15 | 16 | |
| 16 | 17 | /** 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>>> = { | ||
| 46 | 47 | image, |
| 47 | 48 | svg: image, |
| 48 | 49 | markdown, |
| 50 | pdf, | |
| 49 | 51 | csv, |
| 50 | 52 | audio, |
| 51 | 53 | video, |
src/web/style/content.ts +8
| @@ -305,6 +305,14 @@ form.inline { display: inline; margin-left: auto; } | ||
| 305 | 305 | .media-pane .media-player { display: block; width: 100%; max-width: 100%; } |
| 306 | 306 | .media-pane video.media-player { height: auto; max-height: 80vh; background: var(--muted); border-radius: var(--radius-sm); } |
| 307 | 307 | |
| 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 | ||
| 308 | 316 | /* ---- image view ---- */ |
| 309 | 317 | .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); } |
| 310 | 318 | .imgview-dim { min-width: 9ch; } |
test/media.test.ts +56 −1
| @@ -17,13 +17,14 @@ import { mediaGate } from "../src/git/env.ts"; | ||
| 17 | 17 | import { blobStream, MEDIA_QUEUE_MAX, refTip, readerFor } from "../src/git/read.ts"; |
| 18 | 18 | import { repoDir } from "../src/git/repos.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { seedAdmin } from "./helpers/admin.ts"; |
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | 23 | |
| 24 | 24 | quietLogs(); |
| 25 | 25 | |
| 26 | 26 | 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"); | |
| 27 | 28 | const EVIL_SVG = |
| 28 | 29 | '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script><rect width="1" height="1"/></svg>\n'; |
| 29 | 30 | /** Over the pooled read, so it is sniffed and streamed off git. */ |
| @@ -61,6 +62,8 @@ beforeAll(async () => { | ||
| 61 | 62 | { path: "art/logo.png", content: PNG }, |
| 62 | 63 | { path: "art/fake.png", content: "not a png at all\n" }, |
| 63 | 64 | { path: "art/evil.svg", content: EVIL_SVG }, |
| 65 | { path: "doc.pdf", content: PDF }, | |
| 66 | { path: "fake.pdf", content: "<script>alert(1)</script>" }, | |
| 64 | 67 | { path: "art/page.html", content: "<script>alert(1)</script>" }, |
| 65 | 68 | { path: "clip.mp4", content: CLIP }, |
| 66 | 69 | { path: "README.md", content: "# gallery\n" }, |
| @@ -114,6 +117,32 @@ describe("the media route", () => { | ||
| 114 | 117 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); |
| 115 | 118 | }); |
| 116 | 119 | |
| 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 | ||
| 117 | 146 | test("a single range is 206 with its window", async () => { |
| 118 | 147 | const res = await get("/alice/gallery/media/main/art/logo.png", { range: "bytes=1-3" }); |
| 119 | 148 | expect(res.status).toBe(206); |
| @@ -314,6 +343,32 @@ describe("the media permits", () => { | ||
| 314 | 343 | }); |
| 315 | 344 | }); |
| 316 | 345 | |
| 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 | ||
| 317 | 372 | describe("the blob page's switch", () => { |
| 318 | 373 | const flat = (s: string): string => s.replace(/\s+/g, " "); |
| 319 | 374 |
new above
offline
The frame-ancestors addition is fine, but PDF_POLICY below is only valuable once a page embeds the PDF; see change 1.