diff --git a/backend/src/services/carddavClient.js b/backend/src/services/carddavClient.js index 0110b4c..4e9289b 100644 --- a/backend/src/services/carddavClient.js +++ b/backend/src/services/carddavClient.js @@ -14,6 +14,9 @@ const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, // -> response, so parsing is namespace-agnostic trimValues: false, // preserve vCard line structure inside + // Large CardDAV REPORTs can exceed fast-xml-parser's 1000-expansion default. + // Raise it generously while preserving the previous depth setting. + processEntities: { maxTotalExpansions: 10_000_000, maxExpansionDepth: 10 }, }); const toArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]); @@ -175,8 +178,12 @@ export async function fetchAddressBookCards({ url, username, password, allowPriv // testing. Returns [{ href, etag, vcard }]. export function parseCards(xmlText, baseUrl) { const xml = parser.parse(xmlText); + const responses = toArray(xml?.multistatus?.response); + if (responses.some(response => /\b507\b/.test(textOf(response.status)))) { + throw new Error('CardDAV server returned a truncated address book response'); + } const cards = []; - for (const response of toArray(xml?.multistatus?.response)) { + for (const response of responses) { const props = propsOf(response); const vcard = textOf(props['address-data']).trim(); if (!vcard) continue; // collection self-entry or a non-vCard resource diff --git a/backend/src/services/carddavClient.test.js b/backend/src/services/carddavClient.test.js index dc31daf..2b148de 100644 --- a/backend/src/services/carddavClient.test.js +++ b/backend/src/services/carddavClient.test.js @@ -70,6 +70,21 @@ describe('parseAddressBooks', () => { }); describe('parseCards', () => { + it('rejects a truncated address-book response', () => { + const xml = ` + /dav/c/uid1.vcf + BEGIN:VCARD +UID:uid1 +FN:Jane Doe +END:VCARDHTTP/1.1 200 OK + + /dav/c/HTTP/1.1 507 Insufficient Storage + +`; + expect(() => parseCards(xml, BASE)) + .toThrow('CardDAV server returned a truncated address book response'); + }); + it('extracts vCards + etags and skips entries without address-data', () => { const xml = ` @@ -136,4 +151,26 @@ END:VCARD const cards = parseCards(xml, BASE); expect(cards[0].vcard).toContain('Tom & Jerry'); }); + + it('parses a large book whose response exceeds 1000 XML entity references', () => { + // Regression: fast-xml-parser >=4.5.5 defaults maxTotalExpansions to 1000. + // A real address book's REPORT response carries far more counted entity + // references than that (< / > / " in vCard data), so the whole + // sync was rejected with "Entity expansion limit exceeded". + const N = 1500; + const responses = Array.from({ length: N }, (_, i) => ` + /dav/c/uid${i}.vcf + "e${i}" + BEGIN:VCARD +VERSION:3.0 +UID:uid${i} +ORG:Tom <${i}> Ltd +END:VCARD + HTTP/1.1 200 OK + `).join(''); + const xml = `${responses}`; + const cards = parseCards(xml, BASE); + expect(cards).toHaveLength(N); + expect(cards[0].vcard).toContain('Tom <0> Ltd'); // entities still decoded + }); });