fix: sanitise single-page docs and serve a Content-Security-Policy - #62
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
#42's core vulnerability, its defence-in-depth half, and the related hardening in #55.
1. The actual fix — publish-time sanitisation
markdown-itran withhtml: trueand nothing sanitised the result. That output is re-hosted withset:htmlon 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'svalidateLinkblocksjavascript:in markdown links; raw HTML bypasses it entirely.actions/publish-single-page-docs/src/sanitize.jsputs 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>andstyle=,<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.mddocuments 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>. Nowassets/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.htmlships one inline script. This repo does not control when those repos re-publish, so enforcingscript-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.jsmoves any inline script found in a sub-app artifact into a file at build time, before anything else readsapps/. 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-srckeeps'unsafe-inline'.Base.astro's@layerordering fix, the shadow-DOM compat styles and sub-appstyle=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-srcallowshttps:becausetype: "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 withcontents: write. Now passed throughenv:, matching what the upload step already did.Verification
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.jsfails the build if any inline script reachesdist/, 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
<style>the same way, then drop'unsafe-inline'fromstyle-src.#54(self-hosting Inter) would letstyle-src/font-srcdrop the Google Fonts origins.Closes #42
Closes #55