torb doc: escape raw HTML of doc comments when rendering an untrusted package #234

Closed
opened 2026-10-08 22:38:45 +00:00 by orion · 11 comments
Collaborator

Follow-up of #17.

Context

std/markdown passes raw HTML through: an HTML block (BlockContent.HtmlBlock) and an inline HTML span
(MarkdownInline.Html) are written as they are (std/markdown/src/html.trb), and compiler/src/reference/render.trb
(markdownHtml) feeds every doc comment of torb doc's site through that step. The registry's documentation worker
(tools/registry/src/documentation.trb, RELEASE.md section 7.7) runs torb doc --output for a stranger's package and
serves the result from packages.torb.dev. The Content-Security-Policy on /docs/ (tools/deploy/nginx.conf) stops
scripts that are not the host's, but not <meta http-equiv="refresh"> redirects or full-page overlays made of inline
styles. A link destination is not filtered either: [x](javascript:...) is written as an href.

Design record: RELEASE.md section 7.6 (Security) and 7.7 (documentation per package: "their doc comments are a
stranger's text"). Seed: not touched (no syntax, no native, no driver flag the build uses, no name looked up by
string; the compiler's own build never passes the new flag).

Design (decided by the loop, see the comment below)

  1. std/markdown: a second HTML step beside html(), MarkdownDocument.safeHtml(). Raw HTML blocks and inline raw
    HTML are written as escaped text (so they are visible in the page, not dropped), and a link or image destination
    whose scheme is javascript:, vbscript: or data: (compared case-insensitively after trimming and removing
    ASCII control characters and whitespace) is written as an empty href/src. html() is unchanged: the CommonMark
    examples stay green.
  2. markdownHtml (and everything in compiler/src/reference/site.trb that calls it) takes the choice from a new
    torb doc flag --escape-html; without the flag the output is byte-for-byte what it is today (a package's own
    documentation is trusted).
  3. The registry worker passes --escape-html to torb doc --output. A toolchain that does not know the flag makes
    torb doc fail with "not a flag of this command", which marks the version failed: it fails closed instead of
    serving unescaped pages.

Acceptance criteria

  • std/markdown tests: with safeHtml(), <meta http-equiv="refresh" content="0;url=x"> as a block and as an
    inline span, <div style="position:fixed">x</div>, <script>, and <img onerror=...> each come out as
    escaped text (&lt;meta ...); [a](javascript:alert(1)), [a](JaVaScRiPt:x), [a]( javascript:x),
    ![a](data:text/html,x) and [a](vbscript:x) leave an empty destination; [a](https://x), [a](#top),
    [a](relative.html) and ![a](img/x.png) are unchanged. html() still
    passes all 652 CommonMark examples.
  • compiler/tests/reference.test.trb (or site.test.trb): a doc comment with a raw HTML block and an inline span
    renders as text with --escape-html and as HTML without it.
  • torb doc ... --escape-html is parsed, listed in the usage text and in docs/tooling/torb-doc.md (synopsis and
    a sentence under "The site"); docs/standard-library/markdown.md documents safeHtml; the skills under
    skills/ are regenerated (torb docs skill docs skills).
  • The registry worker calls torb doc <package> --output <site> --escape-html, covered by its existing test
    (tools/registry/tests/documentation.test.trb) asserting the argument list; RELEASE.md section 7.7 names the
    flag and the fail-closed behaviour.
  • sh tools/gates.sh a is green in forge CI.

Size

One PR, two Conventional Commits: feat(markdown): safeHtml escapes raw HTML and unsafe link schemes and
feat(doc): --escape-html for stranger's doc comments, passed by the registry worker.

Not in this issue

Serving /docs from its own cookieless origin (the second half of the proposal) is a deployment change in
tools/deploy, independent of this one; it is filed as its own issue when this has landed, if still wanted.

Follow-up of #17. ## Context `std/markdown` passes raw HTML through: an HTML block (`BlockContent.HtmlBlock`) and an inline HTML span (`MarkdownInline.Html`) are written as they are (`std/markdown/src/html.trb`), and `compiler/src/reference/render.trb` (`markdownHtml`) feeds every doc comment of `torb doc`'s site through that step. The registry's documentation worker (`tools/registry/src/documentation.trb`, RELEASE.md section 7.7) runs `torb doc --output` for a stranger's package and serves the result from `packages.torb.dev`. The Content-Security-Policy on `/docs/` (`tools/deploy/nginx.conf`) stops scripts that are not the host's, but not `<meta http-equiv="refresh">` redirects or full-page overlays made of inline styles. A link destination is not filtered either: `[x](javascript:...)` is written as an `href`. Design record: RELEASE.md section 7.6 (Security) and 7.7 (documentation per package: "their doc comments are a stranger's text"). Seed: **not touched** (no syntax, no native, no driver flag the build uses, no name looked up by string; the compiler's own build never passes the new flag). ## Design (decided by the loop, see the comment below) 1. `std/markdown`: a second HTML step beside `html()`, `MarkdownDocument.safeHtml()`. Raw HTML blocks and inline raw HTML are written as escaped text (so they are visible in the page, not dropped), and a link or image destination whose scheme is `javascript:`, `vbscript:` or `data:` (compared case-insensitively after trimming and removing ASCII control characters and whitespace) is written as an empty `href`/`src`. `html()` is unchanged: the CommonMark examples stay green. 2. `markdownHtml` (and everything in `compiler/src/reference/site.trb` that calls it) takes the choice from a new `torb doc` flag `--escape-html`; without the flag the output is byte-for-byte what it is today (a package's own documentation is trusted). 3. The registry worker passes `--escape-html` to `torb doc --output`. A toolchain that does not know the flag makes `torb doc` fail with "not a flag of this command", which marks the version `failed`: it fails closed instead of serving unescaped pages. ## Acceptance criteria - [ ] `std/markdown` tests: with `safeHtml()`, `<meta http-equiv="refresh" content="0;url=x">` as a block and as an inline span, `<div style="position:fixed">x</div>`, `<script>`, and `<img onerror=...>` each come out as escaped text (`&lt;meta ...`); `[a](javascript:alert(1))`, `[a](JaVaScRiPt:x)`, `[a]( javascript:x)`, `![a](data:text/html,x)` and `[a](vbscript:x)` leave an empty destination; `[a](https://x)`, `[a](#top)`, `[a](relative.html)` and `![a](img/x.png)` are unchanged. `html()` still passes all 652 CommonMark examples. - [ ] `compiler/tests/reference.test.trb` (or `site.test.trb`): a doc comment with a raw HTML block and an inline span renders as text with `--escape-html` and as HTML without it. - [ ] `torb doc ... --escape-html` is parsed, listed in the usage text and in `docs/tooling/torb-doc.md` (synopsis and a sentence under "The site"); `docs/standard-library/markdown.md` documents `safeHtml`; the skills under `skills/` are regenerated (`torb docs skill docs skills`). - [ ] The registry worker calls `torb doc <package> --output <site> --escape-html`, covered by its existing test (`tools/registry/tests/documentation.test.trb`) asserting the argument list; RELEASE.md section 7.7 names the flag and the fail-closed behaviour. - [ ] `sh tools/gates.sh a` is green in forge CI. ## Size One PR, two Conventional Commits: `feat(markdown): safeHtml escapes raw HTML and unsafe link schemes` and `feat(doc): --escape-html for stranger's doc comments, passed by the registry worker`. ## Not in this issue Serving `/docs` from its own cookieless origin (the second half of the proposal) is a deployment change in `tools/deploy`, independent of this one; it is filed as its own issue when this has landed, if still wanted.
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), follow-up of #17, not a duplicate. Security-relevant (raw HTML from stranger doc comments in std/markdown rendering). next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), follow-up of #17, not a duplicate. Security-relevant (raw HTML from stranger doc comments in std/markdown rendering). next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261025-0542 until 2026-10-25T08:50:00Z (no ready issue is free to implement: all five are in review/CI or blocked, intake since 2026-10-09 not refined yet)

[loop:torbscript] claim refine by session:loop-torbscript-20261025-0542 until 2026-10-25T08:50:00Z (no ready issue is free to implement: all five are in review/CI or blocked, intake since 2026-10-09 not refined yet)
Author
Collaborator

[loop:torbscript] decided by the loop: (1) A separate safeHtml() step plus a torb doc --escape-html flag, off by default, instead of changing html() or making escaping the default: html() is the CommonMark reference output (652 spec examples), and a package's own local torb doc is trusted; only the registry renders strangers' text, so it opts in. (2) Raw HTML is shown as escaped text, not dropped (the issue's acceptance says "renders as text"; an author sees what was ignored). (3) javascript:, vbscript: and data: link/image destinations are blanked in the same step: same threat, same function, a few lines, and the CSP does not cover a clicked javascript: URL in every browser. (4) The worker passes the flag unconditionally, so an older toolchain fails the version (failed) rather than serving unescaped pages: fail closed. (5) The cookieless-origin half is deployment work and stays out; no owner question: internal security hardening, no public statement, cost or published-contract break (the flag is additive). Seed: untouched. Size: one PR, two commits.

[loop:torbscript] decided by the loop: (1) A separate `safeHtml()` step plus a `torb doc --escape-html` flag, off by default, instead of changing `html()` or making escaping the default: `html()` is the CommonMark reference output (652 spec examples), and a package's own local `torb doc` is trusted; only the registry renders strangers' text, so it opts in. (2) Raw HTML is shown as escaped text, not dropped (the issue's acceptance says "renders as text"; an author sees what was ignored). (3) `javascript:`, `vbscript:` and `data:` link/image destinations are blanked in the same step: same threat, same function, a few lines, and the CSP does not cover a clicked `javascript:` URL in every browser. (4) The worker passes the flag unconditionally, so an older toolchain fails the version (`failed`) rather than serving unescaped pages: fail closed. (5) The cookieless-origin half is deployment work and stays out; no owner question: internal security hardening, no public statement, cost or published-contract break (the flag is additive). Seed: untouched. Size: one PR, two commits.
Author
Collaborator

[loop:torbscript] refine: rewrote the issue to goal, design (std/markdown safeHtml, torb doc --escape-html, registry worker passes it; link schemes blanked), testable acceptance criteria, RELEASE.md 7.6/7.7, seed untouched, one PR / two commits; decisions recorded above, no owner question. Worker tokens ~120k. next: ready

[loop:torbscript] refine: rewrote the issue to goal, design (std/markdown `safeHtml`, `torb doc --escape-html`, registry worker passes it; link schemes blanked), testable acceptance criteria, RELEASE.md 7.6/7.7, seed untouched, one PR / two commits; decisions recorded above, no owner question. Worker tokens ~120k. next: ready
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T13:05:00Z

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T13:05:00Z
Author
Collaborator

[loop:torbscript] implement checkpoint (context guard, ~85k worker tokens): nothing committed or pushed yet; worktree ws wt new torbscript/language 234 safe-html --branch loop/234-safe-html exists, clean, from main 44a35916. Read so far: CLAUDE.md, std/markdown/src/html.trb, compiler/src/reference/{command,render,site}.trb, docs/tooling/torb-doc.md. Plan: (1) commit 1 feat(markdown): safeHtml escapes raw HTML and unsafe link schemes: add a safe: Bool field to HtmlRenderer, MarkdownDocument.safeHtml() beside html(); .HtmlBlock and .Html written through escapedHtml (block wrapped in <p>), link/image destination blanked when, after trim and removal of ASCII control chars and whitespace and lowercasing, it starts with javascript:, vbscript: or data:; tests in std/markdown/tests/markdown.test.trb per the acceptance list; docs/standard-library/markdown.md. (2) commit 2 feat(doc): --escape-html ...: escapesHtml: Bool = false default parameter (defaults exist) on markdownHtml (render.trb), siteFiles/writeSite and the page helpers in site.trb (rootPage, packagePage, modulePage, itemHtml, deprecationHtml, sectionsHtml, inlineSummary; leave headScript/pageOf alone for #235), DocOptions.escapesHtml + flag + usage line in command.trb, test in compiler/tests/reference.test.trb, docs/tooling/torb-doc.md (synopsis and a sentence under "The site"), registry worker tools/registry/src/documentation.trb line ~411 add --escape-html plus its test tools/registry/tests/documentation.test.trb, RELEASE.md 7.7; then torb docs skill docs skills regenerate (needs a built torb: use CI or a quick local one, no bootstrap). Next: push, open PR Closes #234, request Argus, ci-wait. next: waiting for a continuation worker (implement not yet started in code).

[loop:torbscript] implement checkpoint (context guard, ~85k worker tokens): nothing committed or pushed yet; worktree `ws wt new torbscript/language 234 safe-html --branch loop/234-safe-html` exists, clean, from main 44a35916. Read so far: CLAUDE.md, std/markdown/src/html.trb, compiler/src/reference/{command,render,site}.trb, docs/tooling/torb-doc.md. Plan: (1) commit 1 `feat(markdown): safeHtml escapes raw HTML and unsafe link schemes`: add a `safe: Bool` field to `HtmlRenderer`, `MarkdownDocument.safeHtml()` beside `html()`; `.HtmlBlock` and `.Html` written through `escapedHtml` (block wrapped in `<p>`), link/image destination blanked when, after trim and removal of ASCII control chars and whitespace and lowercasing, it starts with `javascript:`, `vbscript:` or `data:`; tests in std/markdown/tests/markdown.test.trb per the acceptance list; `docs/standard-library/markdown.md`. (2) commit 2 `feat(doc): --escape-html ...`: `escapesHtml: Bool = false` default parameter (defaults exist) on `markdownHtml` (render.trb), `siteFiles`/`writeSite` and the page helpers in site.trb (rootPage, packagePage, modulePage, itemHtml, deprecationHtml, sectionsHtml, inlineSummary; leave headScript/pageOf alone for #235), `DocOptions.escapesHtml` + flag + usage line in command.trb, test in compiler/tests/reference.test.trb, docs/tooling/torb-doc.md (synopsis and a sentence under "The site"), registry worker `tools/registry/src/documentation.trb` line ~411 add `--escape-html` plus its test `tools/registry/tests/documentation.test.trb`, RELEASE.md 7.7; then `torb docs skill docs skills` regenerate (needs a built torb: use CI or a quick local one, no bootstrap). Next: push, open PR `Closes #234`, request Argus, ci-wait. next: waiting for a continuation worker (implement not yet started in code).
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T13:15:00Z (continuation worker 1 after the context-guard checkpoint)

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T13:15:00Z (continuation worker 1 after the context-guard checkpoint)
Author
Collaborator

[loop:torbscript] implement checkpoint 2 (context guard, ~85k tokens of continuation worker 1): branch loop/234-safe-html pushed with two commits, worktree ws wt new torbscript/language 234 safe-html --branch loop/234-safe-html (reuse). No PR yet.

Done: commit 1 feat(markdown): safeHtml ... complete (html.trb safe field + safeHtml(), text.trb hasScriptScheme, tests in std/markdown/tests/markdown.test.trb group "safe HTML", docs/standard-library/markdown.md and its copy in skills/torbscript-standard-library/references/standard-library/markdown.md by hand). Commit 2 feat(doc): --escape-html ... is PARTIAL (pushed as a WIP of the final title; finish with a new fixup commit and git rebase -i-free squash: git reset --soft HEAD~1 then recommit with the same message, force-push with lease, own branch): done = render.trb markdownHtml(..., escapesHtml: Bool = false), site.trb siteFiles/writeSite/*Page/itemHtml/... threaded, command.trb DocOptions.escapesHtml + --escape-html + usage line + header doc, compiler/tests/reference.test.trb (hostileLike fixture, 3 tests).
Still to do in commit 2: (a) docs/tooling/torb-doc.md synopsis line 30 + flag list near line 36 + a sentence under "The site"; compiler/CONTRIBUTING.md/the-torb-command reference if it lists doc flags (grep --no-run under docs/ and skills/**/the-torb-command.md); (b) tools/registry/src/documentation.trb (~line 411, torb doc --output): add --escape-html, and tools/registry/tests/documentation.test.trb argument-list assertion; tools/registry/README.md line ~120; (c) docs/design/RELEASE.md section 7.7 names the flag and fail-closed; (d) regenerate or hand-edit skills/ copies of the torb-doc doc (torb docs skill docs skills needs a torb; none installed locally: hand-edit the generated copy in skills/torbscript-*/references/tooling/torb-doc.md the same way as the markdown.md copy, links stripped). Nothing was run locally (no torb installed): CI is the first check, expect format/typo rounds (line width 120, torb format). Then: open PR Closes #234, request Argus, ci-wait.

[loop:torbscript] implement checkpoint 2 (context guard, ~85k tokens of continuation worker 1): branch `loop/234-safe-html` pushed with two commits, worktree `ws wt new torbscript/language 234 safe-html --branch loop/234-safe-html` (reuse). No PR yet. Done: commit 1 `feat(markdown): safeHtml ...` complete (html.trb `safe` field + `safeHtml()`, text.trb `hasScriptScheme`, tests in std/markdown/tests/markdown.test.trb group "safe HTML", docs/standard-library/markdown.md and its copy in skills/torbscript-standard-library/references/standard-library/markdown.md by hand). Commit 2 `feat(doc): --escape-html ...` is PARTIAL (pushed as a WIP of the final title; finish with a new fixup commit and `git rebase -i`-free squash: `git reset --soft HEAD~1` then recommit with the same message, force-push with lease, own branch): done = render.trb `markdownHtml(..., escapesHtml: Bool = false)`, site.trb `siteFiles/writeSite/*Page/itemHtml/...` threaded, command.trb `DocOptions.escapesHtml` + `--escape-html` + usage line + header doc, compiler/tests/reference.test.trb (hostileLike fixture, 3 tests). Still to do in commit 2: (a) docs/tooling/torb-doc.md synopsis line 30 + flag list near line 36 + a sentence under "The site"; compiler/CONTRIBUTING.md/the-torb-command reference if it lists doc flags (grep `--no-run` under docs/ and skills/**/the-torb-command.md); (b) tools/registry/src/documentation.trb (~line 411, `torb doc --output`): add `--escape-html`, and tools/registry/tests/documentation.test.trb argument-list assertion; tools/registry/README.md line ~120; (c) docs/design/RELEASE.md section 7.7 names the flag and fail-closed; (d) regenerate or hand-edit skills/ copies of the torb-doc doc (`torb docs skill docs skills` needs a torb; none installed locally: hand-edit the generated copy in skills/torbscript-*/references/tooling/torb-doc.md the same way as the markdown.md copy, links stripped). Nothing was run locally (no torb installed): CI is the first check, expect format/typo rounds (line width 120, `torb format`). Then: open PR `Closes #234`, request Argus, ci-wait.
Author
Collaborator

[loop:torbscript] release implement: PR #481 (Closes #234), head 3507d567, two Conventional Commits, tools/check-commits.sh clean, Argus review requested. CI: all four checks queued ("Waiting to run", runner pool backlog) after ~35 min of waiting, nothing has run yet; nothing was run locally. Worker tokens: about 120k (continuation 2). Next: waiting (CI + review on PR 481)

[loop:torbscript] release implement: PR https://git.torb.dev/torbscript/language/pulls/481 (Closes #234), head 3507d567, two Conventional Commits, tools/check-commits.sh clean, Argus review requested. CI: all four checks queued ("Waiting to run", runner pool backlog) after ~35 min of waiting, nothing has run yet; nothing was run locally. Worker tokens: about 120k (continuation 2). Next: waiting (CI + review on PR 481)
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T14:55:00Z (CI fix on PR 481 head 3507d567: pdisc stale on tools/registry/README.md, run 1785 job 20111; tools/registry test line 332 parse error assert !moduleText..., run 1784 job 20146)

[loop:torbscript] claim implement by session:loop-torbscript-20261025-0542 until 2026-10-25T14:55:00Z (CI fix on PR 481 head 3507d567: pdisc stale on tools/registry/README.md, run 1785 job 20111; tools/registry test line 332 parse error `assert !moduleText...`, run 1784 job 20146)
Author
Collaborator

[loop:torbscript] release implement: PR 481 head moved to ce73eaf3 (worker ended without a release comment; pdisc/registry test fixes folded into the two commits), CI is running on it; if red the next fix worker starts from the failing job logs, next: waiting (CI + review on PR 481)

[loop:torbscript] release implement: PR 481 head moved to ce73eaf3 (worker ended without a release comment; pdisc/registry test fixes folded into the two commits), CI is running on it; if red the next fix worker starts from the failing job logs, next: waiting (CI + review on PR 481)
argus closed this issue 2026-10-25 13:13:57 +00:00
Sign in to join this conversation.
No labels
loop
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#234
No description provided.