Files
homarr/apps/nextjs/src/app/api/user-medias/svg-purify.ts
T
Thomas Camlong 345a4be4b0 fix: CI build errors + simplify cache handler and data cache
- Remove `export const dynamic` from 4 routes (incompatible with cacheComponents)
- Static import for redis-handler (Turbopack can't resolve relative dynamic imports)
- Simplify redis-handler: Buffer.concat, NonNullable, single connect promise
- Inline single-use helpers in integration-data-cache
- Non-greedy SVG regex to prevent ReDoS
- Inline getDevelopmentServiceAliases in next.config
- Remove restating comments
2026-08-08 00:19:27 +02:00

100 lines
2.8 KiB
TypeScript

import createDOMPurify from "dompurify";
import { parseHTML } from "linkedom";
const svgSanitizeOptions = {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ["style"],
};
const LINKEDOM_NODE_PROPS = [
"ownerDocument",
"parentNode",
"nextSibling",
"previousSibling",
"nodeName",
"nodeType",
"childNodes",
] as const;
// ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify
// resolves them via prototype getters (lookupGetter). Without this shim, sanitize
// is a no-op and XSS payloads pass through unchanged.
const patchLinkedomNodePrototype = (Node: typeof window.Node) => {
for (const prop of LINKEDOM_NODE_PROPS) {
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, prop);
if (descriptor?.get) continue;
if (descriptor?.value !== undefined) {
delete Node.prototype[prop as keyof Node];
}
Object.defineProperty(Node.prototype, prop, {
configurable: true,
enumerable: true,
get() {
return Object.getOwnPropertyDescriptor(this, prop)?.value ?? null;
},
set(value: unknown) {
Object.defineProperty(this, prop, {
value,
writable: true,
configurable: true,
enumerable: true,
});
},
});
}
};
const createDocument = () => parseHTML("<!doctype html><html><head></head><body></body></html>");
const { window, document, Node } = createDocument();
patchLinkedomNodePrototype(Node);
if (!document.implementation?.createHTMLDocument) {
Object.defineProperty(document, "implementation", {
value: {
createHTMLDocument() {
return createDocument().document;
},
createDocument() {
return createDocument().document;
},
},
});
}
window.NodeFilter ??= {
FILTER_ACCEPT: 1,
FILTER_REJECT: 2,
FILTER_SKIP: 3,
SHOW_ALL: 0xffffffff,
SHOW_ELEMENT: 1,
SHOW_ATTRIBUTE: 2,
SHOW_TEXT: 4,
SHOW_CDATA_SECTION: 8,
SHOW_PROCESSING_INSTRUCTION: 64,
SHOW_COMMENT: 128,
SHOW_DOCUMENT: 0x100,
SHOW_DOCUMENT_TYPE: 0x200,
SHOW_DOCUMENT_FRAGMENT: 0x400,
SHOW_NOTATION: 0x800,
} as typeof window.NodeFilter;
const purify = createDOMPurify(window as unknown as Parameters<typeof createDOMPurify>[0]);
export const sanitizeSvg = (svgText: string) => {
// linkedom's DOMParser omits html/body for fragment-only SVG; DOMPurify needs a body root.
const wrapped = `<html><body>${svgText}</body></html>`;
const sanitized = purify.sanitize(wrapped, svgSanitizeOptions);
const svgMatch = sanitized.match(/<svg[^>]*\/>|<svg[\s\S]*?<\/svg>/i);
return svgMatch ? svgMatch[0] : "";
};
export function looksLikeSvg(content: Uint8Array): boolean {
const head = new TextDecoder().decode(content.subarray(0, 512));
return /<svg[\s>]/i.test(head);
}
export { svgSanitizeOptions, purify };