From 1f5ee1d7d54da6ec72305794204d90c9a3df0d9d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:31:55 -0700 Subject: [PATCH 1/2] fix: lift CardDAV parser entity-expansion cap for large address books fast-xml-parser >=4.5.5 defaults processEntities.maxTotalExpansions to 1000. A CardDAV addressbook-query REPORT for a large address book carries far more counted entity references than that (</>/" in vCard data), so parseCards threw "Entity expansion limit exceeded" and the whole sync failed. Lift the count cap. Passing an object for processEntities also flips maxExpansionDepth from its boolean-mode default of 10 to 10000, so pin it back to 10 to keep this change scoped to the single intended knob. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/services/carddavClient.js | 6 ++++++ backend/src/services/carddavClient.test.js | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/backend/src/services/carddavClient.js b/backend/src/services/carddavClient.js index 0110b4c..cd440e9 100644 --- a/backend/src/services/carddavClient.js +++ b/backend/src/services/carddavClient.js @@ -14,6 +14,12 @@ const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, // -> response, so parsing is namespace-agnostic trimValues: false, // preserve vCard line structure inside + // fast-xml-parser >=4.5.5 caps total entity expansions at 1000/document, which a + // large address book's REPORT exceeds (vCard data carries many </>/" + // refs). Lift the count cap, but pin maxExpansionDepth to 10 (fast-xml-parser's + // boolean-mode default) so the object form doesn't silently loosen the + // nested-entity (billion-laughs) depth guard from 10 to 10000. + processEntities: { maxTotalExpansions: Infinity, maxExpansionDepth: 10 }, }); const toArray = (x) => (Array.isArray(x) ? x : x == null ? [] : [x]); diff --git a/backend/src/services/carddavClient.test.js b/backend/src/services/carddavClient.test.js index dc31daf..e52d70f 100644 --- a/backend/src/services/carddavClient.test.js +++ b/backend/src/services/carddavClient.test.js @@ -136,4 +136,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 + }); }); From 377a0643c831124d2372e6082d7d9160d1a972db Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:36:30 -0700 Subject: [PATCH 2/2] fix: raise CardDAV parser entity cap for large address books Use a generous finite expansion cap while preserving the previous depth setting without claiming fast-xml-parser enforces it. Reject top-level 507 multistatus responses before extracting cards so truncated snapshots cannot return partial results. --- backend/src/services/carddavClient.js | 15 ++++++++------- backend/src/services/carddavClient.test.js | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/src/services/carddavClient.js b/backend/src/services/carddavClient.js index cd440e9..4e9289b 100644 --- a/backend/src/services/carddavClient.js +++ b/backend/src/services/carddavClient.js @@ -14,12 +14,9 @@ const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, // -> response, so parsing is namespace-agnostic trimValues: false, // preserve vCard line structure inside - // fast-xml-parser >=4.5.5 caps total entity expansions at 1000/document, which a - // large address book's REPORT exceeds (vCard data carries many </>/" - // refs). Lift the count cap, but pin maxExpansionDepth to 10 (fast-xml-parser's - // boolean-mode default) so the object form doesn't silently loosen the - // nested-entity (billion-laughs) depth guard from 10 to 10000. - processEntities: { maxTotalExpansions: Infinity, maxExpansionDepth: 10 }, + // 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]); @@ -181,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 e52d70f..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 = `