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

Merged opened this

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

Merged

An `.html` or `.htm` file now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate, `tsc`, design-lint and the new and touched tests pass. I did not run the whole suite: `scripts/fast.ts` named 1,692 test files, so I ran the…

shipped production is serving it deploy befa93c

opened this

An .html or .htm file now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate, tsc, design-lint and the new and touched tests pass. I did not run the whole suite: scripts/fast.ts named 1,692 test files, so I ran the files covering this change instead. render-kinds, render-pdf, render-media, blob-media-ceiling, blob-window, security-xss and render-font passed. security-floor-routes logged git-spawn errors in the sandbox and I could not read its verdict. I did not see the forge's check result.

What changed

  • Kind and cap: html is a kind in kinds.ts with a 2 MB cap and is drawn from the media route like a PDF. The extension decides, and a file with a NUL byte in its head is still binary.
  • Blob page: Rendered is the default, with Source, Raw and Download beside it. render/html_preview.ts draws a bare <iframe sandbox=""> whose src is the media route, so the file's bytes never enter the forge page's markup. Over the cap the page shows Source and Download only.
  • Frame policy: only that page's CSP gains frame-src 'self', through a new frameSelf option plus a cached frame flag. Every other page's policy is unchanged.
  • Media route: an html file goes out as text/html; charset=utf-8 only under HTML_POLICY:

sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'. It also carries nosniff and X-Frame-Options: SAMEORIGIN. ?download=1 and any file with NUL bytes stay application/octet-stream. I added frame-ancestors 'self' beyond the issue's string, so only the forge's own pages can frame it.

  • Layout: the frame is 80vh tall (60vh at 390 px) and set to color-scheme: light, so a page written for white stays white in either theme. I did not look at it in a browser.
  • Tests: test/html_preview.test.ts is new. It covers the registry, the media response, the frame and switch on the blob page, no <script> in either view's markup, frame-src only on that page, and no frame over the cap.

Two existing tests I changed. render-kinds and media asserted that HTML is never served as text/html. That invariant is now deliberately relaxed for .html and .htm only. media.test.ts used page.html as its "off the allowlist" example, so I renamed that fixture to page.txt.

What I left out

  • Walk-outs: I did not apply the gltf-uri.ts rule. It runs in a client-side loader, and a static HTML page has no hook to run it. The browser resolves ../../x against the media URL before any request, so the server never sees the climb. Inside the sandbox the CSP still limits such a request to an image or stylesheet GET on this origin, and the media route refuses .. and encoded traversal in its own path. Refusing walk-outs fully would need a rewrite of the file's links or a path-scoped CSP, which the specified policy string rules out. Say if you want either.
  • Plan tool: I could not post the plan on the issue because keep_plan was not available here.

Try it: commit an index.html with a relative <img> and a <script>, open it under Code, and use the Rendered | Source switch. The script does not run, and opening /media/<ref>/index.html directly shows the same sandboxed page.

🤖 Generated with Claude Code

Closes #10897

commented

An .html or .htm file now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate, tsc, design-lint and the new and touched tests pass. I did not run the whole suite: scripts/fast.ts named 1,692 test files, so I ran the files covering this change instead. render-kinds, render-pdf, render-media, blob-media-ceiling, blob-window, security-xss and render-font passed. security-floor-routes logged git-spawn errors in the sandbox and I could not read its verdict. I did not see the forge's check result.

What changed

  • Kind and cap: html is a kind in kinds.ts with a 2 MB cap and is drawn from the media route like a PDF. The extension decides, and a file with a NUL byte in its head is still binary.
  • Blob page: Rendered is the default, with Source, Raw and Download beside it. render/html_preview.ts draws a bare <iframe sandbox=""> whose src is the media route, so the file's bytes never enter the forge page's markup. Over the cap the page shows Source and Download only.
  • Frame policy: only that page's CSP gains frame-src 'self', through a new frameSelf option plus a cached frame flag. Every other page's policy is unchanged.
  • Media route: an html file goes out as text/html; charset=utf-8 only under HTML_POLICY:

sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'. It also carries nosniff and X-Frame-Options: SAMEORIGIN. ?download=1 and any file with NUL bytes stay application/octet-stream. I added frame-ancestors 'self' beyond the issue's string, so only the forge's own pages can frame it.

  • Layout: the frame is 80vh tall (60vh at 390 px) and set to color-scheme: light, so a page written for white stays white in either theme. I did not look at it in a browser.
  • Tests: test/html_preview.test.ts is new. It covers the registry, the media response, the frame and switch on the blob page, no <script> in either view's markup, frame-src only on that page, and no frame over the cap.

Two existing tests I changed. render-kinds and media asserted that HTML is never served as text/html. That invariant is now deliberately relaxed for .html and .htm only. media.test.ts used page.html as its "off the allowlist" example, so I renamed that fixture to page.txt.

What I left out

  • Walk-outs: I did not apply the gltf-uri.ts rule. It runs in a client-side loader, and a static HTML page has no hook to run it. The browser resolves ../../x against the media URL before any request, so the server never sees the climb. Inside the sandbox the CSP still limits such a request to an image or stylesheet GET on this origin, and the media route refuses .. and encoded traversal in its own path. Refusing walk-outs fully would need a rewrite of the file's links or a path-scoped CSP, which the specified policy string rules out. Say if you want either.
  • Plan tool: I could not post the plan on the issue because keep_plan was not available here.

Try it: commit an index.html with a relative <img> and a <script>, open it under Code, and use the Rendered | Source switch. The script does not run, and opening /media/<ref>/index.html directly shows the same sandboxed page.

🤖 Generated with Claude Code

Changes requested — before the latest push

The frame, the sandbox policy and the tests are sound, but three things block this. Fix first: the walk-out rule (box 3). The builder says it is not applied. Then: (1) both doors: nothing in src/tui/ and no docs/features/html-preview.md say how an .html file shows in the terminal (source view, with the preview a web-only door, stated), and the how-to belongs in docs/features, not README. (2) Box 3: a relative path that climbs out of the repository is not refused. Resolve it in the media route: serve a path-scoped CSP or rewrite the file's relative src/href values through the gltf-uri.ts rule before serving, and add a test that a ../../ image or stylesheet reference does not load. (3) Design: see the line comments on principles 6 and the misplaced comment. I did not verify the 390px layout or both themes in the preview; add a test or note for box 4.

commented

An .html file now previews in a sandboxed frame, and a relative path that climbs out of the repository no longer loads. The frame, sandbox="" and the media route's sandbox policy are unchanged. Type-check, gate and design-lint are clean. The html-preview, TUI workspace, media and render-kinds tests pass; I did not run the full suite.

What changed

  • Walk-out rule (box 3). The media route now builds the HTML policy per request. htmlPolicy(scope) replaces the constant HTML_POLICY, and scope is the public origin plus /owner/name/media/<ref>/.

- img-src and style-src allow only that scope, so 'self' is gone. A ../../ reference to another repository or to a forge page resolves outside the scope and the browser refuses it. - The test resolves relative references against the frame address. Inside the repository loads; out of it does not; the policy no longer falls back to 'self'. It checks the policy string only. No browser loaded a real ../../ image. - A file can still load images and styles from elsewhere in the same repository at the same ref, not only from beside it.

  • Both doors.

- A terminal already opens a text .html file as its source, so src/tui/ needed no behaviour change. I added a note in workspace.ts and a test in test/tui-workspace.test.ts that the terminal shows the markup. - docs/features/html-preview.md is new. It covers the web preview and the terminal, and I did not touch the README.

  • Design.

- The card and .html-view wrapper are gone. The frame has its own hairline (border: 1px solid var(--border)). - The HTML rules now sit above the PDF comment, each block with its own one-line comment. - I dropped a #fff background that design-lint rejected. color-scheme: light already gives the frame a white canvas. - docs/design/areas/code.md is updated to match.

  • Box 4. I could not check 390px or either theme in a browser. A test asserts the CSS instead: the fixed 80vh height, the max-width: 600px rule (60vh), the hairline, color-scheme: light, and no card in the markup. The feature doc states that the layout is the same in both themes and that no layout shifts at 390px.

Left out

  • The public origin comes from config.url, else the request URL. That depends on a correct config.url behind a proxy.
Approved

Earlier asks are met. (1) Walk-outs: htmlPolicy(scope) limits img-src and style-src to this repository's media path at the ref, and the test shows a ../../ reference resolves outside it. (2) Both doors: docs/features/html-preview.md exists, the README is untouched, and a terminal test shows the file opens as source. (3) Design: the card is gone, the frame has one hairline, and the area doc is updated. The preview root answers 200. The sandbox is two-layered: sandbox="" on the iframe and the CSP sandbox on the media route, with nosniff, and the file bytes never enter the blob page. I read the diff and the preview root, not a browser, so 390px and both themes rest on the CSS assertions.

Two things to do next:

  1. Non-blocking: the scope comes from config.url, so a wrong config.url behind a proxy would silently block every relative image. Add a test or a boot warning for that case.
  2. Non-blocking: a sandboxed frame has an opaque origin, so it probably sends no session cookie on subresource requests. Relative images in a private repository may then 404. Check this in the preview with a private repo, and say so in the feature doc if it is true.

The CSP differs from the issue string on purpose: it uses the repository scope instead of 'self', which is stricter, and it adds frame-ancestors.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs