Repository navigation
fix(rich): Phase D5 render trust — fail closed on untrusted static prompts + KaTeX evidence - #693
Conversation
Static prompt/option render paths (take-exam runtime, grading, result, preview, choice inputs) handed a non-null contentDocument straight to ContentDocumentRenderer on a TypeScript annotation alone (#669 F-06): a corrupt or future-version persisted value could partially render inconsistently or throw a render-time TypeError on malformed nested structure. Add the shared static read authority classifyPersistedQuestionContent / resolvePersistedQuestionDocument (@exam/contracts, §7 read contract) and resolve trust inside ContentRenderer before rendering: only rich_valid / rich_noncanonical reach the document renderer (noncanonical = read-only DISPLAY, never repair); unsupported_version / corrupt fail closed to a controlled integrity notice — never a TypeError, never a silent fallback to the plain content projection (a derived search/display text on Rich questions, not an authority). The answer-side classifier is untouched: the prompt seam reuses the same domain/contracts primitives, so prompt and answer reads keep one definition of valid Rich.
Permanent executable evidence for the §15 rendering/security contract (#669 Phase D5-B), characterized empirically against the pinned KaTeX (0.18.4) with the exact production options — no remembered defaults: - M1: normal inline/block math renders structured output - M2/R3: malformed math never crashes; parse errors preserve the source verbatim (katex-error projection), unknown commands keep tokens and arguments visible — no silent disappearance - M3/R4 + PC-F08: trust-disallowed commands produce no anchor/image/ attribute capability; the Phase-C fidelity observation is reproduced exactly (command token remains as inert text, argument not faithfully represented) and classified EXPECTED_KATEX_POLICY — source evidence stays, no active content, and §3.1 freezes no rendered-fidelity promise for trust-disallowed constructs - M4/R5: HTML-like / event-handler-like source becomes escaped text only - M6/R7: expansion abuse fails bounded by maxExpand with source preserved; dimension abuse is capped by maxSize (structural assertions, no timing thresholds) - R8: real ContentRenderer → ContentDocumentRenderer → MathRenderer composition renders inert static output - R9: real editor math path — Tiptap JSON → canonical document → static read trust boundary → rendered math — preserves source semantics - browser-level no-remote-content proof (jsdom cannot prove network behavior): the adversarial prompt renders in a real browser with zero cross-origin requests and no active/remote node
As-built references only (§33): the static question-content read classification authority joins the §18 authority map, and §15 records the Phase D5 implementation pointers (render trust boundary in ContentRenderer, encapsulated KaTeX seam with explicit expansion/size bounds as implementation parameters, permanent evidence locations). No normative semantics changed.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Second recurrence of the registered 2026-07-25 host-load flake (coverage instrumentation + parallel workers breach the 5s default testTimeout); standalone passes immediately, full verify rerun is green, and the D5 changes have no causal connection to exam-attempt lock ordering. Bookkeeping per the ledger's own recurrence protocol — no timeout, no skip, no code change.
|
Independent D5 focused review — PASS. I verified the D5-A trust boundary, D5-B KaTeX evidence, and exact-head CI at Verdict:
No D5 blocker found. #693 is mergeable after review. One follow-up should be closed before Phase E rather than bundled into D5: |
Closes the render-trust subphase of #669 (Phase D5, the final corrective phase before Phase E). Builds on the Phase-B authority (#669 Phase-B authority pass), D1 (#687 write truth), D3 (#690 read/edit trust), D2 (#691 replay truth), and D4 (#692 export truth).
F-06 is a read-trust defect.
PC-F08 is primarily a rendering/fidelity evidence class; D5 does not reclassify it as a semantic defect without new evidence. It is reproduced and classified
EXPECTED_KATEX_POLICYbelow.D5-A — static / prompt Rich read trust (closes F-06)
Every static prompt/option render path funnels through
ContentRenderer, which previously handed a non-nullcontentDocumentstraight toContentDocumentRendereron a TypeScript annotation alone — a corrupt or future-version persisted value could partially render inconsistently or throw a render-timeTypeError(document.content.map/inlines.map), reproduced verbatim by the new regressions before the fix.Call-graph audit (all production static Rich entry points):
TakeExamPagepromptContentRendererResultPageprompt / answerContentRenderer/resolveRichAnswerDocument(D4)GradingDetailPageprompt / answerContentRenderer/resolveRichAnswerDocument(D4)QuestionPreviewprompt + optionsContentRendererSingleChoiceInput/MultipleChoiceInputoptionsContentRendererAttemptDetailPageprojectionresolveRichAnswerDocument(D4)Changes:
packages/contracts/src/persistedQuestionContent.ts:classifyPersistedQuestionContent/resolvePersistedQuestionDocument, distinguishingplain/rich_valid/rich_noncanonical/unsupported_version/corrupt(§7). It reuses the exact primitive sequence of the D4 answer classifier (version gate → envelope gate → bounded preflight → schema+limits → canonical identity) so prompt and answer reads keep one definition of valid Rich — but it is a separate entry, because prompt provenance differs (contentModeis derived from slot nullness per ADR-019 B′; nolegacy_plainstate exists: the document slot never carried a legacy plain-string population). The answer classifier's semantics are untouched (§28).ContentRendererbecomes the render trust boundary: onlyrich_valid/rich_noncanonicalreachContentDocumentRenderer.rich_noncanonicalgrants read-only DISPLAY, never canonicality or repair (read ≠ repair).unsupported_version/corruptfail closed to a controlled integrity notice (content.unsafeDocument) — never a TypeError, never a silent fallback to the plaincontentprojection (on a Rich questioncontentis a server-derived search/display text, not an authority).ContentDocumentRenderer's per-node fail-safes remain as defense in depth, pinned by tests that drive the renderer directly (unreachable through the boundary); no second grammar/parser/oracle introduced.Regressions: D5A-R1 (canonical prompt, real render), R2 (docVersion 2 fail-closed, never V1-interpreted, never plain fallback), R3 (corrupt envelopes: missing/non-array/wrong-type content, string in slot), R4 (nested structures that crash
.map()), R5 (off-grammar nodes rejected before rendering, not partially rendered), R6 (historical noncanonical: explicit classification, displays, zero mutation).D5-B — rendering / KaTeX permanent executable evidence
Characterized empirically against the pinned KaTeX 0.18.4 with the exact production options (
throwOnError: false, trust: false, strict: "ignore", output: "html", maxSize: 50, maxExpand: 1000) — no remembered defaults.MathRenderer.evidence.test.tsxnow carries the permanent evidence at the library seam, the React seams, and the composition layer:EXPECTED_KATEX_POLICY):\includegraphics[...]{https://…}renders the command token as inert text while the argument is not faithfully represented — source evidence remains, no active content, degradation bounded, and §3.1 freezes no rendered-fidelity promise for trust-disallowed constructs. Not a semantic defect; notrustenabling, no grammar change.maxExpandwith the source preserved; dimension abuse is capped bymaxSize— structural assertions only, no timing thresholds. Grammar-layer bounds (CONTENT_LIMITS) are separate and unchanged.ContentRenderer → ContentDocumentRenderer → MathRenderercomposition renders inert static output.rich-content.spec.tsrenders an adversarial prompt (\includegraphicsremote URL,\href, HTML-like math) in a real browser — zero cross-origin requests, no active/remote node, inert token text visible.dangerouslySetInnerHTMLis the encapsulated KaTeX seam (pinned by test); the V1 grammar has no image/iframe/video/audio/link node types — URLs can only exist as inert text/latex.Explicitly out of scope / follow-up found during audit
examCommands.tsprojection invariant) callsplainTextProjection(question.contentDocument)directly on raw repository rows. A corrupt envelope row would surface as aTypeError500 instead of a typedValidationError— latent (the write seam canonicalizes; import is Plain-only), server-side, non-render. Not bundled (D4/publish semantics must not move in D5); recommend a focused follow-up issue.Verification
pnpm verify:static— PASS (local)pnpm verify(coverage + build) — PASS (local)bash scripts/e2e/run.sh rich-content— PASS (2/2 shards, including the new adversarial-math browser test)Closes F-06. #669 #673