Skip to content

fix: sanitise single-page docs and serve a Content-Security-Policy - #62

Merged
oto-macenauer-absa merged 2 commits into
masterfrom
fix/single-page-docs-xss
Aug 14, 2026
Merged

fix: sanitise single-page docs and serve a Content-Security-Policy#62
oto-macenauer-absa merged 2 commits into
masterfrom
fix/single-page-docs-xss

Conversation

@oto-macenauer-absa

Copy link
Copy Markdown
Collaborator

Stacked on #61. The CSP is verified by the container suite that PR adds. The diff narrows once #61 merges.

What

#42's core vulnerability, its defence-in-depth half, and the related hardening in #55.

1. The actual fix — publish-time sanitisation

markdown-it ran with html: true and nothing sanitised the result. That output is re-hosted with set:html on the knowledge base's own origin, next to every other team's docs. So a <script> or an <img onerror> in any onboarding repo's markdown executed against every other doc's page. markdown-it's validateLink blocks javascript: in markdown links; raw HTML bypasses it entirely.

actions/publish-single-page-docs/src/sanitize.js puts rendered HTML through an allowlist before it is ever packed into an artifact. Raw HTML stays supported — only the subset the doc stylesheet actually renders survives.

Dropped: <script>, on* handlers, <style> and style=, <iframe>/<object>/<embed>, forms and form controls, javascript:/data: URLs. Inner text is kept where there is any, because losing a paragraph to an unsupported wrapper is a worse failure than losing the wrapper.

contract/SINGLE_PAGE.md documents exactly what is kept and dropped, since this is a behaviour change for every onboarding repo.

2. The CSP — and the two things that had to happen first

The policy's point is script-src 'self' with no 'unsafe-inline'. Getting there was the interesting part.

The action's mermaid bootstrap was an inline <script>. Now assets/mermaid-init.js. Hashing it instead would have hard-coupled nginx's config to the action's exact bytes, breaking bundles published by any other version.

Already-published bundles still contain the inline version. Confirmed against the sibling example repo's real dist.tar.gz: example-service/index.html ships one inline script. This repo does not control when those repos re-publish, so enforcing script-src 'self' would have silently killed every already-published doc's diagrams — no error, no failed request, just diagrams that stopped rendering.

So scripts/hoist-inline-scripts.js moves any inline script found in a sub-app artifact into a file at build time, before anything else reads apps/. On this build it hoisted 12 scripts across 4 apps — the vendored docs-example fixture alone had 5, so this was never only about mermaid. Content-addressed filenames, original attributes preserved, and a classic <script src> blocks the parser exactly like an inline one, so execution order is unchanged.

Verified in a real browser, against the real 2.5 MB mermaid bundle, on a doc published by the old action: diagrams render as SVG, zero CSP violations.

What the policy does not do

style-src keeps 'unsafe-inline'. Base.astro's @layer ordering fix, the shadow-DOM compat styles and sub-app style= attributes are all inline, and CSS is a much weaker primitive than script. Tightening it means hoisting styles the same way — worth doing, not worth blocking this on.

frame-src allows https: because type: "iframe" entries embed arbitrary external sites.

3. #55 — step outputs out of the run: body

${{ }} is substituted into the shell text before bash sees it. The values are validated kebab-case slugs today so this was not exploitable — but that safety rested on a regex in a different file, for a reusable action other repositories run with contents: write. Now passed through env:, matching what the upload step already did.

Verification

action self-test                                      → 20 checks (was 11; +9)
npm run test:container                                → 28 passed  (was 21; +7)
npx playwright test --config=playwright.config.ci.js  → 22 passed
npm test                                              → 61 passed  (was 57; +4)

The self-test asserts the allowlist rather than describing it — each case is markdown a doc repo could commit today, and the "keeps what the contract promises" case guards against over-sanitising. The container suite asserts the CSP in a browser, not just as a header: a policy that blocks something needed fails silently, and only a browser reports it. build-integrity.spec.js fails the build if any inline script reaches dist/, so the CSP can never quietly become wrong.

Caught during this work: my allowlist initially dropped <label>, which would have cost task-list checkboxes their accessible name and click target. The self-test now pins it.

Follow-ups worth filing

  • Hoist inline <style> the same way, then drop 'unsafe-inline' from style-src.
  • #54 (self-hosting Inter) would let style-src/font-src drop the Google Fonts origins.

Closes #42
Closes #55

oto-macenauer-absa and others added 2 commits August 14, 2026 14:14
The publishing action renders markdown with html:true and applied no
sanitisation. The result is re-hosted with set:html on the knowledge
base's own origin next to every other doc, and no CSP was set anywhere,
so a <script> or an <img onerror> in any onboarding repo's markdown ran
against every other team's page. markdown-it blocks javascript: in
markdown links, but raw HTML bypasses that entirely.

Rendered HTML now goes through an allowlist in the action, before it is
ever packed into an artifact. Raw HTML stays supported — only the subset
the doc stylesheet renders survives. contract/SINGLE_PAGE.md documents
exactly what is kept and what is dropped.

Behind that, the deployment serves a CSP whose point is script-src 'self'
with no 'unsafe-inline'. Two things had to change first. The action's
mermaid bootstrap moves from an inline <script> to assets/mermaid-init.js.
And bundles published before that change still carry the inline version —
verified against the sibling example repo's real artifact — so the
marketplace hoists any inline script it finds in a sub-app artifact into a
file at build time. Without that, enforcing the policy would have silently
killed every already-published doc's diagrams.

Verified in a browser against the real 2.5 MB mermaid bundle, on a doc
published by the old action: diagrams render, zero CSP violations.

style-src still allows 'unsafe-inline' — Base.astro's layer-ordering fix,
the shadow-DOM compat styles and sub-app style attributes are all inline,
and CSS is a much weaker primitive than script. Noted as follow-up work.

Also moves two step outputs in action.yml out of the run: script body and
into env. The values are validated kebab-case slugs today, so this was not
exploitable, but a ${{ }} expression is substituted into the shell text
before bash sees it, and that safety should not rest on a regex in another
file for a reusable action other repos run with contents: write.

Closes #42
Closes #55

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqFK6yffibtCBTF8xZ4hXW
The container suite gained browser-based tests: asserting the CSP header
is not the same as asserting the page still works under it, and only a
browser reports a violation. The image job had no browser installed, so
those five tests could not launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqFK6yffibtCBTF8xZ4hXW
@oto-macenauer-absa
oto-macenauer-absa merged commit 19e8785 into master Aug 14, 2026
7 checks passed
@oto-macenauer-absa
oto-macenauer-absa deleted the fix/single-page-docs-xss branch August 14, 2026 12:42
oto-macenauer-absa added a commit that referenced this pull request Aug 14, 2026
> **Stacked on #62.** It touches the same `DOC_CSS` block and the
regenerated fixture `doc.css`, so branching off master would have
conflicted. The diff narrows once #62 merges.

## What

`.mp-doc pre.mermaid` already sets `text-align: center`. It never worked
for narrow diagrams, because mermaid gives the `<svg>` it swaps in
`display: block` — and a block box ignores its parent's `text-align`.
Wide diagrams filled the column so it looked fine; narrow ones sat flush
left.

Measured in the browser, 800px reading column, before:

| diagram | svg width | space left | space right |
|---|---|---|---|
| flowchart (wide) | 758px | 21px | 21px |
| second diagram | 228px | 21px | **551px** |

After: `left: 275px, right: 275px`.

## Changes

One line, in `actions/publish-single-page-docs/src/template.js`
(`DOC_CSS`):

```css
.mp-doc pre.mermaid svg { max-width: 100%; height: auto; display: block; margin-inline: auto; }
```

plus the regenerated test fixture `doc.css` that mirrors it.

Docs published by an earlier version of the action keep their existing
stylesheet until they re-publish.

## Verification

```
npm run build:headless               → 8 apps integrated
npx playwright test build-integrity  → 29 passed
```

Centring itself is verified by browser measurement (the table above),
not by a test: the committed fixture stubs mermaid with a no-op — the
real bundle is 2.5 MB and is deliberately not committed — so a
fixture-based layout assertion would pass without ever rendering an SVG.

Closes #63
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant