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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium
- name: Verify PDF gestures and mobile highlight persistence
run: pnpm --filter @mdbase-reader/app test:pdf-touch
- name: Verify imported SVG rendering in the HTML sandbox
run: pnpm --filter @mdbase-reader/app test:html-svg
- name: Verify private feedback with sample data and intercepted delivery
env:
VITE_MDBASE_FEEDBACK_URL: http://127.0.0.1:8891/v1/feedback
Expand Down
1 change: 1 addition & 0 deletions apps/reader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"preview": "vite preview --host 127.0.0.1",
"test": "pnpm manifest && pnpm manifest:verify && node --test scripts/*.test.mjs && vitest run src capture --maxWorkers=2 --testTimeout=15000",
"test:browser": "node scripts/audit-reader.mjs",
"test:html-svg": "node scripts/audit-html-svg.mjs",
"test:feedback": "node scripts/feedback-browser-test.mjs",
"test:pdf-touch": "node scripts/run-pdf-touch-audits.mjs",
"typecheck": "tsc -p tsconfig.json",
Expand Down
76 changes: 76 additions & 0 deletions apps/reader/scripts/audit-html-svg.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";

import { chromium, expect } from "@playwright/test";
import { createServer } from "vite";

// The production reproduction, rendered in Reader's script-disabled HTML sandbox.
const root = resolve(import.meta.dirname, "..");
const fixture = await readFile(
resolve(root, "../../packages/renderer-html/src/fixtures/imported-svg.html"),
"utf8",
);
const server = await createServer({
root,
server: { host: "127.0.0.1", port: 0, strictPort: true },
});
let browser;
try {
await server.listen();
const address = server.httpServer.address();
const origin = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
const externalRequests = [];
await page.route("**/*", (route) => {
if (new URL(route.request().url()).origin === origin) return route.continue();
externalRequests.push(route.request().url());
return route.abort();
});
// No app, collection, daemon or production network access is needed.
await page.route(`${origin}/svg-test`, (route) =>
route.fulfill({
contentType: "text/html",
body: "<!doctype html><title>SVG regression</title>",
}),
);
await page.goto(`${origin}/svg-test`);
const moduleUrl = `/@fs${resolve(root, "../../packages/renderer-html/src/html-document.ts")}`;
for (const passes of [0, 1, 2]) {
await page.evaluate(
async ({ fixture, moduleUrl, passes }) => {
const { prepareHtmlDocument } = await import(moduleUrl);
let source = fixture;
for (let pass = 0; pass < passes; pass++) source = prepareHtmlDocument(source);
const frame = globalThis.document.createElement("iframe");
frame.sandbox = "allow-same-origin";
frame.title = "HTML reading document";
globalThis.document.body.replaceChildren(frame);
await new Promise((resolveLoad) => {
frame.onload = resolveLoad;
frame.srcdoc = source;
});
},
{ fixture, moduleUrl, passes },
);
const frame = page.frameLocator("iframe");
const image = frame.getByAltText("Test image");
await expect
.poll(() => image.evaluate((image) => [image.naturalWidth, image.naturalHeight]))
.toEqual([30, 30]);
await expect(frame.locator("code")).toHaveText("const example = 42;");
if (passes) {
await expect(frame.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveAttribute(
"content",
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'",
);
}
}
expect(externalRequests).toEqual([]);
console.log(
"Imported SVG: 30×30 in Chrome and after both Reader sanitization passes; CSP unchanged.",
);
} finally {
await browser?.close();
await server.close();
}
4 changes: 3 additions & 1 deletion packages/renderer-html/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
"@lingui/react": "6.9.0",
"@mdbase-reader/core": "workspace:*",
"@mdbase-reader/reading-surface": "workspace:*",
"dompurify": "^3.4.17",
"react": "^19.2.8"
},
"devDependencies": {
"@types/react": "^19.2.14",
"happy-dom": "^20.11.2"
"happy-dom": "^20.11.2",
"jsdom": "28.1.0"
}
}
26 changes: 26 additions & 0 deletions packages/renderer-html/src/fixtures/imported-svg.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<title>[test] agent-20261029T030716Z-R2-1a2 reader</title>
<meta name="author" content="[test] agent-20261029T030716Z-R2-1a2" />
</head>
<body>
<h1>[test] agent-20261029T030716Z-R2-1a2 reader</h1>
<p>Unicode: café 漢字 🐨</p>
<table>
<tr>
<th>Item</th>
<th>Value</th>
</tr>
<tr>
<td>Alpha</td>
<td>42</td>
</tr>
</table>
<pre><code>const example = 42;</code></pre>
<img
alt="Test image"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Crect width='30' height='30' fill='blue'/%3E%3C/svg%3E"
/>
</body>
</html>
15 changes: 14 additions & 1 deletion packages/renderer-html/src/html-document.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
// @vitest-environment happy-dom
// @vitest-environment jsdom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";

import { describe, expect, it } from "vitest";

import { extractArchivedHtmlText, prepareHtmlDocument } from "./html-document.js";

describe("prepareHtmlDocument", () => {
it("retains the embedded SVG image from the production import fixture", () => {
const source = readFileSync(resolve(import.meta.dirname, "fixtures/imported-svg.html"), "utf8");
const rendered = new DOMParser().parseFromString(prepareHtmlDocument(source), "text/html");
const image = rendered.querySelector("img");
expect(image?.getAttribute("src")).toMatch(/^data:image\/svg\+xml,/u);
expect(image?.getAttribute("alt")).toBe("Test image");
expect(rendered.querySelector("td")?.textContent).toBe("Alpha");
expect(rendered.querySelector("code")?.textContent).toBe("const example = 42;");
expect(rendered.body.textContent).toContain("Unicode: café 漢字 🐨");
});
it("shares background index text with the rendered document, without executing markup", () => {
const source =
"<head><title>Not body text</title><style>secret style</style></head><body><form><p>CAFÉ 世界文学</p><button>definition</button></form><script>secret script</script><textarea>secret input</textarea></body>";
Expand Down
10 changes: 9 additions & 1 deletion packages/renderer-html/src/html-document.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { highlightPalette } from "@mdbase-reader/core";

import { annotationHighlightCss } from "./html-css-highlights.js";
import { sanitizedSvgImage } from "./svg-image.js";

const blockedElements = [
"script",
Expand Down Expand Up @@ -60,12 +61,19 @@ export function prepareHtmlDocument(source: string): string {
function sanitizeAttributes(element: Element): void {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLocaleLowerCase();
if (name.startsWith("on") || name === "srcdoc" || name === "style") {
if (name.startsWith("on") || name === "srcdoc" || name === "srcset" || name === "style") {
element.removeAttribute(attribute.name);
}
}
for (const name of urlAttributes) {
const value = element.getAttribute(name)?.trim();
if (name === "src" && element.localName === "img" && value) {
const svg = sanitizedSvgImage(value);
if (svg) {
element.setAttribute(name, svg);
continue;
}
}
if (value && !safeResource(name, value)) {
element.removeAttribute(name);
}
Expand Down
140 changes: 140 additions & 0 deletions packages/renderer-html/src/svg-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";

import { prepareHtmlDocument } from "./html-document.js";
import { sanitizedSvgImage } from "./svg-image.js";

const drawing = `<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30"><title>café 漢字 🐨</title><rect width="30" height="30" fill="blue"/></svg>`;

function decodedImage(url: string | null): Document {
expect(url).toMatch(/^data:image\/svg\+xml,/u);
return new DOMParser().parseFromString(decodeURIComponent(url!.split(",")[1]!), "image/svg+xml");
}

describe("sanitizedSvgImage", () => {
it.each(["percent", "base64"])("preserves geometry and Unicode in %s images", (encoding) => {
const url =
encoding === "percent"
? `data:image/svg+xml,${encodeURIComponent(drawing)}`
: `data:image/svg+xml;charset=utf-8;base64,${btoa(String.fromCharCode(...new TextEncoder().encode(drawing)))}`;
const svg = decodedImage(sanitizedSvgImage(url));
expect(svg.documentElement.getAttribute("width")).toBe("30");
expect(svg.querySelector("rect")?.getAttribute("fill")).toBe("blue");
expect(svg.querySelector("title")?.textContent).toBe("café 漢字 🐨");
});

it("strips scripts, event handlers, foreign content, animation, CSS and external references", () => {
const source = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" onload="alert(1)">
<script>alert(1)</script><foreignObject><iframe src="https://bad.example"/></foreignObject>
<style>@import url(https://bad.example/style)</style>
<a href="javascript:alert(1)"><text>Label</text></a>
<image href="https://bad.example/pixel"/><use xlink:href="https://bad.example/file#icon"/>
<rect width="30" height="30" fill="blue" onclick="alert(1)" style="fill:url(https://bad.example/paint)">
<set attributeName="href" to="https://bad.example"/><animate attributeName="onload" values="alert(1)"/>
</rect><path fill="url(https://bad.example/paint)" stroke="u\\72l(https://bad.example/stroke)"/>
<defs><linearGradient id="gradient"><stop stop-color="blue"/></linearGradient></defs>
<circle fill="url(#gradient)"/>
</svg>`;
const svg = decodedImage(sanitizedSvgImage(`data:image/svg+xml,${encodeURIComponent(source)}`));
const clean = new XMLSerializer().serializeToString(svg);
expect(clean).not.toMatch(
/script|onload|onclick|foreignObject|iframe|<style|style=|href=|<set|<animate|bad\.example/iu,
);
expect(svg.querySelector("rect")?.getAttribute("fill")).toBe("blue");
expect(svg.querySelector("circle")?.getAttribute("fill")).toBe("url(#gradient)");
});

it.each([
"data:image/svg+xml,%zz",
"data:image/svg+xml;base64,!!!!",
"data:image/svg+xml,%3Csvg",
"data:image/svg+xml,%3Chtml/%3E",
"https://bad.example/image.svg",
"data:text/html,<svg/>",
])("rejects malformed or non-SVG input: %s", (url) => {
expect(sanitizedSvgImage(url)).toBeNull();
const html = new DOMParser().parseFromString(
prepareHtmlDocument(`<img src='${url}'>`),
"text/html",
);
expect(html.querySelector("img")?.hasAttribute("src")).toBe(false);
});

it("preserves non-breaking spaces, local reuse and quoted paints without the source doctype", () => {
const source = `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs><rect id="shape" width="30" height="30"/><linearGradient id="g"><stop stop-color="blue"/></linearGradient></defs>
<use href="#shape" fill="url('#g')"/><use xlink:href="#shape" fill='url("#g")'/>
<text aria-label="a\u00a0b">a\u00a0b</text>
</svg>`;
const svg = decodedImage(
sanitizedSvgImage(`data:image/svg+xml;utf8,${encodeURIComponent(source)}`),
);
expect(svg.doctype).toBeNull();
expect(svg.querySelector("text")?.textContent).toBe("a\u00a0b");
expect(svg.querySelector("text")?.getAttribute("aria-label")).toBe("a\u00a0b");
expect(svg.querySelectorAll("use")).toHaveLength(2);
expect(svg.querySelectorAll("use")[0]?.getAttribute("href")).toBe("#shape");
expect(svg.querySelectorAll("use")[1]?.getAttribute("xlink:href")).toBe("#shape");
expect(svg.querySelectorAll("use")[0]?.getAttribute("fill")).toBe("url('#g')");
expect(svg.querySelectorAll("use")[1]?.getAttribute("fill")).toBe('url("#g")');
});

it.each([';charset="utf-8"', ";charset=US-ASCII", ";utf8", ""])(
"accepts %s parameters and literal percent signs as Chrome does",
(parameters) => {
const source = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100%"/></svg>';
const svg = decodedImage(sanitizedSvgImage(`data:image/svg+xml${parameters},${source}`));
expect(svg.querySelector("rect")?.getAttribute("width")).toBe("100%");
},
);

it("keeps embedded raster images and local paint fallbacks, while rejecting external fallbacks", () => {
const raster = "data:image/png;base64,iVBORw0KGgo=";
const source = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image xlink:href="${raster}"/><image href="https://bad.example/image.png"/>
<rect fill="url(#missing) blue"/><circle fill="url(#missing) url(https://bad.example/paint)"/>
</svg>`;
const svg = decodedImage(sanitizedSvgImage(`data:image/svg+xml,${encodeURIComponent(source)}`));
expect(svg.querySelectorAll("image")[0]?.getAttribute("xlink:href")).toBe(raster);
expect(svg.querySelectorAll("image")[1]?.hasAttribute("href")).toBe(false);
expect(svg.querySelector("rect")?.getAttribute("fill")).toBe("url(#missing) blue");
expect(svg.querySelector("circle")?.hasAttribute("fill")).toBe(false);
});

it("removes responsive image candidates so they cannot bypass SVG sanitization", () => {
const url = `data:image/svg+xml,${encodeURIComponent(drawing)}`;
const html = new DOMParser().parseFromString(
prepareHtmlDocument(
`<picture><source srcset="${url}"><img src="${url}" srcset="${url}"></picture>`,
),
"text/html",
);
expect(html.querySelector("[srcset]")).toBeNull();
decodedImage(html.querySelector("img")!.getAttribute("src"));
});

it("is stable across capture and rendering passes and keeps the restrictive document CSP", () => {
const once = prepareHtmlDocument(
`<img src="data:image/svg+xml,${encodeURIComponent(drawing)}">`,
);
const twice = prepareHtmlDocument(once);
const parsed = new DOMParser().parseFromString(twice, "text/html");
expect(parsed.querySelector("img")?.getAttribute("src")).toBe(
new DOMParser().parseFromString(once, "text/html").querySelector("img")?.getAttribute("src"),
);
expect(
parsed.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute("content"),
).toBe(
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'",
);
const unsafeLocation = new DOMParser().parseFromString(
prepareHtmlDocument(
`<iframe src="data:image/svg+xml,${encodeURIComponent(drawing)}"></iframe><video poster="data:image/svg+xml,${encodeURIComponent(drawing)}"></video>`,
),
"text/html",
);
expect(unsafeLocation.querySelector("iframe")).toBeNull();
expect(unsafeLocation.querySelector("video")?.hasAttribute("poster")).toBe(false);
});
});
61 changes: 61 additions & 0 deletions packages/renderer-html/src/svg-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import purify from "dompurify";

/** Keep embedded images static and self-contained before putting them back in a data URL. */
export function sanitizedSvgImage(value: string): string | null {
const match = /^data:image\/svg\+xml((?:;[^,]*)?),([\s\S]*)$/iu.exec(value);
if (!match || !purify.isSupported) {
return null;
}
try {
const payload = decodeURIComponent((match[2] ?? "").replace(/%(?![\da-f]{2})/giu, "%25"));
const source = /(?:^|;)base64(?:;|$)/iu.test(match[1] ?? "")
? new TextDecoder("utf-8", { fatal: true }).decode(
Uint8Array.from(atob(payload), (character) => character.charCodeAt(0)),
)
: payload;
const parsed = new DOMParser().parseFromString(source, "image/svg+xml");
if (
parsed.querySelector("parsererror") ||
parsed.documentElement.localName !== "svg" ||
parsed.documentElement.namespaceURI !== "http://www.w3.org/2000/svg"
) {
return null;
}
// Only the root is kept; source doctypes and processing instructions never reach the image.
const clean = purify.sanitize(parsed.documentElement, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
FORBID_TAGS: ["style", "animate", "animatemotion", "animatetransform", "set", "discard"],
FORBID_ATTR: ["style"],
RETURN_DOM_FRAGMENT: true,
});
const svg = clean.firstElementChild;
if (svg?.localName !== "svg") {
return null;
}
sanitizeSvgReferences(svg);
return `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
} catch {
return null;
}
}

function sanitizeSvgReferences(svg: Element): void {
for (const element of [svg, ...svg.querySelectorAll("*")]) {
for (const attribute of [...element.attributes]) {
// Presentation attributes can also load resources; permit only local fragment paints.
const references = attribute.value.replace(/url\(\s*(['"]?)#[\w.-]+\1\s*\)/giu, "");
const embeddedRaster =
element.localName === "image" &&
/^data:image\/(?:png|gif|jpeg|webp|avif);base64,/iu.test(attribute.value);
if (
(attribute.localName === "href" &&
!attribute.value.trim().startsWith("#") &&
!embeddedRaster) ||
/\\|url\s*\(/iu.test(references)
) {
element.removeAttribute(attribute.name);
}
}
}
}
Loading