import { XMLParser, XMLValidator } from "fast-xml-parser"; export interface ParsedXmlElement { name: string; attributes: Record; children: ParsedXmlNode[]; } export type ParsedXmlNode = ParsedXmlElement | string; export interface SafeXmlDocument { root: ParsedXmlElement; } const ATTRIBUTES_KEY = ":@"; const TEXT_KEY = "#text"; // DAV request XML is shallow: PROPFIND/PROPPATCH/LOCK/REPORT/MKCALENDAR bodies // are normally under a dozen levels deep. These fixed structural caps are // intentionally well above interoperable requests while bounding parser work // for many-small-element bodies that can sit below the byte cap. const MAX_XML_ELEMENT_DEPTH = 128; const MAX_XML_ELEMENT_COUNT = 50_000; function assertSafeXml(xml: string, maxBytes: number): void { if (new TextEncoder().encode(xml).byteLength > maxBytes) { throw new Error("XML body exceeds configured maximum size"); } if (/; const elementName = Object.keys(record).find((key) => key !== ATTRIBUTES_KEY); if (!elementName) return null; if (elementName.startsWith("?")) return null; if (elementName === TEXT_KEY) { return String(record[elementName] ?? ""); } state.elementCount += 1; if (state.elementCount > MAX_XML_ELEMENT_COUNT) { throw new Error("XML document exceeds configured element count limit"); } if (depth > MAX_XML_ELEMENT_DEPTH) { throw new Error("XML document exceeds configured element depth limit"); } const rawAttributes = (record[ATTRIBUTES_KEY] ?? {}) as Record; const attributes = Object.fromEntries(Object.entries(rawAttributes).map(([key, value]) => [key, String(value)])); const rawChildren = Array.isArray(record[elementName]) ? (record[elementName] as unknown[]) : []; return { name: elementName, attributes, children: rawChildren .map((child) => elementFromPreserved(child, state, depth + 1)) .filter((node): node is ParsedXmlNode => node !== null), }; } export function parseSafeXml(xml: string, maxBytes = 1_048_576): SafeXmlDocument { assertSafeXml(xml, maxBytes); const validation = XMLValidator.validate(xml, { allowBooleanAttributes: false, }); if (validation !== true) { throw new Error(validation.err.msg); } const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, attributeNamePrefix: "", parseTagValue: false, parseAttributeValue: false, processEntities: false, trimValues: false, }); const parsed = parser.parse(xml) as unknown[]; const state = { elementCount: 0 }; const nodes = parsed .map((entry) => elementFromPreserved(entry, state, 1)) .filter((node): node is ParsedXmlNode => node !== null); const root = nodes.find((node): node is ParsedXmlElement => typeof node !== "string"); if (!root) throw new Error("XML document has no root element"); return { root }; }