Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions apps/e2e/e2e/rich-content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1194,3 +1194,104 @@ test.describe("editor identity, reconciliation, grading closure", () => {
expect(JSON.stringify(finalAnswer)).not.toContain("丁作答");
});
});

test.describe("#669 D5 math render security (browser evidence)", () => {
/**
* jsdom cannot prove "no network fetch is initiated by math rendering"
* (D5-B M5): this test renders trust-disallowed / remote-referencing /
* HTML-like latex through the real static read path in a real browser and
* asserts the page initiates zero cross-origin requests and mounts no
* active/remote element for the adversarial payload. Complements the
* library-level characterization in
* apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx.
*/
test("adversarial math renders inert with zero external network fetches", async ({
page,
request,
}) => {
const adminToken = await adminApiToken(request);
const courseId = await seedCourseId(request, adminToken);
const createRes = await adminPost(request, adminToken, "/api/questions", {
courseId,
score: 5,
difficulty: 1,
type: "single_choice",
contentDocument: {
docVersion: 1,
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: `D5B安全-${STAMP}:` },
{
type: "inlineMath",
latex:
"\\includegraphics[width=5em]{https://evil.example/x.png}",
},
],
},
{
type: "blockMath",
latex:
"\\href{https://evil.example}{click}<img src=x onerror=alert(1)>",
},
],
},
options: [
{
id: "opt-a",
content: "选项A",
contentDocument: null,
isCorrect: true,
},
{
id: "opt-b",
content: "选项B",
contentDocument: null,
isCorrect: false,
},
],
standardAnswer: "opt-a",
rubric: null,
});
expect(createRes.status(), await createRes.text()).toBe(201);
const { id: questionId } = (await createRes.json()) as { id: string };

// Record every http(s) request the real browser issues while the
// adversarial prompt renders; anything not aimed at the app origin is a
// violation of the no-remote-content invariant.
const externalRequests: string[] = [];
page.on("request", (req) => {
const url = new URL(req.url());
if (
(url.protocol === "http:" || url.protocol === "https:") &&
url.origin !== BASE_URL
) {
externalRequests.push(req.url());
}
});

await loginAsAdmin(page);
await page.goto(`${BASE_URL}/admin/questions/${questionId}/edit`);

// The adversarial math renders its inert projection on the real read
// path (the trust-disallowed command stays as visible token text).
await expect(page.locator(".katex").first()).toBeVisible();
await expect(
page.getByText("\\includegraphics", { exact: false }).first(),
).toBeVisible();

// No active/remote node may reference the adversarial payload anywhere
// on the page.
await expect(page.locator("img[src*='evil.example']")).toHaveCount(0);
await expect(page.locator("a[href*='evil.example']")).toHaveCount(0);
await expect(
page.locator("iframe, frame, object, embed, applet"),
).toHaveCount(0);

// The strongest form of the no-remote-content property: the whole page
// loaded without a single cross-origin request.
expect(externalRequests, `${externalRequests.join("\n")}`).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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('<iframe src="javascript:alert(1)"></iframe>'),
{
Expand All @@ -110,7 +121,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", ()
{
type: "text",
text: "<img src=x onerror=alert(1)>",
marks: ["bold", "italic", "underline", "inlineCode"],
marks: ["bold", "italic", "underline"],
},
],
},
Expand Down Expand Up @@ -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", () => {
Expand All @@ -201,7 +212,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => {
para("after"),
] as unknown as ContentBlock[]);
const { container } = render(
<ContentRenderer content="" document={hostile} />,
<ContentDocumentRenderer document={hostile} />,
);
expect(container.textContent).toContain(UNSUPPORTED);
expect(container.textContent).toContain("after");
Expand All @@ -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(
<ContentRenderer
content=""
<ContentDocumentRenderer
document={doc([
{
type: "paragraph",
Expand Down Expand Up @@ -239,7 +249,19 @@ describe("ContentRenderer — corrupt-data fail-safes", () => {
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 = "<script>".repeat(20000);
const { container } = render(
<ContentDocumentRenderer document={doc([para(huge)])} />,
);
assertInert(container);
});
});

describe("ContentRenderer — boundary fail-closed on oversize/hostile-structured documents", () => {
it("survives corrupt oversize input (deep tree, huge text run) through the trust boundary without crashing", () => {
// Beyond CONTENT_LIMITS.depth (16): the trust resolver rejects the tree
// before the renderer ever walks it.
let block: ContentBlock = para("leaf");
for (let i = 0; i < 60; i++) {
block = {
Expand All @@ -250,15 +272,21 @@ describe("ContentRenderer — corrupt-data fail-safes", () => {
const { container, unmount } = render(
<ContentRenderer content="" document={doc([block])} />,
);
expect(container.textContent).toContain("leaf");
assertInert(container);
expect(
container.querySelector("[data-testid='content-integrity-notice']"),
).not.toBeNull();
unmount();

// Beyond CONTENT_LIMITS.textRun (20000): same boundary rejection.
const huge = "<script>".repeat(20000);
const hugeRender = render(
<ContentRenderer content="" document={doc([para(huge)])} />,
);
assertInert(hugeRender.container);
expect(
hugeRender.container.querySelector(
"[data-testid='content-integrity-notice']",
),
).not.toBeNull();
hugeRender.unmount();
});
});
Expand Down
Loading
Loading