From 3d2483d2832ad70ee311af6d1e58d8b2e3b89ea6 Mon Sep 17 00:00:00 2001 From: JnHu Date: Sat, 3 Oct 2026 17:56:09 +0800 Subject: [PATCH 1/4] fix(rich): fail closed on untrusted static Rich prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../content/ContentRenderer.security.test.tsx | 46 +++- .../content/ContentRenderer.trust.test.tsx | 217 ++++++++++++++++++ .../shared/content/ContentRenderer.tsx | 28 ++- apps/web/src/i18n/locales/zh-CN.ts | 1 + packages/contracts/src/index.ts | 1 + .../src/persistedQuestionContent.test.ts | 168 ++++++++++++++ .../contracts/src/persistedQuestionContent.ts | 126 ++++++++++ 7 files changed, 577 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/shared/content/ContentRenderer.trust.test.tsx create mode 100644 packages/contracts/src/persistedQuestionContent.test.ts create mode 100644 packages/contracts/src/persistedQuestionContent.ts diff --git a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx index b2aeefd59..4824099a1 100644 --- a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx +++ b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx @@ -5,6 +5,7 @@ import { render } from "@testing-library/react"; import type { ContentBlock, ContentDocumentV1 } from "@exam/domain"; import { describe, expect, it } from "vitest"; import { ContentRenderer } from "./ContentRenderer"; +import { ContentDocumentRenderer } from "./ContentDocumentRenderer"; import { MathRenderer } from "./MathRenderer"; /** @@ -20,6 +21,13 @@ import { MathRenderer } from "./MathRenderer"; * These assertions are structural (DOM shape), not behavioral: jsdom never * executes injected handlers anyway, so "no on* attribute / no script element" * is the actual invariant we can prove here. + * + * Layering (#669 Phase D5-A): schema/limit-offending documents are rejected + * by the ContentRenderer trust boundary before rendering (see + * ContentRenderer.trust.test.tsx). The per-node fail-safes below are + * defense in depth: they pin ContentDocumentRenderer's own behavior for + * documents it would receive only if the boundary were bypassed, so a + * boundary regression can never silently turn into raw-HTML or crash output. */ function doc(blocks: ContentBlock[]): ContentDocumentV1 { @@ -102,6 +110,9 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () }); it("rich text runs render hostile strings as escaped text, including inside marks", () => { + // Marks are a valid combination (the grammar forbids inlineCode + other + // marks; that off-grammar case fails closed at the trust boundary — see + // ContentRenderer.trust.test.tsx D5A-R5). const hostileDoc = doc([ para(''), { @@ -110,7 +121,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () { type: "text", text: "", - marks: ["bold", "italic", "underline", "inlineCode"], + marks: ["bold", "italic", "underline"], }, ], }, @@ -192,7 +203,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () }); }); -describe("ContentRenderer — corrupt-data fail-safes", () => { +describe("ContentDocumentRenderer — defense-in-depth fail-safes (boundary bypassed)", () => { const UNSUPPORTED = "此内容包含当前版本不支持的元素"; it("replaces an unknown block node with the controlled placeholder", () => { @@ -201,7 +212,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => { para("after"), ] as unknown as ContentBlock[]); const { container } = render( - , + , ); expect(container.textContent).toContain(UNSUPPORTED); expect(container.textContent).toContain("after"); @@ -210,8 +221,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => { it("drops an unknown inline node and ignores an unknown mark while keeping sibling content", () => { const { container } = render( - { expect(container.querySelector("strong")?.textContent).toBe("styled"); }); - it("survives corrupt oversize input (deep tree, huge text run) without crashing", () => { + it("renders an oversize text run as escaped verbatim text if it is ever reached", () => { + const huge = "", + "", + "\\text{h}", + ]; + for (const latex of corpus) { + const html = katexRenderToHtml(latex, false); + assertInertHtml(html); + const dom = new DOMParser().parseFromString(html, "text/html"); + expect(dom.body.textContent).not.toBe(""); + } + }); + + // M6 — bounded rendering: explicit configuration, structural assertions + // only (no timing thresholds). + it("D5B M6/R7: expansion abuse fails bounded by maxExpand with the source preserved", () => { + const latex = "\\def\\a{\\a\\a}\\a"; + const html = katexRenderToHtml(latex, false); + // maxExpand: 1000 stops the self-expansion as a controlled parse error + // that still carries the source evidence. + expect(html).toContain("katex-error"); + const dom = new DOMParser().parseFromString(html, "text/html"); + expect(dom.body.textContent).toContain(latex); + // Structural bound on the output: a failed expansion never produces an + // unbounded render. + expect(html.length).toBeLessThan(5000); + assertInertHtml(html); + }); + + it("D5B M6: dimension abuse is capped by maxSize", () => { + const html = katexRenderToHtml("\\rule{99999em}{99999em}", true); + expect(html).not.toContain("99999"); + expect(html.length).toBeLessThan(2000); + assertInertHtml(html); + }); +}); + +describe("MathRenderer — real React seam", () => { + it("D5B-R1: normal inline math renders through the lazy production seam", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + }); + }); + + it("D5B-R2: normal block math renders in display mode", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + }); + }); + + it("D5B-R3: malformed math never crashes the seam and the source stays visible", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect( + container.querySelector(".katex-error") ?? + container.querySelector("code"), + ).not.toBeNull(); + }); + expect(container.textContent).toContain("\\frac{1}{2"); + }); + + it("D5B-R5: HTML-like math input is inert in the live DOM — no elements, escaped source only", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.textContent).not.toBe(""); + }); + expect(container.querySelector("script, img, iframe")).toBeNull(); + for (const el of Array.from(container.querySelectorAll("*"))) { + for (const attr of Array.from(el.attributes)) { + expect(/^on/i.test(attr.name)).toBe(false); + } + } + }); +}); + +describe("ContentRenderer → ContentDocumentRenderer → MathRenderer composition", () => { + function doc(blocks: ContentBlock[]): ContentDocumentV1 { + return { docVersion: 1, type: "doc", content: blocks }; + } + + it("D5B-R8: a real supported document with text, inline math, and block math composes into inert static output", async () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("质点动能"); + await waitFor(() => { + expect(container.querySelectorAll(".katex").length).toBe(2); + }); + // Inert output sweep over the composed DOM. + expect( + container.querySelectorAll( + "script, iframe, img, video, audio, object, embed, link, a", + ), + ).toHaveLength(0); + }); + + it("D5B-R9: the real editor math path — Tiptap JSON → canonical document → static read seam → rendered math — preserves source semantics", async () => { + // Editor-side Tiptap JSON (toolbar inline math + block math), the same + // shape the editor emits on every update (canonical by construction — + // contentAdapter normalizes). + const editorJson: JSONContent = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "动能定理:" }, + { type: "inlineMath", attrs: { latex: "E=mc^2" } }, + ], + }, + { type: "blockMath", attrs: { latex: "\\frac{1}{2" } }, + ], + }; + const canonical = tiptapToContentDocument(editorJson); + // The editor document passes the D5-A static read trust boundary. + const trusted = resolvePersistedQuestionDocument(canonical); + expect(trusted).not.toBeNull(); + const { container } = render( + , + ); + // The valid math renders its projection; the malformed block renders the + // controlled katex-error projection carrying the source. + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + expect(container.querySelector(".katex-error")).not.toBeNull(); + }); + // The rendered valid math is a projection, not the raw source text… + expect(container.textContent).not.toContain("E=mc^2"); + // …while the malformed block's source evidence stays visible. + expect(container.textContent).toContain("\\frac{1}{2"); + }); +}); From b9b562cc2494e2b2090a0321af71acea57628850 Mon Sep 17 00:00:00 2001 From: JnHu Date: Sat, 3 Oct 2026 18:12:03 +0800 Subject: [PATCH 3/4] docs(rich): record D5 render-trust implementation pointers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rich-content-semantic-contract.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/architecture/rich-content-semantic-contract.md b/docs/architecture/rich-content-semantic-contract.md index 8a1cb1ff5..107d3df2e 100644 --- a/docs/architecture/rich-content-semantic-contract.md +++ b/docs/architecture/rich-content-semantic-contract.md @@ -456,6 +456,21 @@ Phase-E acceptance requires permanent executable evidence for: - bounded expansion / resource usage; - no active / remote content path. +As-built (Phase D5): every static prompt / option read path classifies a +non-null `contentDocument` through the shared static read authority +(`packages/contracts/src/persistedQuestionContent.ts`) inside +`ContentRenderer` before any document rendering — only `rich_valid` / +`rich_noncanonical` (read-only DISPLAY) reach the document renderer; +`unsupported_version` / `corrupt` fail closed to a controlled integrity +notice and never fall back to the plain `content` projection. Math +rendering goes through one encapsulated KaTeX seam with trust disabled and +explicit expansion / size bounds (implementation parameters, not frozen +vocabulary). The permanent executable evidence lives at the library / +React-seam / composition layers in +`apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx` +and at the browser network level in `apps/e2e/e2e/rich-content.spec.ts` +(D5 math render security). + ## 16. Audit / telemetry Audit / telemetry must not become a second answer-persistence surface. Raw Rich @@ -499,6 +514,7 @@ are not moved into this semantic contract. | Rich V1 grammar, limits, normalization, equivalence | This document + [`packages/domain/src/content/contentDocument.ts`](../../packages/domain/src/content/contentDocument.ts) implementation | | Wire schema / type identity | [`packages/contracts/src/contentDocument.ts`](../../packages/contracts/src/contentDocument.ts) | | Persisted-answer read classification (§7) | [`packages/contracts/src/persistedRichAnswer.ts`](../../packages/contracts/src/persistedRichAnswer.ts) — the single shared classifier consumed by web read paths and API export (Phase D4) | +| Static question-content read classification (§7) | [`packages/contracts/src/persistedQuestionContent.ts`](../../packages/contracts/src/persistedQuestionContent.ts) — consumed by the `ContentRenderer` render trust boundary (Phase D5) | | Cross-boundary Exam semantics | [`exam-semantic-boundaries.md`](exam-semantic-boundaries.md) / ADR-021 | | Product capability composition | [`product-capability-composition.md`](product-capability-composition.md) / ADR-022 | | Attempt lifecycle / SaveAnswer / submit / grading / result | [`exam-runtime.md`](exam-runtime.md), ADR-005, ADR-006, ADR-008, ADR-012 | From c6263701759d73d03eff1f6fb6dc14c7a82ae1a0 Mon Sep 17 00:00:00 2001 From: JnHu Date: Sat, 3 Oct 2026 18:22:49 +0800 Subject: [PATCH 4/4] docs(test-flakes): record ea-lock-order recurrence during D5 verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/standards/test-flakes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/standards/test-flakes.md b/docs/standards/test-flakes.md index 2435833da..eebc26304 100644 --- a/docs/standards/test-flakes.md +++ b/docs/standards/test-flakes.md @@ -981,6 +981,7 @@ Error: Test timed out in 5000ms. - 2026-07-25:P5-N1 review 修复阶段,`pnpm verify` 全量 coverage 下单次出现(1/1598),standalone 立即 3/3 PASS(1.2s)。 - 2026-09-19:#550 corrective-1 campaign 门禁(`pnpm test`,plain turbo 无 coverage)单次出现——同机数分钟前刚结束 95-min production-mode soak 测量,turbo 15 包并行负载击穿 5s 默认 testTimeout;standalone `npx vitest run tests/concurrency/ea-lock-order.test.ts` 立即 3/3 PASS(tests 1.8s),全量 `pnpm test` 复跑 EXIT=0(2,813 passed / 12 skipped)。与 2026-08-31 条目同机制(宿主负载型,操作背景引入),无代码改动、不调 timeout、不 skip。 +- 2026-10-03:#669 Phase D5 门禁(`pnpm verify`,coverage + `API_TEST_MAX_WORKERS=4`)单次出现,错误与 2026-07-25 首次登记完全一致(`Test timed out in 5000ms` @ `tests/concurrency/ea-lock-order.test.ts:292`);standalone 立即 3/3 PASS(tests 1.5s)。与 D5 改动(Rich 静态读信任、KaTeX 证据、文档)无因果,机制同前两条(coverage 插桩 + 并行负载),无代码改动、不调 timeout、不 skip;全量 verify 复跑通过。 ---