chore: bump deps (#15124)

This commit is contained in:
DarkSky
2026-06-18 12:55:18 +08:00
committed by GitHub
parent 13d9fe506e
commit d500e472f0
30 changed files with 1239 additions and 504 deletions
+1 -1
View File
@@ -43,7 +43,7 @@
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
+2 -1
View File
@@ -23,7 +23,7 @@
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"bytes": "^3.1.2",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
@@ -46,6 +46,7 @@
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"rxjs": "^7.8.2",
"tldts": "^7.0.19",
"ts-pattern": "^5.1.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
@@ -0,0 +1,185 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { sanitizeSvg } from '../../utils/svg.js';
type HappyDOMWindow = Window & {
happyDOM: {
setURL: (url: string) => void;
};
};
function setLocation(url: string) {
(window as unknown as HappyDOMWindow).happyDOM.setURL(url);
}
function svgDataUrl(svg: string) {
const bytes = new TextEncoder().encode(svg);
let binary = '';
bytes.forEach(byte => {
binary += String.fromCharCode(byte);
});
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function decodeSvgDataUrl(dataUrl: string) {
const base64 = dataUrl.split(',')[1];
return new TextDecoder().decode(
Uint8Array.from(atob(base64), char => char.charCodeAt(0))
);
}
describe('sanitizeSvg', () => {
test('wraps DOMPurify svg fragments back into an svg root', () => {
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
});
test('accepts svg documents with xml and doctype prefixes', () => {
const sanitized = sanitizeSvg(`<?xml version="1.0" standalone="no"?>
<!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" width="100" height="100">
<rect width="100" height="100"></rect>
</svg>`);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
expect(sanitized).not.toContain('<!DOCTYPE');
});
test('rejects non-svg roots', () => {
expect(sanitizeSvg('<div><svg></svg></div>')).toBe('');
});
test('keeps internal glyph references and safe image data urls', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<defs><path id="glyph-a" d="M0 0h10v10z"></path></defs>
<use href="#glyph-a"></use>
<use xlink:href="#glyph-a"></use>
<a xlink:href="https://typst.app/docs/tutorial"><path d="M0 0h10v10z"></path></a>
<image href="data:image/png;base64,AAAA" width="10" height="10"></image>
</svg>
`);
expect(sanitized).toContain('href="#glyph-a"');
expect(sanitized).toContain('xlink:href="#glyph-a"');
expect(sanitized).toContain('xlink:href="https://typst.app/docs/tutorial"');
expect(sanitized).toContain('data:image/png;base64,AAAA');
});
test('removes external glyph references and unsafe css', () => {
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<style>@import "https://example.com/style.css"; .a { fill: #000; }</style>
<use href="https://example.com/glyph.svg#x"></use>
<use xlink:href="https://example.com/glyph.svg#x"></use>
<a xlink:href="javascript:alert(1)"><path d="M0 0h10v10z"></path></a>
<image href="https://example.com/image.png" width="10" height="10"></image>
<path style="fill: url(https://example.com/pattern.svg#x)" d="M0 0h10v10z"></path>
</svg>
`);
expect(sanitized).not.toContain('https://example.com');
expect(sanitized).not.toContain('javascript:');
expect(sanitized).not.toContain('@import');
expect(sanitized).not.toContain('url(');
});
test('removes links sharing the current registrable domain', () => {
setLocation('https://sub.example.co.uk/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://sub.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://other.example.co.uk/docs"><path d="M0 0h10v10z"></path></a>
<a xlink:href="https://example.com/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://sub.example.co.uk/docs');
expect(sanitized).not.toContain('https://other.example.co.uk/docs');
expect(sanitized).toContain('https://example.com/docs');
});
test('keeps private suffix sibling domains separate', () => {
setLocation('https://foo.github.io/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="https://foo.github.io/docs"><path d="M0 0h10v10z"></path></a>
<a href="https://bar.github.io/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('https://foo.github.io/docs');
expect(sanitized).toContain('https://bar.github.io/docs');
});
test('handles local hostnames by exact hostname', () => {
setLocation('http://localhost:3000/workspace');
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<a xlink:href="http://localhost:8080/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://share.localhost/docs"><path d="M0 0h10v10z"></path></a>
<a href="http://127.0.0.1/docs"><path d="M0 0h10v10z"></path></a>
</svg>
`);
expect(sanitized).not.toContain('http://localhost:8080/docs');
expect(sanitized).toContain('http://share.localhost/docs');
expect(sanitized).toContain('http://127.0.0.1/docs');
});
test('recursively sanitizes svg images', () => {
const nestedSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="glyph-a" d="M0 0h10v10z"></path></defs><use href="#glyph-a"></use><use href="https://example.com/glyph.svg#x"></use></svg>'
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${nestedSvg}" width="10" height="10"></image>
</svg>
`);
const sanitizedImageHref = sanitized.match(/href="([^"]+)"/)?.[1];
expect(sanitizedImageHref).toMatch(/^data:image\/svg\+xml;base64,/);
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('<svg');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).toContain('#glyph-a');
expect(decodeSvgDataUrl(sanitizedImageHref ?? '')).not.toContain(
'https://example.com'
);
});
test('removes svg images nested deeper than two levels', () => {
const thirdLevelSvg = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"></rect></svg>'
);
const secondLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${thirdLevelSvg}"></image></svg>`
);
const firstLevelSvg = svgDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg"><image href="${secondLevelSvg}"></image></svg>`
);
const sanitized = sanitizeSvg(`
<svg xmlns="http://www.w3.org/2000/svg">
<image href="${firstLevelSvg}"></image>
</svg>
`);
const firstLevelHref = sanitized.match(/href="([^"]+)"/)?.[1];
const firstLevelSanitizedSvg = decodeSvgDataUrl(firstLevelHref ?? '');
const secondLevelHref = firstLevelSanitizedSvg.match(/href="([^"]+)"/)?.[1];
const secondLevelSanitizedSvg = decodeSvgDataUrl(secondLevelHref ?? '');
expect(firstLevelSanitizedSvg).toContain('<image');
expect(secondLevelSanitizedSvg).not.toContain('<image');
});
});
@@ -20,7 +20,6 @@ import {
type ToSliceSnapshotPayload,
type Transformer,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import pdfMake from 'pdfmake/build/pdfmake';
import type {
Content,
@@ -29,6 +28,7 @@ import type {
} from 'pdfmake/interfaces';
import { getNumberPrefix } from '../../utils';
import { sanitizeSvg } from '../../utils/svg.js';
import { resolveCssVariable } from './css-utils.js';
import { extractTextWithInline } from './delta-converter.js';
import {
@@ -746,9 +746,8 @@ export class PdfAdapter extends BaseAdapter<PdfAdapterFile> {
const trimmedText = text.trim();
if (trimmedText.startsWith('<svg')) {
const svgContent = DOMPurify.sanitize(trimmedText, {
USE_PROFILES: { svg: true },
});
const svgContent = sanitizeSvg(trimmedText);
if (!svgContent) throw new Error('Invalid SVG image asset');
const svgDimensions = extractSvgDimensions(svgContent);
const dimensions = calculateImageDimensions(
blockWidth,
@@ -23,6 +23,7 @@ export * from './reordering';
export * from './safe-html';
export * from './signal';
export * from './string';
export * from './svg';
export * from './title';
export * from './url';
export * from './virtual-padding';
+248
View File
@@ -0,0 +1,248 @@
import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';
import { parse } from 'tldts';
type SanitizeSvgOptions = {
svg?: Config;
foreignObjectHtml?: Config;
};
const MAX_NESTED_SVG_IMAGE_DEPTH = 2;
const DEFAULT_SVG_SANITIZE_CONFIG: Config = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
USE_PROFILES: { html: true },
};
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
const SVG_DATA_URL_PATTERN =
/^data:image\/svg\+xml(?:;charset=[^;,]+)?(?<base64>;base64)?,(?<data>[\s\S]*)$/i;
const SAFE_IMAGE_DATA_URL_PATTERN =
/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[a-z0-9+/=]+$/i;
const UNSAFE_CSS_PATTERN =
/(?:url\s*\(|@import|javascript\s*:|expression\s*\(|-moz-binding)/i;
const SVG_ROOT_PATTERN =
/^\s*(?:<\?xml[\s\S]*?\?>\s*)?(?:<!doctype[\s\S]*?>\s*)?<svg[\s>]/i;
const SVG_ROOT_ATTRIBUTES = [
'class',
'data-height',
'data-width',
'height',
'preserveAspectRatio',
'viewBox',
'width',
'xmlns',
'xmlns:h5',
'xmlns:xlink',
];
function getAttribute(element: Element, attribute: string) {
return (
element.getAttribute(attribute) ??
element.getAttribute(attribute.toLowerCase())
);
}
function getSvgSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_SVG_SANITIZE_CONFIG,
...options?.svg,
};
}
function getForeignObjectHtmlSanitizeConfig(options?: SanitizeSvgOptions) {
return {
...DEFAULT_FOREIGN_OBJECT_HTML_SANITIZE_CONFIG,
...options?.foreignObjectHtml,
};
}
function getOriginalSvgRoot(svg: string, parser: DOMParser) {
const root = parser.parseFromString(svg, 'image/svg+xml').documentElement;
if (root?.tagName.toLowerCase() === 'svg') {
return root;
}
if (!SVG_ROOT_PATTERN.test(svg)) {
return null;
}
return parser.parseFromString(svg, 'text/html').querySelector('svg');
}
function ensureSvgRoot(
originalRoot: Element | null,
sanitized: string,
parser: DOMParser
) {
if (SVG_ROOT_PATTERN.test(sanitized)) {
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
return sanitizedRoot?.tagName.toLowerCase() === 'svg'
? sanitizedRoot
: null;
}
const svgDoc = parser.parseFromString('<svg></svg>', 'image/svg+xml');
const svgRoot = svgDoc.documentElement;
SVG_ROOT_ATTRIBUTES.forEach(attribute => {
const value = originalRoot ? getAttribute(originalRoot, attribute) : null;
if (value) {
svgRoot.setAttribute(attribute, value);
}
});
svgRoot.innerHTML = sanitized;
return svgRoot;
}
function sanitizeForeignObjects(
root: ParentNode,
options?: SanitizeSvgOptions
) {
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
getForeignObjectHtmlSanitizeConfig(options)
);
});
}
function getSiteDomain(hostname: string) {
return (
parse(hostname, { allowPrivateDomains: true }).domain ??
hostname.toLowerCase()
);
}
function isSameSiteDomain(url: URL) {
if (typeof location === 'undefined') return false;
return getSiteDomain(url.hostname) === getSiteDomain(location.hostname);
}
function isSafeLinkUrl(value: string) {
try {
const url = new URL(value);
return SAFE_LINK_PROTOCOLS.has(url.protocol) && !isSameSiteDomain(url);
} catch {
return false;
}
}
function isSafeHref(element: Element, value: string) {
if (value.startsWith('#')) return true;
const tagName = element.tagName.toLowerCase();
if (tagName === 'use') return false;
if (tagName === 'image') return SAFE_IMAGE_DATA_URL_PATTERN.test(value);
if (tagName === 'a') return isSafeLinkUrl(value);
return false;
}
function decodeSvgDataUrl(value: string) {
const groups = value.match(SVG_DATA_URL_PATTERN)?.groups;
if (!groups) return null;
try {
if (groups.base64) {
return new TextDecoder().decode(
Uint8Array.from(atob(groups.data), char => char.charCodeAt(0))
);
}
return decodeURIComponent(groups.data);
} catch {
return null;
}
}
function encodeSvgDataUrl(svg: string) {
const binary = Array.from(new TextEncoder().encode(svg), byte =>
String.fromCharCode(byte)
).join('');
return `data:image/svg+xml;base64,${btoa(binary)}`;
}
function getHrefAttributes(element: Element) {
return Array.from(element.attributes).filter(
attribute => attribute.name === 'href' || attribute.name === 'xlink:href'
);
}
function tightenSvgTree(
root: ParentNode,
options: SanitizeSvgOptions | undefined,
depth: number
) {
root.querySelectorAll('*').forEach(element => {
getHrefAttributes(element).forEach(attribute => {
const href = attribute.value.trim();
const nestedSvg =
element.tagName.toLowerCase() === 'image'
? decodeSvgDataUrl(href)
: null;
if (nestedSvg !== null) {
if (depth < MAX_NESTED_SVG_IMAGE_DEPTH) {
const sanitized = sanitizeSvgWithDepth(nestedSvg, options, depth + 1);
if (sanitized) {
element.setAttribute(attribute.name, encodeSvgDataUrl(sanitized));
return;
}
}
element.remove();
} else if (!isSafeHref(element, href)) {
element.removeAttribute(attribute.name);
}
});
const style = element.getAttribute('style');
if (style && UNSAFE_CSS_PATTERN.test(style)) {
element.removeAttribute('style');
}
if (
element.tagName.toLowerCase() === 'style' &&
UNSAFE_CSS_PATTERN.test(element.textContent ?? '')
) {
element.remove();
}
});
}
export function sanitizeSvg(svg: string, options?: SanitizeSvgOptions): string {
return sanitizeSvgWithDepth(svg, options, 0);
}
function sanitizeSvgWithDepth(
svg: string,
options: SanitizeSvgOptions | undefined,
depth: number
): string {
const svgConfig = getSvgSanitizeConfig(options);
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string' || !SVG_ROOT_PATTERN.test(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const originalRoot = getOriginalSvgRoot(svg, parser);
if (!originalRoot) return '';
const sanitized = DOMPurify.sanitize(svg, svgConfig);
if (typeof sanitized !== 'string') return '';
const sanitizedRoot = ensureSvgRoot(originalRoot, sanitized, parser);
if (!sanitizedRoot) return '';
sanitizeForeignObjects(sanitizedRoot, options);
tightenSvgTree(sanitizedRoot, options, depth);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
@@ -24,7 +24,7 @@
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"js-yaml": "^4.1.1",
"js-yaml": "^4.2.0",
"jszip": "^3.10.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
+1 -1
View File
@@ -19,7 +19,7 @@
"@preact/signals-core": "^1.8.0",
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"fractional-indexing": "^3.2.0",
"lib0": "^0.2.114",
"lit": "^3.2.0",
+1 -1
View File
@@ -37,7 +37,7 @@
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.1.8",
"playwright": "=1.58.2",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.1.8"
},
+1 -1
View File
@@ -34,7 +34,7 @@
"@types/micromatch": "^4.0.9",
"@vanilla-extract/vite-plugin": "^5.0.0",
"magic-string": "^0.30.21",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-wasm": "^3.5.0",
"vite-plugin-web-components-hmr": "^0.1.3"
+1 -1
View File
@@ -90,7 +90,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.55.0",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vitest": "^4.1.8"
},
"packageManager": "yarn@4.13.0",
+2 -2
View File
@@ -92,7 +92,7 @@
"nanoid": "^5.1.6",
"nest-winston": "^1.9.7",
"nestjs-cls": "^6.0.0",
"nodemailer": "^8.0.4",
"nodemailer": "^8.0.11",
"on-headers": "^1.1.0",
"piscina": "^5.1.4",
"prisma": "^6.6.0",
@@ -102,7 +102,7 @@
"rxjs": "^7.8.2",
"semver": "^7.7.4",
"ses": "^1.15.0",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tldts": "^7.0.19",
"winston": "^3.17.0",
+1 -1
View File
@@ -51,7 +51,7 @@
"react-dom": "^19.2.1",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.12.0",
"react-router-dom": "^7.18.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"swr": "^2.3.7",
+1 -1
View File
@@ -31,7 +31,7 @@
"next-themes": "^0.4.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@capacitor/cli": "^7.6.5",
@@ -23,7 +23,7 @@
"next-themes": "^0.4.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.30.4",
"uuid": "^14.0.0"
},
"devDependencies": {
+1 -1
View File
@@ -59,7 +59,7 @@
"electron-log": "^5.4.3",
"electron-squirrel-startup": "1.0.1",
"electron-window-state": "^5.0.3",
"esbuild": "^0.25.0",
"esbuild": "^0.25.12",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"lodash-es": "^4.17.23",
+1 -1
View File
@@ -36,7 +36,7 @@
"next-themes": "^0.4.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@affine-tools/cli": "workspace:*",
+1 -1
View File
@@ -18,7 +18,7 @@
"@toeverything/infra": "workspace:*",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@types/react": "^19.0.1",
+1 -1
View File
@@ -18,7 +18,7 @@
"@toeverything/infra": "workspace:*",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@types/react": "^19.0.1",
+2 -2
View File
@@ -62,7 +62,7 @@
"react": "^19.2.1",
"react-dom": "19.2.1",
"react-paginate": "^8.3.0",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.30.4",
"react-transition-state": "^2.2.0",
"sonner": "^2.0.7",
"swr": "^2.3.7",
@@ -81,7 +81,7 @@
"storybook": "^10.2.14",
"typescript": "^5.9.3",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vite": "^7.3.5",
"vitest": "^4.1.8"
},
"version": "0.26.3"
+4 -2
View File
@@ -56,7 +56,7 @@
"cmdk": "^1.0.4",
"core-js": "^3.39.0",
"dayjs": "^1.11.13",
"dompurify": "^3.3.0",
"dompurify": "^3.4.11",
"eventemitter2": "^6.4.9",
"file-type": "^21.0.0",
"filesize": "^10.1.6",
@@ -82,7 +82,7 @@
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-error-boundary": "^6.0.0",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.30.4",
"react-transition-state": "^2.2.0",
"react-virtuoso": "^4.12.3",
"recharts": "^2.15.4",
@@ -98,6 +98,7 @@
},
"devDependencies": {
"@blocksuite/affine-ext-loader": "workspace:*",
"@playwright/test": "=1.58.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/bytes": "^3.1.5",
@@ -107,6 +108,7 @@
"@vanilla-extract/css": "^1.17.0",
"fake-indexeddb": "^6.0.0",
"happy-dom": "^20.3.0",
"vite": "^7.3.5",
"vitest": "^4.1.8"
}
}
@@ -41,12 +41,12 @@ describe('preview render bridge', () => {
});
});
test('uses worker renderers and only sanitizes mermaid output', async () => {
test('uses worker renderers and sanitizes preview svg output', async () => {
mermaidRender.mockResolvedValue({
svg: '<svg><script>alert(1)</script><text>mermaid</text></svg>',
});
typstRender.mockResolvedValue({
svg: '<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>',
svg: '<svg><script>window.__xss__=1</script><text>typst</text></svg>',
});
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
@@ -57,9 +57,9 @@ describe('preview render bridge', () => {
expect(mermaid.svg).toContain('<svg');
expect(mermaid.svg).toContain('mermaid');
expect(mermaid.svg).not.toContain('<script');
expect(typst.svg).toBe(
'<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>'
);
expect(typst.svg).toContain('<svg');
expect(typst.svg).toContain('typst');
expect(typst.svg).not.toContain('<script');
});
test('sanitizeSvg keeps svg text nodes', () => {
@@ -101,6 +101,37 @@ describe('preview render bridge', () => {
expect(sanitized).not.toContain('<script');
});
test('sanitizeSvg wraps sanitized svg fragments back into root svg', () => {
if (typeof DOMParser === 'undefined') {
return;
}
domPurifySanitize.mockImplementation((value: unknown) => {
if (typeof value !== 'string') {
return '';
}
return '<rect width="100" height="100"></rect>';
});
const sanitized = sanitizeSvg(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
);
expect(sanitized).toContain('<svg');
expect(sanitized).toContain('width="100"');
expect(sanitized).toContain('<rect');
});
test('throws when sanitized typst svg is empty', async () => {
typstRender.mockResolvedValue({
svg: '<div><text>invalid</text></div>',
});
await expect(renderTypstSvg({ code: '= Title' })).rejects.toThrow(
'Preview renderer returned invalid SVG.'
);
});
test('throws when sanitized svg is empty', async () => {
mermaidRender.mockResolvedValue({
svg: '<div><text>invalid</text></div>',
@@ -10,56 +10,10 @@ import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
import type { Config } from 'dompurify';
import DOMPurify from 'dompurify';
/** Mermaid SVG uses `<use>`, `<style>`, and sometimes `<foreignObject>` for labels. */
const MERMAID_SVG_SANITIZE_CONFIG: Config = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
USE_PROFILES: { html: true },
};
function sanitizeForeignObjects(root: ParentNode) {
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
FOREIGN_OBJECT_HTML_SANITIZE_CONFIG
);
});
}
import { sanitizeSvg as sanitizeSvgDocument } from '@blocksuite/affine-shared/utils';
export function sanitizeSvg(svg: string): string {
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG);
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const parsed = parser.parseFromString(svg, 'image/svg+xml');
const root = parsed.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
const sanitized = DOMPurify.sanitize(root, MERMAID_SVG_SANITIZE_CONFIG);
if (typeof sanitized !== 'string') return '';
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
return '';
sanitizeForeignObjects(sanitizedRoot);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
return sanitizeSvgDocument(svg);
}
export async function renderMermaidSvg(
@@ -79,5 +33,9 @@ export async function renderTypstSvg(
): Promise<TypstRenderResult> {
const rendered = await renderTypstSvgBackend(request);
return { svg: rendered.svg };
const sanitizedSvg = sanitizeSvgDocument(rendered.svg);
if (!sanitizedSvg) {
throw new Error('Preview renderer returned invalid SVG.');
}
return { svg: sanitizedSvg };
}
@@ -90,4 +90,14 @@ describe('renderClassicMermaidSvg', () => {
})
);
});
test('keeps mermaid classic renderer in strict security mode', async () => {
render.mockResolvedValue({ svg: '<svg>ok</svg>' });
await renderClassicMermaidSvg({ code: 'flowchart TD;A-->B' });
expect(initialize).toHaveBeenCalledWith(
expect.objectContaining({ securityLevel: 'strict' })
);
});
});
@@ -1,39 +1,179 @@
/**
* @vitest-environment happy-dom
* @vitest-environment node
*/
import { describe, expect, test } from 'vitest';
import { createReadStream } from 'node:fs';
import { createServer, type Server } from 'node:http';
import { extname, join, resolve } from 'node:path';
import { sanitizeSvg } from './bridge';
import { renderClassicMermaidSvg } from './classic-mermaid';
import { type Browser, chromium } from '@playwright/test';
import type DOMPurifyDefault from 'dompurify';
import type { Mermaid } from 'mermaid';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
const canRunDomIntegration =
typeof document !== 'undefined' &&
typeof DOMParser !== 'undefined' &&
typeof XMLSerializer !== 'undefined';
const workspaceRoot = resolve('.');
describe.skipIf(!canRunDomIntegration)('mermaid preview integration', () => {
test('flowchart labels survive classic render and svg sanitization', async () => {
const { svg: raw } = await renderClassicMermaidSvg({
code: 'flowchart TD; A-->B',
options: { theme: 'default' },
});
let server: Server;
let serverUrl: string;
let browser: Browser;
expect(raw).toMatch(/<svg[\s>]/i);
function contentType(path: string) {
switch (extname(path)) {
case '.js':
case '.mjs':
return 'text/javascript';
case '.json':
return 'application/json';
default:
return 'application/octet-stream';
}
}
const sanitized = sanitizeSvg(raw);
expect(sanitized).toMatch(/<svg[\s>]/i);
// happy-dom cannot lay out Mermaid (CSSStyleSheet); skip empty output.
if (!/<(?:rect|path|circle|polygon)\b/i.test(sanitized)) {
beforeAll(async () => {
server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://localhost');
if (url.pathname === '/') {
response.setHeader('Content-Type', 'text/html');
response.end('<!doctype html><html><body></body></html>');
return;
}
const hasLabelText =
/>\s*A\s*</i.test(sanitized) ||
/>\s*B\s*</i.test(sanitized) ||
/foreignObject[\s\S]*>\s*A\s*</i.test(sanitized) ||
/<tspan[^>]*>\s*A\s*</i.test(sanitized);
const filePath = resolve(
join(workspaceRoot, decodeURIComponent(url.pathname))
);
expect(hasLabelText).toBe(true);
if (!filePath.startsWith(workspaceRoot)) {
response.writeHead(403);
response.end();
return;
}
response.setHeader('Content-Type', contentType(filePath));
response.writeHead(200);
createReadStream(filePath)
.on('error', () => {
if (!response.headersSent) {
response.writeHead(404);
}
response.end();
})
.pipe(response);
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to start mermaid preview test server.');
}
serverUrl = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe('mermaid preview integration', () => {
test('flowchart labels survive strict classic render and svg sanitization in browser', async () => {
const page = await browser.newPage();
try {
await page.goto(serverUrl);
const result = await page.evaluate(async () => {
const browserImport = new Function('url', 'return import(url)') as <T>(
url: string
) => Promise<T>;
const mermaid = (
await browserImport<{ default: Mermaid }>(
'/node_modules/mermaid/dist/mermaid.esm.mjs'
)
).default;
const DOMPurify = (
await browserImport<{ default: typeof DOMPurifyDefault }>(
'/node_modules/dompurify/dist/purify.es.mjs'
)
).default;
const sanitizeSvg = (svg: string) => {
const sanitizeConfig = {
USE_PROFILES: { svg: true },
ADD_TAGS: ['use'],
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
};
const foreignObjectConfig = {
USE_PROFILES: { html: true },
};
const parser = new DOMParser();
const parsed = parser.parseFromString(svg, 'image/svg+xml');
const root = parsed.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
const sanitized = DOMPurify.sanitize(root, sanitizeConfig);
if (typeof sanitized !== 'string') return '';
const sanitizedDoc = parser.parseFromString(
sanitized,
'image/svg+xml'
);
const sanitizedRoot = sanitizedDoc.documentElement;
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg') {
return '';
}
sanitizedRoot
.querySelectorAll('foreignObject, foreignobject')
.forEach(element => {
element.innerHTML = DOMPurify.sanitize(
element.innerHTML,
foreignObjectConfig
);
});
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
};
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict',
htmlLabels: false,
fontFamily: 'IBM Plex Mono',
flowchart: { useMaxWidth: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
pie: { useMaxWidth: true },
journey: { useMaxWidth: true },
gitGraph: { useMaxWidth: true },
});
const { svg: raw } = await mermaid.render(
`mermaid-diagram-${Date.now()}`,
'flowchart TD; A-->B'
);
const sanitized = sanitizeSvg(raw);
return {
raw,
sanitized,
hasLabelText:
/>\s*A\s*</i.test(sanitized) ||
/>\s*B\s*</i.test(sanitized) ||
/foreignObject[\s\S]*>\s*A\s*</i.test(sanitized) ||
/<tspan[^>]*>\s*A\s*</i.test(sanitized),
};
});
expect(result.raw).toMatch(/<svg[\s>]/i);
expect(result.sanitized).toMatch(/<svg[\s>]/i);
expect(result.sanitized).toMatch(/<(?:rect|path|circle|polygon)\b/i);
expect(result.hasLabelText).toBe(true);
} finally {
await page.close();
}
}, 30_000);
});
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*/
import { resolve } from 'node:path';
import { type Browser, chromium } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
const typstDemo = String.raw`#set page(paper: "a5")
#set heading(numbering: "1.")
#show link: set text(fill: blue, weight: 700)
#show link: underline
= The Typst Playground
Welcome to the Typst Playground! This is a sandbox where you can experiment with Typst.
= Basics <basics>
Typst is a _markup_ language. You use it to express not just the content, but also the structure and formatting of your document.
- *Strongly emphasize* some text
- Refer to @basics
- Typeset math: $a, b in { 1/2, sqrt(4 a b) }$
= Next steps
To learn more about Typst, check out https://typst.app/docs/tutorial.`;
let server: ViteDevServer;
let serverUrl: string;
let browser: Browser;
const typstRuntimeUrl = `/@fs/${resolve(
'packages/frontend/core/src/modules/typst/renderer/runtime.ts'
)}`;
const svgUtilsUrl = `/@fs/${resolve(
'blocksuite/affine/shared/src/utils/svg.ts'
)}`;
beforeAll(async () => {
server = await createServer({
logLevel: 'silent',
server: {
host: '127.0.0.1',
port: 0,
},
});
await server.listen();
const address = server.httpServer?.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to start typst preview test server.');
}
serverUrl = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
describe('typst preview integration', () => {
test('sanitization preserves rendered glyphs, styles, and link colors', async () => {
const page = await browser.newPage();
try {
await page.goto(serverUrl);
const result = await page.evaluate(
async ({ code, svgUtilsUrl, typstRuntimeUrl }) => {
const browserImport = new Function('url', 'return import(url)') as <
T,
>(
url: string
) => Promise<T>;
const [{ renderTypstSvgWithOptions }, { sanitizeSvg }] =
await Promise.all([
browserImport<{
renderTypstSvgWithOptions: (
code: string,
options: { fontUrls: string[] }
) => Promise<{ svg: string }>;
}>(typstRuntimeUrl),
browserImport<{
sanitizeSvg: (svg: string) => string;
}>(svgUtilsUrl),
]);
const { svg: raw } = await renderTypstSvgWithOptions(code, {
fontUrls: [],
});
const sanitized = sanitizeSvg(raw);
const parse = (svg: string) =>
new DOMParser().parseFromString(svg, 'image/svg+xml');
const rawDoc = parse(raw);
const sanitizedDoc = parse(sanitized);
return {
linkHrefs: Array.from(sanitizedDoc.querySelectorAll('a')).map(
element =>
element.getAttribute('href') ??
element.getAttribute('xlink:href') ??
''
),
rawUseCount: rawDoc.querySelectorAll('use').length,
sanitized,
sanitizedRoot: sanitizedDoc.documentElement.tagName,
sanitizedUseCount: sanitizedDoc.querySelectorAll('use').length,
hasParserError: !!sanitizedDoc.querySelector('parsererror'),
};
},
{ code: typstDemo, svgUtilsUrl, typstRuntimeUrl }
);
expect(result.sanitizedRoot).toBe('svg');
expect(result.hasParserError).toBe(false);
expect(result.rawUseCount).toBeGreaterThan(0);
expect(result.sanitizedUseCount).toBe(result.rawUseCount);
expect(result.linkHrefs).toContain('https://typst.app/docs/tutorial');
expect(result.sanitized).toContain('typst-text');
expect(result.sanitized).toContain('#0074d9');
expect(result.sanitized).not.toContain('<script');
} finally {
await page.close();
}
}, 30_000);
});
@@ -25,7 +25,7 @@
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-markdown": "^10.1.0",
"socket.io": "^4.7.4",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"swr": "^2.3.7",
"tailwindcss": "^4.1.17"
+1 -1
View File
@@ -17,6 +17,6 @@
},
"peerDependencies": {
"react": "^19.2.1",
"react-router-dom": "^7.12.0"
"react-router-dom": "^7.18.0"
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
"@affine/debug": "workspace:*",
"@sentry/react": "^10.53.1",
"nanoid": "^5.1.6",
"react-router-dom": "^6.30.3"
"react-router-dom": "^6.30.4"
},
"devDependencies": {
"@types/react": "^19.0.1",
+427 -397
View File
File diff suppressed because it is too large Load Diff