Public: anyone can read this; the crew can change it.
An .html file in a repository previews as a page in a sandboxed frame, with Source one tap away #10927
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10897-an-html-file-in-a-repository-previews-as → main 3 commits fork 2 threads · 2 unresolved
15 files
changed +287
−8
docs/design/areas/code.md +2
| @@ -12,3 +12,5 @@ | ||
| 12 | 12 | - `.moved-from` (#10109) — the one muted line "Moved from <old path> on <date>" under a repository head for 30 days after a move; the path wraps anywhere, so no sideways scroll at 390px. |
| 13 | 13 | |
| 14 | 14 | A font specimen (`render/font.ts`): `.font-view` sits in a bare card (`card({ extra: "bare" })`, no box of its own); `.font-name` its family heading, `.font-line` one line of sample text, and `.font-s`, `.font-m`, `.font-l` its three sizes. The small size is 14px; the larger two are display sizes, off the UI scale on purpose. |
| 15 | ||
| 16 | An HTML file's preview (`render/html_preview.ts`): `.html-frame` is a bare sandboxed frame with its own hairline (no card around it) 80vh tall (480px at least, 60vh on a phone). The frame is `color-scheme: light`, so the page inside gets the white canvas it was written for in either theme. |
docs/features/html-preview.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # HTML preview | |
| 2 | ||
| 3 | Open an `.html` or `.htm` file (up to 2 MB, no NUL bytes) in a repository and the blob page shows it as the page it is, in a frame (`src/web/render/html_preview.ts`). **Rendered** is the default; **Source** (`?view=source`) shows the markup, escaped, as for any text file; Raw and Download hand back the bytes untouched. | |
| 4 | ||
| 5 | How it is held: the frame points at the media route, which serves the file as `text/html` only under `htmlPolicy` (`src/web/pages/media.ts`): `sandbox` with no `allow-*` token (opaque origin, no script, form or popup), `default-src 'none'`, and images and stylesheets only from this repository's files at the same ref. A relative `../../` reference that climbs out of the repository resolves to an address outside that scope and the browser does not fetch it. The frame element carries `sandbox=""` too, and only the blob page gets `frame-src 'self'`. The file's bytes never enter the blob page's markup. A download is always `application/octet-stream`. | |
| 6 | ||
| 7 | Layout: the frame is 80vh tall (60vh and 360px at least on a phone, 390px wide included), with one hairline and no card around it. It is `color-scheme: light`, so its canvas is white and the page inside looks the same in either theme. Fixed height, so nothing shifts. | |
| 8 | ||
| 9 | Terminal (SSH): the file opens as its source, like any text file; the rendered page is a web-only door, because a terminal cannot draw a page. `test/tui-workspace.test.ts` holds that. |
src/tui/screens/workspace.ts +1 −1
| @@ -911,7 +911,7 @@ export function workspaceScreen( | ||
| 911 | 911 | note = |
| 912 | 912 | archiveNote(t.path, read.value.body, read.value.size, formatSize) ?? |
| 913 | 913 | `Binary file, ${formatSize(read.value.size)}.`; |
| 914 | } else raw = read.value.body; | |
| 914 | } else raw = read.value.body; // .html included: source here, the page preview is web-only | |
| 915 | 915 | } |
| 916 | 916 | entry = { note, noteTitle, noteDetail, raw, picture }; |
| 917 | 917 | remember(key, entry); |
src/web/layout.ts +7 −1
| @@ -169,6 +169,11 @@ export interface PageOptions { | ||
| 169 | 169 | * blob page of a PDF asks; no other page's policy changes. |
| 170 | 170 | */ |
| 171 | 171 | objectSrc?: boolean; |
| 172 | /** | |
| 173 | * The page frames an HTML file from the media route in a sandboxed | |
| 174 | * `<iframe>` (`frame-src 'self'`). Only the blob page of an HTML file asks. | |
| 175 | */ | |
| 176 | frameSelf?: boolean; | |
| 172 | 177 | /** |
| 173 | 178 | * The page sets a font file in the font itself (`render/font.ts`): the |
| 174 | 179 | * browser may load a font from this origin (`font-src 'self'`). Only the |
| @@ -939,8 +944,9 @@ const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; | ||
| 939 | 944 | const BARE_ORIGIN = /^https?:\/\/[a-z0-9.-]+(?::\d{1,5})?$/i; |
| 940 | 945 | |
| 941 | 946 | /** The `frame-src` directive (with its trailing space), or nothing when the page frames nothing. */ |
| 942 | export function frameSources(options: Pick<PageOptions, "turnstile" | "frameSrc">): string { | |
| 947 | export function frameSources(options: Pick<PageOptions, "turnstile" | "frameSrc" | "frameSelf">): string { | |
| 943 | 948 | const origins = [ |
| 949 | ...(options.frameSelf ? ["'self'"] : []), | |
| 944 | 950 | ...(options.turnstile ? [TURNSTILE_ORIGIN] : []), |
| 945 | 951 | ...(options.frameSrc !== undefined && BARE_ORIGIN.test(options.frameSrc) ? [options.frameSrc] : []), |
| 946 | 952 | ]; |
src/web/pages/media.ts +20
| @@ -23,6 +23,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 23 | 23 | import { holds } from "../../http/etag.ts"; |
| 24 | 24 | import { parseRange } from "../../http/range.ts"; |
| 25 | 25 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 26 | import { publicOrigin } from "../../core/public_origin.ts"; | |
| 26 | 27 | import { loadRepo, notFoundPage } from "../chrome.ts"; |
| 27 | 28 | import { cachedMedia, keepMedia } from "./tree_cache.ts"; |
| 28 | 29 | import { HEAD_BYTES, mediaType } from "../render/kinds.ts"; |
| @@ -41,6 +42,19 @@ export const MEDIA_POLICY = | ||
| 41 | 42 | export const PDF_POLICY = |
| 42 | 43 | "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'self'"; |
| 43 | 44 | |
| 45 | /** | |
| 46 | * An HTML file is served as a page only under this: `sandbox` with no | |
| 47 | * `allow-*` token gives it an opaque origin and no script, form or popup; | |
| 48 | * `default-src 'none'` blocks everything not listed. Images and stylesheets | |
| 49 | * load only from `scope`, this repository's files at this ref, so a relative | |
| 50 | * `../../` reference that climbs out of the repository (to another repository | |
| 51 | * or any page of the forge) is refused by the browser, not fetched. Inline | |
| 52 | * styles load. `frame-ancestors 'self'` lets only the forge's own blob page | |
| 53 | * frame it. | |
| 54 | */ | |
| 55 | export const htmlPolicy = (scope: string): string => | |
| 56 | `sandbox; default-src 'none'; img-src ${scope} data:; style-src 'unsafe-inline' ${scope}; frame-ancestors 'self'`; | |
| 57 | ||
| 44 | 58 | /** At or under this, the pooled reader hands the body back without a spawn. */ |
| 45 | 59 | const SMALL_BYTES = 1_000_000; |
| 46 | 60 | |
| @@ -148,6 +162,12 @@ async function mediaRead(ctx: Ctx, clock: Clock): Promise<Response> { | ||
| 148 | 162 | const type = download ? "application/octet-stream" : mediaType(path, head); |
| 149 | 163 | headers["content-type"] = type; |
| 150 | 164 | if (type === "application/pdf") headers["content-security-policy"] = PDF_POLICY; |
| 165 | if (type.startsWith("text/html")) { | |
| 166 | const origin = publicOrigin(ctx.app.config.url, ctx.url.origin); | |
| 167 | headers["content-security-policy"] = htmlPolicy( | |
| 168 | `${origin}${mediaHref(`/${repo.ownerName}/${repo.name}`, ref, "")}`, | |
| 169 | ); | |
| 170 | } | |
| 151 | 171 | |
| 152 | 172 | // A Range past an If-Range that no longer holds gets the whole new file. |
| 153 | 173 | const ifRange = ctx.request.headers.get("if-range"); |
src/web/pages/repo.ts +16
| @@ -1113,6 +1113,15 @@ export function drawsPdf(scope: RepoScope, path: string, blob: Blob, view: BlobV | ||
| 1113 | 1113 | ); |
| 1114 | 1114 | } |
| 1115 | 1115 | |
| 1116 | /** Whether the blob page of this file frames an HTML preview, and so needs `frame-src 'self'`. */ | |
| 1117 | export function drawsHtml(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean { | |
| 1118 | return ( | |
| 1119 | view !== "source" && | |
| 1120 | RENDERERS.html !== undefined && | |
| 1121 | blobRenderer(scope, path, blob)?.render === RENDERERS.html | |
| 1122 | ); | |
| 1123 | } | |
| 1124 | ||
| 1116 | 1125 | /** Whether the blob page of this file sets a font in itself, and so needs `font-src`. */ |
| 1117 | 1126 | export function drawsFont(scope: RepoScope, path: string, blob: Blob, view: BlobView): boolean { |
| 1118 | 1127 | return ( |
| @@ -1514,6 +1523,8 @@ export function codePage( | ||
| 1514 | 1523 | before?: Html; |
| 1515 | 1524 | /** The page draws a PDF in an `<object>` (`PageOptions.objectSrc`). */ |
| 1516 | 1525 | objectSrc?: boolean; |
| 1526 | /** The page frames an HTML preview (`PageOptions.frameSelf`). */ | |
| 1527 | frameSelf?: boolean; | |
| 1517 | 1528 | /** The page sets a font in itself (`PageOptions.fontSrc`). */ |
| 1518 | 1529 | fontSrc?: boolean; |
| 1519 | 1530 | /** Script only some files need (the CSV table's sort and filter). */ |
| @@ -1524,6 +1535,7 @@ export function codePage( | ||
| 1524 | 1535 | ctx, |
| 1525 | 1536 | title: o.title, |
| 1526 | 1537 | ...(o.objectSrc ? { objectSrc: true } : {}), |
| 1538 | ...(o.frameSelf ? { frameSelf: true } : {}), | |
| 1527 | 1539 | ...(o.fontSrc ? { fontSrc: true } : {}), |
| 1528 | 1540 | crumbs: scope.crumbs, |
| 1529 | 1541 | palette: scope.palette, |
| @@ -1687,6 +1699,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1687 | 1699 | explorer: hit.explorer, |
| 1688 | 1700 | content: hit.content, |
| 1689 | 1701 | objectSrc: hit.objectSrc, |
| 1702 | frameSelf: hit.frame, | |
| 1690 | 1703 | ...(hit.font ? { fontSrc: true, extraJs: fontJs } : hit.csv ? { extraJs: csvJs } : {}), |
| 1691 | 1704 | })), |
| 1692 | 1705 | ); |
| @@ -1745,6 +1758,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1745 | 1758 | ), |
| 1746 | 1759 | }); |
| 1747 | 1760 | const objectSrc = drawsPdf(scope, path, shown, view); |
| 1761 | const frame = drawsHtml(scope, path, shown, view); | |
| 1748 | 1762 | const font = drawsFont(scope, path, shown, view); |
| 1749 | 1763 | if (kept && keepable) |
| 1750 | 1764 | keepBlobPage(repo.id, ref, path, `${view}\0${table ?? ""}`, all, { |
| @@ -1752,6 +1766,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1752 | 1766 | explorer: explorerHtml, |
| 1753 | 1767 | content: pane, |
| 1754 | 1768 | objectSrc, |
| 1769 | frame, | |
| 1755 | 1770 | font, |
| 1756 | 1771 | csv: isCsvPath(path), |
| 1757 | 1772 | }); |
| @@ -1761,6 +1776,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1761 | 1776 | explorer: explorerHtml, |
| 1762 | 1777 | content: pane, |
| 1763 | 1778 | objectSrc, |
| 1779 | frameSelf: frame, | |
| 1764 | 1780 | ...(font ? { fontSrc: true, extraJs: fontJs } : isCsvPath(path) ? { extraJs: csvJs } : {}), |
| 1765 | 1781 | })); |
| 1766 | 1782 | notePhase(ctx.request, "render", paneMs + performance.now() - pageStart); |
src/web/pages/tree_cache.ts +1
| @@ -113,6 +113,7 @@ export const keptPartsStats = () => parts.stats(); | ||
| 113 | 113 | export interface RenderedBlob extends RenderedTree { |
| 114 | 114 | readonly title: string; |
| 115 | 115 | readonly objectSrc: boolean; |
| 116 | readonly frame: boolean; | |
| 116 | 117 | readonly csv: boolean; |
| 117 | 118 | readonly font: boolean; |
| 118 | 119 | } |
src/web/render/html_preview.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * An HTML file drawn as the page it is, in a sandboxed frame. The frame's | |
| 3 | * `src` is the media route, which serves the bytes as `text/html` only under | |
| 4 | * a `sandbox` policy (`pages/media.ts` `htmlPolicy`), so the file's own | |
| 5 | * script never runs, on the forge's origin or in the frame. The `sandbox` | |
| 6 | * attribute carries no `allow-*` token either: two locks, not one. Relative | |
| 7 | * images and stylesheets resolve against the media address, beside the file; | |
| 8 | * the route's policy lets only files under the same ref load (`htmlPolicy`). | |
| 9 | * The file's bytes never enter this page's markup. The page that holds the | |
| 10 | * frame sends `frame-src 'self'` (`layout.ts` `frameSelf`). | |
| 11 | */ | |
| 12 | ||
| 13 | import { html } from "../html.ts"; | |
| 14 | import { fileSize } from "../ui.ts"; | |
| 15 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 16 | ||
| 17 | export function htmlPreview(input: RenderInput): Rendered { | |
| 18 | return { | |
| 19 | lines: 0, | |
| 20 | summary: `${fileSize(input.size)} · HTML`, | |
| 21 | content: html`<iframe class="html-frame" sandbox="" src="${input.media}" title="Preview of ${input.path}" referrerpolicy="no-referrer" loading="lazy"></iframe>`, | |
| 22 | }; | |
| 23 | } |
src/web/render/kinds.ts +9 −1
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | * gets one only from the allowlist below, decided by extension and confirmed |
| 8 | 8 | * by the file's own first bytes where its format has a signature; anything |
| 9 | 9 | * else, or a file whose bytes say it is not what its name says, goes out as |
| 10 | * `application/octet-stream`. Never HTML, never script. `renderBlob` asks | |
| 10 | * `application/octet-stream`. HTML is the one page type, and only under the | |
| 11 | * sandbox policy in `pages/media.ts`; never script on this origin. `renderBlob` asks | |
| 11 | 12 | * `renderers.ts` what draws a kind, and a kind with nothing there keeps the |
| 12 | 13 | * source view it always had. |
| 13 | 14 | */ |
| @@ -16,6 +17,7 @@ export type FileKind = | ||
| 16 | 17 | | "image" |
| 17 | 18 | | "svg" |
| 18 | 19 | | "pdf" |
| 20 | | "html" | |
| 19 | 21 | | "audio" |
| 20 | 22 | | "video" |
| 21 | 23 | | "csv" |
| @@ -47,6 +49,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 47 | 49 | image: 50 * MB, |
| 48 | 50 | svg: 5 * MB, |
| 49 | 51 | pdf: 100 * MB, |
| 52 | html: 2 * MB, | |
| 50 | 53 | audio: 500 * MB, |
| 51 | 54 | video: 4_000 * MB, |
| 52 | 55 | csv: 10 * MB, |
| @@ -104,6 +107,9 @@ const adts: Magic = (head) => head[0] === 0xff && ((head[1] ?? 0) & 0xf6) === 0x | ||
| 104 | 107 | const svgRoot: Magic = (head) => |
| 105 | 108 | !head.includes(0) && /<svg[\s>]/i.test(new TextDecoder().decode(head)); |
| 106 | 109 | |
| 110 | /** HTML is text: a NUL in the head means it is something else. */ | |
| 111 | const textHead: Magic = (head) => !head.includes(0); | |
| 112 | ||
| 107 | 113 | interface Media { |
| 108 | 114 | readonly kind: FileKind; |
| 109 | 115 | /** The format as a person says it, for a header: `WebM`, `QuickTime`. */ |
| @@ -124,6 +130,8 @@ const MEDIA: Readonly<Record<string, Media>> = { | ||
| 124 | 130 | bmp: { kind: "image", name: "BMP", type: "image/bmp", magic: ascii("BM") }, |
| 125 | 131 | ico: { kind: "image", name: "ICO", type: "image/x-icon", magic: bytes(0x00, 0x00, 0x01, 0x00) }, |
| 126 | 132 | svg: { kind: "svg", name: "SVG", type: "image/svg+xml", magic: svgRoot }, |
| 133 | html: { kind: "html", name: "HTML", type: "text/html; charset=utf-8", magic: textHead }, | |
| 134 | htm: { kind: "html", name: "HTML", type: "text/html; charset=utf-8", magic: textHead }, | |
| 127 | 135 | pdf: { kind: "pdf", name: "PDF", type: "application/pdf", magic: ascii("%PDF-") }, |
| 128 | 136 | mp3: { kind: "audio", name: "MP3", type: "audio/mpeg", magic: mpegAudio }, |
| 129 | 137 | ogg: { kind: "audio", name: "Ogg", type: "audio/ogg", magic: ascii("OggS") }, |
src/web/render/renderers.ts +3
| @@ -16,6 +16,7 @@ import { gpx } from "./gpx.ts"; | ||
| 16 | 16 | import { font } from "./font.ts"; |
| 17 | 17 | import { ics } from "./ics.ts"; |
| 18 | 18 | import { vcf } from "./vcf.ts"; |
| 19 | import { htmlPreview } from "./html_preview.ts"; | |
| 19 | 20 | import { image } from "./image.ts"; |
| 20 | 21 | import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 21 | 22 | import { log, hasEscape } from "./log.ts"; |
| @@ -67,6 +68,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 67 | 68 | markdown, |
| 68 | 69 | notebook, |
| 69 | 70 | pdf, |
| 71 | html: htmlPreview, | |
| 70 | 72 | csv, |
| 71 | 73 | sqlite, |
| 72 | 74 | audio, |
| @@ -95,6 +97,7 @@ const MEDIA_KINDS: ReadonlySet<FileKind> = new Set<FileKind>([ | ||
| 95 | 97 | "image", |
| 96 | 98 | "svg", |
| 97 | 99 | "pdf", |
| 100 | "html", | |
| 98 | 101 | "audio", |
| 99 | 102 | "video", |
| 100 | 103 | "model3d", |
src/web/style/content.ts +3
| @@ -474,6 +474,9 @@ form.inline { display: inline; margin-left: auto; } | ||
| 474 | 474 | .media-pane .media-player { display: block; width: 100%; max-width: 100%; } |
| 475 | 475 | .media-pane video.media-player { height: auto; max-height: 80vh; background: var(--muted); border-radius: var(--radius-sm); } |
| 476 | 476 | |
| 477 | /* An HTML file's page in a sandboxed frame: one hairline, a fixed height, so nothing shifts. */ | |
| 478 | .html-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 1px solid var(--border); border-radius: var(--radius-sm); color-scheme: light; } | |
outdated
The existing comment "A PDF in the browser's own viewer" now sits above the HTML rules and the PDF rules follow it. Move the new block above that comment and give it its own one-line comment, so each block is described once (principle 1). |
||
| 479 | @media (max-width: 600px) { .html-frame { height: 60vh; min-height: 360px; } } | |
| 477 | 480 | /* A PDF in the browser's own viewer: a fixed frame, so nothing shifts. */ |
| 478 | 481 | .pdf-view { overflow: hidden; } |
| 479 | 482 | .pdf-frame { display: block; width: 100%; height: 80vh; min-height: 480px; border: 0; } |
test/html_preview.test.ts added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | /** | |
| 2 | * An .html file previews as a page in a sandboxed frame: the blob page holds | |
| 3 | * the frame and the switch and none of the file's markup; the media route | |
| 4 | * serves it as text/html only under the sandbox policy. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { htmlPolicy } from "../src/web/pages/media.ts"; | |
| 15 | import { fileKind, kindByName, mediaType, RENDER_CAP } from "../src/web/render/kinds.ts"; | |
| 16 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const PAGE = '<!doctype html><title>t</title><link rel="stylesheet" href="site.css"><img src="pic.png"><script>alert("xss-marker")</script>\n'; | |
| 22 | const BIG = `<p>${"x".repeat(2_040_000)}</p>`; | |
| 23 | ||
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let base = ""; | |
| 28 | ||
| 29 | const get = (path: string): Promise<Response> => fetch(`${base}${path}`, { redirect: "manual" }); | |
| 30 | const flat = (s: string): string => s.replace(/\s+/g, " "); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-html-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | base = servers.url; | |
| 37 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-html-passphrase" }); | |
| 38 | if (!a.ok) throw new Error("signup failed"); | |
| 39 | const repo = await createRepo(app, a.value, { name: "site", visibility: "public" }); | |
| 40 | if (!repo.ok) throw new Error("repo failed"); | |
| 41 | const made = await commitToBranch(app, a.value, repo.value, { | |
| 42 | message: "site", | |
| 43 | files: [ | |
| 44 | { path: "web/index.html", content: PAGE }, | |
| 45 | { path: "old.HTM", content: "<p>hi</p>" }, | |
| 46 | { path: "big.html", content: BIG }, | |
| 47 | { path: "bin.html", content: new Uint8Array([60, 0, 1, 2]) }, | |
| 48 | ], | |
| 49 | }); | |
| 50 | if (!made.ok) throw new Error("commit failed"); | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | await servers.stop(); | |
| 55 | await rm(dataDir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("the registry", () => { | |
| 59 | test("html and htm are a kind with a 2 MB cap; NUL bytes are not html", () => { | |
| 60 | const head = new TextEncoder().encode("<p>"); | |
| 61 | expect(fileKind("a.html", head)).toBe("html"); | |
| 62 | expect(fileKind("a.HTM", head)).toBe("html"); | |
| 63 | expect(kindByName("x/y.html")).toBe("html"); | |
| 64 | expect(RENDER_CAP.html).toBe(2_000_000); | |
| 65 | expect(fileKind("a.html", new Uint8Array([60, 0]))).toBe("binary"); | |
| 66 | expect(mediaType("a.html", new Uint8Array([60, 0]))).toBe("application/octet-stream"); | |
| 67 | }); | |
| 68 | }); | |
| 69 | ||
| 70 | describe("the media route for an html file", () => { | |
| 71 | test("text/html under the sandbox policy, nosniff, same-origin framing", async () => { | |
| 72 | const res = await get("/alice/site/media/main/web/index.html"); | |
| 73 | expect(res.status).toBe(200); | |
| 74 | expect(res.headers.get("content-type")).toStartWith("text/html"); | |
| 75 | const scope = `${base}/alice/site/media/main/`; | |
| 76 | const policy = res.headers.get("content-security-policy") ?? ""; | |
| 77 | expect(policy).toBe(htmlPolicy(scope)); | |
| 78 | expect(policy).toStartWith("sandbox; default-src 'none';"); | |
| 79 | expect(policy).toContain(`img-src ${scope} data:`); | |
| 80 | expect(policy).toContain(`style-src 'unsafe-inline' ${scope};`); | |
| 81 | expect(policy).not.toContain("script-src"); | |
| 82 | expect(policy).not.toContain("allow-"); | |
| 83 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 84 | expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN"); | |
| 85 | expect((await get("/alice/site/media/main/old.HTM")).headers.get("content-security-policy")).toBe(htmlPolicy(scope)); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a relative path that climbs out of the repository is outside what the policy loads", async () => { | |
| 89 | const scope = `${base}/alice/site/media/main/`; | |
| 90 | const policy = (await get("/alice/site/media/main/web/index.html")).headers.get("content-security-policy") ?? ""; | |
| 91 | const frame = new URL("/alice/site/media/main/web/index.html", base); | |
| 92 | const loads = (ref: string): boolean => new URL(ref, frame).href.startsWith(scope); | |
| 93 | expect(policy).toContain(scope); | |
| 94 | // Beside the file and above it, inside the repository: loads. | |
| 95 | expect(loads("pic.png")).toBe(true); | |
| 96 | expect(loads("../site.css")).toBe(true); | |
| 97 | // Out of the repository, to another one or to the forge's own pages: refused. | |
| 98 | expect(loads("../../../bob/site/media/main/pic.png")).toBe(false); | |
| 99 | expect(loads("../../../../account/style.css")).toBe(false); | |
| 100 | expect(loads("../../../../../x.png")).toBe(false); | |
| 101 | // And the policy never falls back to the whole origin. | |
| 102 | expect(policy).not.toContain("'self' data:"); | |
| 103 | expect(policy).not.toMatch(/(?:img|style)-src[^;]*'self'/); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a download, or bytes that are not text, is never html", async () => { | |
| 107 | const dl = await get("/alice/site/media/main/web/index.html?download=1"); | |
| 108 | expect(dl.headers.get("content-type")).toBe("application/octet-stream"); | |
| 109 | const bin = await get("/alice/site/media/main/bin.html"); | |
| 110 | expect(bin.headers.get("content-type")).toBe("application/octet-stream"); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a path that walks out of the repository is refused", async () => { | |
| 114 | expect((await get("/alice/site/media/main/web/%2e%2e/%2e%2e/x.html")).status).toBe(404); | |
| 115 | expect((await get("/alice/site/media/main/..%2f..%2fx.html")).status).toBe(404); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | describe("the blob page of an html file", () => { | |
| 120 | test("a sandboxed frame onto the media route, the switch, and none of the file's markup", async () => { | |
| 121 | const res = await get("/alice/site/blob/main/web/index.html"); | |
| 122 | const text = await res.text(); | |
| 123 | const page = flat(text); | |
| 124 | expect(page).toContain('<iframe class="html-frame" sandbox="" src="/alice/site/media/main/web/index.html"'); | |
| 125 | expect(page).not.toMatch(/sandbox="[^"]*allow-/); | |
| 126 | expect(page).toContain('aria-current="page" >Rendered'); | |
| 127 | expect(page).toContain('href="/alice/site/blob/main/web/index.html?view=source"'); | |
| 128 | expect(page).toContain('href="/alice/site/raw/main/web/index.html"'); | |
| 129 | expect(page).toContain("/alice/site/media/main/web/index.html?download=1"); | |
| 130 | expect(res.headers.get("content-security-policy")).toContain("frame-src 'self'"); | |
| 131 | // The script is in neither view as live markup. | |
| 132 | expect(text).not.toContain("<script>alert"); | |
| 133 | const source = await (await get("/alice/site/blob/main/web/index.html?view=source")).text(); | |
| 134 | expect(source).not.toContain("<iframe"); | |
| 135 | expect(source).not.toContain("<script>alert"); | |
| 136 | expect(source).toContain("<"); | |
| 137 | }); | |
| 138 | ||
| 139 | test("only that page gets frame-src", async () => { | |
| 140 | const readme = await get("/alice/site"); | |
| 141 | expect(readme.headers.get("content-security-policy")).not.toContain("frame-src"); | |
| 142 | const src = await get("/alice/site/blob/main/web/index.html?view=source"); | |
| 143 | expect(src.headers.get("content-security-policy")).not.toContain("frame-src"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("over the cap there is no frame and no Rendered", async () => { | |
| 147 | const res = await get("/alice/site/blob/main/big.html"); | |
| 148 | const page = flat(await res.text()); | |
| 149 | expect(page).not.toContain("<iframe"); | |
| 150 | expect(page).not.toContain(">Rendered"); | |
| 151 | expect(page).toContain("download=1"); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("the frame's layout", () => { | |
| 156 | test("a bare frame with its own hairline, a fixed height, a phone rule; no card around it", async () => { | |
| 157 | const page = flat(await (await get("/alice/site/blob/main/web/index.html")).text()); | |
| 158 | expect(page).not.toContain("html-view"); | |
| 159 | const css = await (await get("/alice/site")).text(); | |
| 160 | const sheet = /href="([^"]*\.css[^"]*)"/.exec(css)?.[1]; | |
| 161 | const style = sheet ? await (await get(sheet)).text() : css; | |
| 162 | const flatStyle = flat(style); | |
| 163 | expect(flatStyle).toMatch(/\.html-frame \{[^}]*height: 80vh[^}]*border: 1px solid var\(--border\)[^}]*color-scheme: light/); | |
| 164 | expect(flatStyle).toMatch(/max-width: 600px\) \{ \.html-frame \{ height: 60vh/); | |
| 165 | }); | |
| 166 | }); |
test/media.test.ts +2 −2
| @@ -65,7 +65,7 @@ beforeAll(async () => { | ||
| 65 | 65 | { path: "art/evil.svg", content: EVIL_SVG }, |
| 66 | 66 | { path: "doc.pdf", content: PDF }, |
| 67 | 67 | { path: "fake.pdf", content: "<script>alert(1)</script>" }, |
| 68 | { path: "art/page.html", content: "<script>alert(1)</script>" }, | |
| 68 | { path: "art/page.txt", content: "<script>alert(1)</script>" }, | |
| 69 | 69 | { path: "clip.mp4", content: CLIP }, |
| 70 | 70 | { path: "README.md", content: "# gallery\n" }, |
| 71 | 71 | { path: "src/a.ts", content: "export const a = 1\n" }, |
| @@ -119,7 +119,7 @@ describe("the media route", () => { | ||
| 119 | 119 | const fake = await get("/alice/gallery/media/main/art/fake.png"); |
| 120 | 120 | expect(fake.status).toBe(200); |
| 121 | 121 | expect(fake.headers.get("content-type")).toBe("application/octet-stream"); |
| 122 | const page = await get("/alice/gallery/media/main/art/page.html"); | |
| 122 | const page = await get("/alice/gallery/media/main/art/page.txt"); | |
| 123 | 123 | expect(page.headers.get("content-type")).toBe("application/octet-stream"); |
| 124 | 124 | }); |
| 125 | 125 |
test/render-kinds.test.ts +7 −3
| @@ -24,6 +24,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 24 | 24 | ico: [of(0, 0, 1, 0, 1), "image", "image/x-icon"], |
| 25 | 25 | svg: [enc('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>'), "svg", "image/svg+xml"], |
| 26 | 26 | pdf: [enc("%PDF-1.7\n"), "pdf", "application/pdf"], |
| 27 | html: [enc("<!doctype html><p>"), "html", "text/html; charset=utf-8"], | |
| 28 | htm: [enc("<p>"), "html", "text/html; charset=utf-8"], | |
| 27 | 29 | mp3: [enc("ID3\x04\0"), "audio", "audio/mpeg"], |
| 28 | 30 | ogg: [enc("OggS\0"), "audio", "audio/ogg"], |
| 29 | 31 | oga: [enc("OggS\0"), "audio", "audio/ogg"], |
| @@ -104,14 +106,16 @@ describe("the registry of file kinds", () => { | ||
| 104 | 106 | ); |
| 105 | 107 | }); |
| 106 | 108 | |
| 107 | test("nothing it can answer is HTML or script", () => { | |
| 108 | for (const ext of [...KNOWN_EXTENSIONS, "html", "htm", "js", "mjs", "xhtml", "xml"]) { | |
| 109 | test("nothing it can answer is script, and HTML only as .html or .htm (sandboxed by the media route)", () => { | |
| 110 | for (const ext of [...KNOWN_EXTENSIONS, "js", "mjs", "xhtml", "xml"]) { | |
| 111 | if (ext === "html" || ext === "htm") continue; | |
| 109 | 112 | const head = SAMPLES[ext]?.[0] ?? enc("<script>alert(1)</script>"); |
| 110 | 113 | const type = mediaType(`a.${ext}`, head); |
| 111 | 114 | expect(type).not.toMatch(/html|javascript|ecmascript|xml(?!$)/); |
| 112 | 115 | expect(type).not.toBe("text/html"); |
| 113 | 116 | } |
| 114 | expect(mediaType("index.html", enc("<html>"))).toBe("application/octet-stream"); | |
| 117 | expect(mediaType("index.html", enc("<html>"))).toBe("text/html; charset=utf-8"); | |
| 118 | expect(mediaType("index.html", new Uint8Array([60, 0]))).toBe("application/octet-stream"); | |
| 115 | 119 | }); |
| 116 | 120 | |
| 117 | 121 | test("a file with no known extension is text or binary by its bytes", () => { |
test/tui-workspace.test.ts +18
| @@ -309,6 +309,24 @@ describe("the terminal workspace", () => { | ||
| 309 | 309 | } |
| 310 | 310 | }); |
| 311 | 311 | |
| 312 | test("a .html file opens as its source: the page preview is a web-only door", async () => { | |
| 313 | const made = await createRepo(forge, alice, { name: "pages", visibility: "public", description: "pages" }); | |
| 314 | if (!made.ok) throw new Error("repo failed"); | |
| 315 | await commitToBranch(forge, alice, made.value, { | |
| 316 | message: "seed", | |
| 317 | files: [{ path: "index.html", content: "<h1>hello page</h1>\n" }], | |
| 318 | }); | |
| 319 | const h = boot(); | |
| 320 | try { | |
| 321 | await h.settle(); | |
| 322 | await open(h, "pages"); | |
| 323 | await openFile(h, "index.html"); | |
| 324 | expect(h.text()).toContain("<h1>hello page</h1>"); | |
| 325 | } finally { | |
| 326 | h.stop(); | |
| 327 | } | |
| 328 | }); | |
| 329 | ||
| 312 | 330 | test("a .patch opens as the diff a pull shows, v flips to the source, a non-diff stays text", async () => { |
| 313 | 331 | const made = await createRepo(forge, alice, { |
| 314 | 332 | name: "patches", |
Principle 6 (few borders, never a box around a thing): the frame is wrapped in a
cardjust to clip overflow. Use the frame's own hairline, or a bare card as font.ts does (card({ extra: "bare" })), and drop the.html-viewwrapper if the frame alone suffices.