fix: internalize xml parsing (#606)

This commit is contained in:
Michał Pierzchała
2026-05-28 17:44:15 +02:00
committed by GitHub
parent 5083d04560
commit 46784f2e81
4 changed files with 426 additions and 89 deletions
-1
View File
@@ -204,7 +204,6 @@
"detox"
],
"dependencies": {
"fast-xml-parser": "^5.7.2",
"pngjs": "^7.0.0",
"yaml": "^2.9.0"
},
-37
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
fast-xml-parser:
specifier: ^5.7.2
version: 5.7.2
pngjs:
specifier: ^7.0.0
version: 7.0.0
@@ -260,9 +257,6 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
'@oxc-project/types@0.127.0':
resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
@@ -1206,13 +1200,6 @@ packages:
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-xml-builder@1.1.5:
resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==}
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1684,10 +1671,6 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -1930,9 +1913,6 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -2371,8 +2351,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@nodable/entities@2.1.0': {}
'@oxc-project/types@0.127.0': {}
'@oxfmt/binding-android-arm-eabi@0.42.0':
@@ -3139,17 +3117,6 @@ snapshots:
fast-uri@3.1.2: {}
fast-xml-builder@1.1.5:
dependencies:
path-expression-matcher: 1.5.0
fast-xml-parser@5.7.2:
dependencies:
'@nodable/entities': 2.1.0
fast-xml-builder: 1.1.5
path-expression-matcher: 1.5.0
strnum: 2.2.3
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -3965,8 +3932,6 @@ snapshots:
dependencies:
entities: 6.0.1
path-expression-matcher@1.5.0: {}
path-parse@1.0.7: {}
pathe@2.0.3: {}
@@ -4251,8 +4216,6 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strnum@2.2.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { parseXmlDocumentSync } from '../xml.ts';
test('parseXmlDocumentSync preserves ordered nodes with attributes and decoded text', () => {
const nodes = parseXmlDocumentSync(
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
'<dict>',
'<key>CFBundleDisplayName</key>',
'<string escaped="&quot;yes&quot;">Example &amp; App</string>',
'<empty enabled="true"/>',
'</dict>',
'</plist>',
].join(''),
);
assert.deepEqual(nodes, [
{
name: 'plist',
attributes: { version: '1.0' },
text: null,
children: [
{
name: 'dict',
attributes: {},
text: null,
children: [
{ name: 'key', attributes: {}, text: 'CFBundleDisplayName', children: [] },
{
name: 'string',
attributes: { escaped: '"yes"' },
text: 'Example & App',
children: [],
},
{ name: 'empty', attributes: { enabled: 'true' }, text: null, children: [] },
],
},
],
},
]);
});
test('parseXmlDocumentSync reads cdata text and skips declarations', () => {
const nodes = parseXmlDocumentSync(
[
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<root>',
'<!-- ignored -->',
'<value><![CDATA[ <raw>&text</raw> ]]></value>',
'</root>',
].join(''),
);
assert.equal(nodes[0]?.children[0]?.text, '<raw>&text</raw>');
});
test('parseXmlDocumentSync skips UTF-8 byte order marks', () => {
const nodes = parseXmlDocumentSync('\uFEFF<root/>');
assert.equal(nodes[0]?.name, 'root');
});
test('parseXmlDocumentSync rejects mismatched closing tags', () => {
assert.throws(() => parseXmlDocumentSync('<root><child></root>'), /Expected <\/child>/);
});
test('parseXmlDocumentSync does not expand custom doctype entities', () => {
const nodes = parseXmlDocumentSync(
'<!DOCTYPE root [<!ENTITY secret "expanded">]><root>&secret;</root>',
);
assert.equal(nodes[0]?.text, '&secret;');
});
test('parseXmlDocumentSync rejects unsafe attribute names', () => {
for (const attributeName of [
'__defineGetter__',
'__defineSetter__',
'__proto__',
'constructor',
'prototype',
]) {
assert.throws(
() => parseXmlDocumentSync(`<root ${attributeName}="polluted"/>`),
new RegExp(`Unsupported XML attribute name "${attributeName}"`),
);
}
});
test('parseXmlDocumentSync rejects excessive nesting depth', () => {
const xml = `${'<node>'.repeat(257)}${'</node>'.repeat(257)}`;
assert.throws(() => parseXmlDocumentSync(xml), /Maximum XML nesting depth/);
});
test('parseXmlDocumentSync rejects documents above the configured size limit', () => {
assert.throws(
() => parseXmlDocumentSync('<root>oversized</root>', { maxDocumentChars: 10 }),
/XML document exceeds maximum supported size of 10 characters/,
);
});
+322 -51
View File
@@ -1,5 +1,3 @@
import { XMLParser } from 'fast-xml-parser';
export type XmlNode = {
name: string;
attributes: Record<string, string>;
@@ -7,17 +5,31 @@ export type XmlNode = {
children: XmlNode[];
};
let xmlParser: XMLParser | null = null;
const MAX_XML_NESTING_DEPTH = 256;
const MAX_XML_DOCUMENT_CHARS = 128 * 1024 * 1024;
const XML_NAME_CHARS = new Set(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-',
);
const XML_WHITESPACE_CHARS = new Set([' ', '\t', '\n', '\r']);
const UNSAFE_XML_ATTRIBUTE_NAMES = new Set([
'__defineGetter__',
'__defineSetter__',
'__proto__',
'constructor',
'prototype',
]);
export function parseXmlDocumentSync(xml: string): XmlNode[] {
xmlParser ??= new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
preserveOrder: true,
trimValues: true,
parseTagValue: false,
});
return normalizeXmlNodes(xmlParser.parse(xml));
export function parseXmlDocumentSync(
xml: string,
options: { maxDocumentChars?: number } = {},
): XmlNode[] {
const maxDocumentChars = options.maxDocumentChars ?? MAX_XML_DOCUMENT_CHARS;
if (xml.length > maxDocumentChars) {
throw new Error(
`XML document exceeds maximum supported size of ${maxDocumentChars} characters.`,
);
}
return new LimitedXmlParser(xml).parse();
}
export function visitXmlPlistEntries(
@@ -38,50 +50,309 @@ export function visitXmlPlistEntries(
}
}
function normalizeXmlNodes(value: unknown): XmlNode[] {
if (!Array.isArray(value)) return [];
const nodes: XmlNode[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const record = entry as Record<string, unknown>;
for (const [name, childValue] of Object.entries(record)) {
if (name === ':@' || name === '#text') continue;
nodes.push({
name,
attributes: normalizeXmlAttributes(record[':@']),
text: readXmlText(childValue) ?? readXmlText(record['#text']),
children: normalizeXmlNodes(childValue),
});
class LimitedXmlParser {
private readonly roots: XmlNode[] = [];
private readonly stack: XmlNode[] = [];
private index = 0;
private readonly xml: string;
constructor(xml: string) {
this.xml = xml;
}
parse(): XmlNode[] {
this.skipByteOrderMark();
while (this.index < this.xml.length) {
this.readNextToken();
}
this.assertFullyClosed();
return this.roots;
}
private readNextToken(): void {
if (this.xml[this.index] !== '<') {
this.readText();
return;
}
const reader = this.resolveMarkupReader();
reader();
}
private resolveMarkupReader(): () => void {
if (this.startsWith('<!--')) return () => this.skipUntil('-->', 'Comment is not closed.');
if (this.startsWith('<?'))
return () => this.skipUntil('?>', 'Processing instruction is not closed.');
if (this.startsWith('<![CDATA[')) return () => this.readCdata();
if (this.startsWith('<!')) return () => this.skipDeclaration();
if (this.startsWith('</')) return () => this.readClosingTag();
return () => this.readOpeningTag();
}
private assertFullyClosed(): void {
if (this.stack.length > 0) {
const node = this.stack[this.stack.length - 1];
throw new Error(`Unclosed XML tag <${node?.name ?? 'unknown'}>.`);
}
}
return nodes;
}
function normalizeXmlAttributes(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const attributes: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'string') {
attributes[key] = entry;
private skipByteOrderMark(): void {
if (this.xml.charCodeAt(0) === 0xfeff) {
this.index = 1;
}
}
return attributes;
private readOpeningTag(): void {
this.index += 1;
this.skipWhitespace();
const name = this.readRequiredName(`Missing XML tag name at offset ${this.index}.`);
const { attributes, selfClosing } = this.readOpeningTagBody();
const node: XmlNode = { name, attributes, text: null, children: [] };
this.addNode(node);
if (!selfClosing) {
this.pushOpenNode(node);
}
}
private readOpeningTagBody(): { attributes: Record<string, string>; selfClosing: boolean } {
const attributes: Record<string, string> = {};
while (true) {
this.skipWhitespace();
const tagEnd = this.readOpeningTagEnd();
if (tagEnd) return { attributes, selfClosing: tagEnd === 'self-closing' };
const attribute = this.readAttribute();
attributes[attribute.name] = attribute.value;
}
}
private readOpeningTagEnd(): 'open' | 'self-closing' | null {
if (this.index >= this.xml.length) throw new Error('Opening XML tag is not closed.');
if (this.xml[this.index] === '>') {
this.index += 1;
return 'open';
}
if (this.xml[this.index] === '/' && this.xml[this.index + 1] === '>') {
this.index += 2;
return 'self-closing';
}
return null;
}
private readAttribute(): { name: string; value: string } {
const name = this.readRequiredName(`Invalid XML attribute at offset ${this.index}.`);
assertSafeXmlAttributeName(name);
this.skipWhitespace();
if (this.xml[this.index] !== '=') {
throw new Error(`Missing value for XML attribute "${name}".`);
}
this.index += 1;
this.skipWhitespace();
return { name, value: this.readAttributeValue(name) };
}
private pushOpenNode(node: XmlNode): void {
if (this.stack.length >= MAX_XML_NESTING_DEPTH) {
throw new Error(`Maximum XML nesting depth of ${MAX_XML_NESTING_DEPTH} exceeded.`);
}
this.stack.push(node);
}
private readClosingTag(): void {
this.index += 2;
this.skipWhitespace();
const name = this.readName();
this.skipWhitespace();
if (this.xml[this.index] !== '>') {
throw new Error(`Closing XML tag </${name}> is not closed.`);
}
this.index += 1;
const node = this.stack.pop();
if (!node) {
throw new Error(`Unexpected closing XML tag </${name}>.`);
}
if (node.name !== name) {
throw new Error(`Expected </${node.name}> before </${name}>.`);
}
}
private readText(): void {
const nextTagIndex = this.xml.indexOf('<', this.index);
const endIndex = nextTagIndex === -1 ? this.xml.length : nextTagIndex;
this.appendText(this.xml.slice(this.index, endIndex), true);
this.index = endIndex;
}
private readCdata(): void {
const startIndex = this.index + '<![CDATA['.length;
const endIndex = this.xml.indexOf(']]>', startIndex);
if (endIndex === -1) throw new Error('CDATA section is not closed.');
this.appendText(this.xml.slice(startIndex, endIndex), false);
this.index = endIndex + ']]>'.length;
}
private appendText(text: string, decodeEntities: boolean): void {
const trimmed = text.trim();
if (!trimmed) return;
const node = this.stack[this.stack.length - 1];
if (!node) return;
// Preserve fast-xml-parser's trimValues behavior for each text segment we keep.
node.text = `${node.text ?? ''}${decodeEntities ? decodeXmlEntities(trimmed) : trimmed}`;
}
private addNode(node: XmlNode): void {
const parent = this.stack[this.stack.length - 1];
if (parent) {
parent.children.push(node);
} else {
this.roots.push(node);
}
}
private readName(): string {
const startIndex = this.index;
while (this.index < this.xml.length && isXmlNameChar(this.xml[this.index])) {
this.index += 1;
}
return this.xml.slice(startIndex, this.index);
}
private readRequiredName(errorMessage: string): string {
const name = this.readName();
if (!name) throw new Error(errorMessage);
return name;
}
private readAttributeValue(attributeName: string): string {
const quote = this.xml[this.index];
if (quote !== '"' && quote !== "'") {
throw new Error(`XML attribute "${attributeName}" must use a quoted value.`);
}
this.index += 1;
const startIndex = this.index;
const endIndex = this.xml.indexOf(quote, startIndex);
if (endIndex === -1) {
throw new Error(`XML attribute "${attributeName}" is not closed.`);
}
this.index = endIndex + 1;
return decodeXmlEntities(this.xml.slice(startIndex, endIndex).trim());
}
private skipDeclaration(): void {
const state: DeclarationScanState = { quote: null, bracketDepth: 0 };
for (let cursor = this.index + 2; cursor < this.xml.length; cursor += 1) {
if (updateDeclarationScan(state, this.xml[cursor])) {
this.index = cursor + 1;
return;
}
}
throw new Error('XML declaration is not closed.');
}
private skipUntil(token: string, errorMessage: string): void {
// Opening markup tokens are longer than or equal to their closing tokens here, so
// this skips past the opening token without missing a valid overlapping close.
const endIndex = this.xml.indexOf(token, this.index + token.length);
if (endIndex === -1) throw new Error(errorMessage);
this.index = endIndex + token.length;
}
private skipWhitespace(): void {
while (this.index < this.xml.length && isXmlWhitespace(this.xml[this.index])) {
this.index += 1;
}
}
private startsWith(token: string): boolean {
return this.xml.startsWith(token, this.index);
}
}
function readXmlText(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (!Array.isArray(value)) return null;
const text = value
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const textValue = (entry as Record<string, unknown>)['#text'];
return typeof textValue === 'string' ? textValue.trim() : null;
})
.filter((entry): entry is string => entry !== null && entry.length > 0)
.join('')
.trim();
return text.length > 0 ? text : null;
type DeclarationScanState = {
quote: string | null;
bracketDepth: number;
};
function isXmlNameChar(char: string | undefined): boolean {
return char !== undefined && XML_NAME_CHARS.has(char);
}
function isXmlWhitespace(char: string | undefined): boolean {
return char !== undefined && XML_WHITESPACE_CHARS.has(char);
}
function updateDeclarationScan(state: DeclarationScanState, char: string | undefined): boolean {
if (char === undefined) return false;
if (updateDeclarationQuote(state, char)) return false;
updateDeclarationBracketDepth(state, char);
return isDeclarationEnd(state, char);
}
function updateDeclarationQuote(state: DeclarationScanState, char: string): boolean {
if (state.quote) {
if (char === state.quote) state.quote = null;
return true;
}
if (char === '"' || char === "'") {
state.quote = char;
return true;
}
return false;
}
function updateDeclarationBracketDepth(state: DeclarationScanState, char: string): void {
if (char === '[') {
state.bracketDepth += 1;
return;
}
if (char === ']' && state.bracketDepth > 0) {
state.bracketDepth -= 1;
}
}
function isDeclarationEnd(state: DeclarationScanState, char: string): boolean {
return char === '>' && state.bracketDepth === 0;
}
function assertSafeXmlAttributeName(name: string): void {
if (UNSAFE_XML_ATTRIBUTE_NAMES.has(name)) {
throw new Error(`Unsupported XML attribute name "${name}".`);
}
}
function decodeXmlEntities(value: string): string {
return value.replace(
/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g,
(entity, body: string) => {
switch (body) {
case 'amp':
return '&';
case 'lt':
return '<';
case 'gt':
return '>';
case 'quot':
return '"';
case 'apos':
return "'";
default:
return decodeNumericXmlEntity(entity, body);
}
},
);
}
function decodeNumericXmlEntity(entity: string, body: string): string {
const codePoint = body.startsWith('#x')
? Number.parseInt(body.slice(2), 16)
: Number(body.slice(1));
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return entity;
}
try {
return String.fromCodePoint(codePoint);
} catch {
return entity;
}
}