mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-05 19:40:30 +08:00
chore: bump deps (#15124)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user