feat(regression): sanitize + validate published regression content (S2-E1) - #152
Conversation
…2-E1)
Regression reports (a Markdown writeup + SVG residual plot + JSON fact-box per
fit) are dataset-authored content served to anonymous users, but the build copied
the SVG/JSON verbatim and rendered the Markdown without sanitization. S2-E1 adds
the security boundary and the dataset-validation checks for that content, without
yet moving the files to the dataset (that is S2-E2/E3) — so it is backward-
compatible and hardens the existing docs/regression path in place.
New `src/kayak/web/regression.py` (used by both build and validate-dataset):
- render_markdown_safe: filter maintainer sections → python-markdown → nh3.clean
with an explicit tag/attr allowlist, http(s)-only URL schemes, link rel, and a
local-only img@src filter (strips raw HTML, event handlers, javascript:/data:,
external image sources).
- validate_svg: defusedxml parse (no DTD/entities/external) + a strict
element/attribute allowlist, then re-serialize from the validated tree (never
serve verbatim). Rejects script/foreignObject/use/image/event-handlers/href/
style, foreign-namespace elements, backslash CSS-escapes, and any resource
function (url()/image()/element(), case-insensitive) that isn't a same-document
url(#id). SVG is treated as active content (the browser renders /static/.../
<slug>.svg as a document outside the page CSP).
- validate_json_sidecar: size cap + reject NaN/Infinity + object-with-slug shape
(accepts both the pair-linear and lead/lag schemas).
Build (web/build/deploy.py): _deploy_regression_artifacts now renders/validates
through the module (re-serialized SVG, no shutil.copy2) and is fail-closed.
validate-dataset: new _check_regression ties every non-empty
calc_expression.provenance_slug to its {md,svg,json} triple, follows md links to
require companion reports + referenced sidecars (order-independent; code-fence-
aware), runs the content through the sanitizers (reject nonconforming), and warns
on orphan reports via a non-fatal warnings channel that leaves validate_dataset()'s
list[str] error contract intact. No slugs + no regression/ dir = none configured.
Deps: add nh3 + defusedxml (regenerated uv.lock; mypy override for defusedxml).
Fixture: a declared report + a link-reachable lead/lag companion under
tests/fixtures/dataset/regression/ (authored by build_dataset_fixture.py so a
regen reproduces it), exercising the slug↔report check, the maintainer-section
drop, the reference closure, and the SVG/JSON sanitizers. All 25 real reports
pass the new checks (move-verbatim invariant). Two rounds of adversarial review
(incl. headless-browser bypass testing) drove the SVG allowlist hardening.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review — PR #152Findings1. [HIGH] This will fail production deploy against the current
I reproduced the deploy gate directly from this PR head: So merging this code alone bricks the next deploy before migrate/sync/build. Either gate the new 2. [MED] The PR reverts current The diff against Verification
|
Adversarial review — PR #152 (S2-E1: sanitize + validate regression content)Reviewed on the live host. This is a security PR whose trust boundary is the SVG served as a standalone document outside the page CSP — a stored-XSS surface — so I built an isolated venv with the PR's pinned deps ( Verdict: strong, merge-worthy security work. The SVG/markdown sanitizers held up to every attack I threw; the architecture (allowlist + re-serialize-from-tree, nh3 backstop, fail-closed build) is right. Two defense-in-depth findings below — one a genuine (if low-severity, orphan-only) XSS path that this exact PR is the right place to close, one a robustness note. Neither is a blocker. What I verifiedSVG bypass battery — all defeated (each rejected, or stripped by re-serialization with the clean bytes served):
Markdown — nh3 strips everything dangerous: raw Build is fail-closed ( Findings1. [Low–Med, defense-in-depth] Unescaped report filename injected into the generated
2. [Low, defense-in-depth] Nits / notes
Net: the core sanitizers are genuinely solid and I couldn't break them; closing the |
…G PI/comment 1. [HIGH] Deploy-brick: deploy.sh runs `validate-dataset` against the real kayak_data on every deploy, and the new _check_regression made the 17 declared provenance_slugs require a regression/ directory that won't exist until the S2 file-move (D1). Deploying E1 alone would have failed the gate before migrate/sync. Fix: the regression/ directory is the dataset's opt-in — while it is absent the reports still live engine-side (docs/regression), so declared slugs yield a non-fatal WARNING, not an error. Once the dir exists the check is fully enforced (the fixture exercises that path). Real dataset now validates OK (1 warning); fixture still fully enforced. 2. [Low-Med] Orphan-filename XSS: the generated report page interpolated the file stem into <title> unescaped, and the build globbed every *.md regardless of slug — so an orphan file whose NAME carried HTML metacharacters could emit a stored-XSS page (validate-dataset only warns on orphans). Fix: the build now skips any regression file whose stem isn't a safe slug (new kayak.web.regression.is_safe_slug, shared with the validator's charset), and html.escape()s the title as defence-in-depth. (Also renamed the shadowing local `html` template var → `page`, and added encoding= to the page write.) 3. [Low] validate_svg now rejects XML comments and processing instructions explicitly (e.g. `<?xml-stylesheet href="javascript:…"?>`), so the reject-nonconforming contract no longer rests on ElementTree silently dropping them. A single leading `<?xml …?>` declaration is still allowed. Also merged current main to pick up #151's deploy/SETUP.md (the branch predated it, so the diff had appeared to revert the healthchecks.io SA-teardown-C note). All 25 real reports still pass; full gate green (1536 tests, wheel-smoke). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed in Finding 1 (HIGH) — deploy-brick. You're right: So E1 is now genuinely deploy-safe on its own; D1 lands the files and the check engages. Finding 2 (MED) — reverted SETUP.md. Stale-branch artifact: this branch predated #151's merge, so the diff appeared to delete the healthchecks.io / branch-protection note. Merged current Security review finding 1 (orphan-filename XSS). Fixed both ways you suggested: the build now skips any regression file whose stem isn't a safe slug (new Security review finding 2 (SVG PI/comment). All 25 real reports still pass; full gate green locally (1536 tests, wheel-smoke, ruff/mypy). Holding for your go before merge. |
Adversarial rereview — PR #152 follow-up (
|
Adversarial re-review — PR #152 (after
|
…review)
The PI/comment guard treated any leading `<?xml…` as the XML declaration via
`startswith("<?xml")`, which also matched a leading `<?xml-stylesheet …?>` PI —
so that one construct (the very example the guard cites) was stripped, not
flagged. Distinguish the real declaration by the whitespace the XML spec requires
after the `xml` target (`re.match(r"<\?xml\s", body)`); the PI target
`xml-stylesheet` has a `-`, so it falls through to the reject. Not exploitable
(ElementTree drops the PI and the build serves the re-serialized SVG), but it
closes the reject-nonconforming contract over its own example.
Tests: leading xml-stylesheet PI now rejected; a genuine `<?xml version?>`
declaration still accepted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the residual in Leading
(Not exploitable — ElementTree drops the PI and the build serves the re-serialized SVG — but it closes the contract over its own example.) Full gate green locally (1538 tests, wheel-smoke, ruff/mypy). All 25 real reports still pass; deploy gate on the real |
First PR of slice S2 (regression content → dataset). The full S2 arc moves the published regression reports out of the engine repo into
kayak_dataand hardens how they're served; this PR (E1) adds the sanitization + validation capability and the dataset-validation checks without moving any files yet — so it's backward-compatible and immediately hardens the existingdocs/regressionbuild path. It becomes the trustedengine_test_reffor the later dataset PR.Decomposition (each its own PR): E1 (this) → D1 (kayak_data adds
regression/) → D2 (pin bump) → E2 (build/generator source →DATASET_DIR) → E3 (delete engine copy). Ordering keeps reports served throughout.What's in E1
New
src/kayak/web/regression.py— pure sanitizers used by bothlevels buildandlevels validate-dataset:render_markdown_safe— filter maintainer sections → python-markdown →nh3.clean(tag/attr allowlist, http(s)-only schemes,rel="noopener noreferrer nofollow", local-onlyimg@src). Strips raw HTML, event handlers,javascript:/data:, external images.validate_svg—defusedxmlparse (no DTD/entities/external) + strict element/attr allowlist, then re-serialize from the validated tree (never verbatim). Rejectsscript/foreignObject/use/image/event-handlers/href/style, foreign-namespace elements, backslash CSS-escapes, and any resource function (url()/image()/element(), case-insensitive) that isn't a same-documenturl(#id). SVG is treated as active content (served at/static/regression/<slug>.svg, rendered as a document outside the page CSP).validate_json_sidecar— size cap + rejectNaN/Infinity+ object-with-slugshape (both pair-linear and lead/lag schemas).Build (
web/build/deploy.py):_deploy_regression_artifactsnow renders/validates through the module (re-serialized SVG, noshutil.copy2) and is fail-closed.validate-dataset: new
_check_regressionties every non-emptycalc_expression.provenance_slugto its{md,svg,json}triple, follows md links to require companion (lead/lag) reports + referenced sidecars (order-independent + code-fence-aware), runs content through the sanitizers (reject nonconforming), and warns on orphan reports via a non-fatal warnings channel that leavesvalidate_dataset() -> list[str]'s error contract intact. No slugs + noregression/dir = "none configured".Deps: add
nh3+defusedxml(regenerateduv.lock; mypy override fordefusedxml). Fixture: a declared report + a link-reachable lead/lag companion undertests/fixtures/dataset/regression/, authored bybuild_dataset_fixture.pyso a regen reproduces it.Verification
pytest -m "not slow"(1533 passed) / wheel-smoke /git diff --check.docs/regressionreports pass the new SVG/JSON/markdown checks (move-verbatim invariant) — the build won't hard-fail on current content.levels validate-dataset tests/fixtures/datasetpasses with the lead/lag companion reachable via link (no orphan warning).url()bypass + a BFS-ordering gap in the slug→report check; both fixed (with regression tests). Round 2 confirmed convergence; the SVG allowlist additionally rejects foreign-namespace elements and backslash CSS-escapes as defense-in-depth.Not in this PR
File move (E2/E3), the
kayak_dataregression/dir + pin bump (D1/D2), and the generator--outdefault repoint (E2). Build still readsBASE_DIR/docs/regressionhere.🤖 Generated with Claude Code