Skip to content
File

Blob: src/worker/xml/parser.ts

typescript97 lines
1import { XMLParser, XMLValidator } from "fast-xml-parser";
2 
3export interface ParsedXmlElement {
4 name: string;
5 attributes: Record<string, string>;
6 children: ParsedXmlNode[];
7}
8 
9export type ParsedXmlNode = ParsedXmlElement | string;
10 
11export interface SafeXmlDocument {
12 root: ParsedXmlElement;
13}
14 
15const ATTRIBUTES_KEY = ":@";
16const TEXT_KEY = "#text";
17// DAV request XML is shallow: PROPFIND/PROPPATCH/LOCK/REPORT/MKCALENDAR bodies
18// are normally under a dozen levels deep. These fixed structural caps are
19// intentionally well above interoperable requests while bounding parser work
20// for many-small-element bodies that can sit below the byte cap.
21const MAX_XML_ELEMENT_DEPTH = 128;
22const MAX_XML_ELEMENT_COUNT = 50_000;
23 
24function assertSafeXml(xml: string, maxBytes: number): void {
25 if (new TextEncoder().encode(xml).byteLength > maxBytes) {
26 throw new Error("XML body exceeds configured maximum size");
27 }
28 if (/<!DOCTYPE/i.test(xml) || /<!ENTITY/i.test(xml)) {
29 throw new Error("Unsafe XML entity declarations are not accepted");
30 }
31 if (/\sxmlns:[A-Za-z_][\w.-]*=(["'])\1/u.test(xml)) {
32 throw new Error("Prefixed XML namespace declarations must not be empty");
33 }
34}
35 
36function elementFromPreserved(entry: unknown, state: { elementCount: number }, depth: number): ParsedXmlNode | null {
37 if (typeof entry !== "object" || entry === null) return null;
38 
39 const record = entry as Record<string, unknown>;
40 const elementName = Object.keys(record).find((key) => key !== ATTRIBUTES_KEY);
41 if (!elementName) return null;
42 if (elementName.startsWith("?")) return null;
43 
44 if (elementName === TEXT_KEY) {
45 return String(record[elementName] ?? "");
46 }
47 state.elementCount += 1;
48 if (state.elementCount > MAX_XML_ELEMENT_COUNT) {
49 throw new Error("XML document exceeds configured element count limit");
50 }
51 if (depth > MAX_XML_ELEMENT_DEPTH) {
52 throw new Error("XML document exceeds configured element depth limit");
53 }
54 
55 const rawAttributes = (record[ATTRIBUTES_KEY] ?? {}) as Record<string, unknown>;
56 const attributes = Object.fromEntries(Object.entries(rawAttributes).map(([key, value]) => [key, String(value)]));
57 const rawChildren = Array.isArray(record[elementName]) ? (record[elementName] as unknown[]) : [];
58 
59 return {
60 name: elementName,
61 attributes,
62 children: rawChildren
63 .map((child) => elementFromPreserved(child, state, depth + 1))
64 .filter((node): node is ParsedXmlNode => node !== null),
65 };
66}
67 
68export function parseSafeXml(xml: string, maxBytes = 1_048_576): SafeXmlDocument {
69 assertSafeXml(xml, maxBytes);
70 
71 const validation = XMLValidator.validate(xml, {
72 allowBooleanAttributes: false,
73 });
74 if (validation !== true) {
75 throw new Error(validation.err.msg);
76 }
77 
78 const parser = new XMLParser({
79 preserveOrder: true,
80 ignoreAttributes: false,
81 attributeNamePrefix: "",
82 parseTagValue: false,
83 parseAttributeValue: false,
84 processEntities: false,
85 trimValues: false,
86 });
87 
88 const parsed = parser.parse(xml) as unknown[];
89 const state = { elementCount: 0 };
90 const nodes = parsed
91 .map((entry) => elementFromPreserved(entry, state, 1))
92 .filter((node): node is ParsedXmlNode => node !== null);
93 const root = nodes.find((node): node is ParsedXmlElement => typeof node !== "string");
94 if (!root) throw new Error("XML document has no root element");
95 return { root };
96}