Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/src/services/carddavClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true, // <d:response> -> response, so parsing is namespace-agnostic
trimValues: false, // preserve vCard line structure inside <address-data>
// 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]);
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions backend/src/services/carddavClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ describe('parseAddressBooks', () => {
});

describe('parseCards', () => {
it('rejects a truncated address-book response', () => {
const xml = `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response><d:href>/dav/c/uid1.vcf</d:href>
<d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:uid1
FN:Jane Doe
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>
<d:response><d:href>/dav/c/</d:href><d:status>HTTP/1.1 507 Insufficient Storage</d:status>
<d:error><d:number-of-matches-within-limits/></d:error></d:response>
</d:multistatus>`;
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 = `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:response>
Expand Down Expand Up @@ -136,4 +151,26 @@ END:VCARD</card:address-data>
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 (&lt; / &gt; / &quot; in vCard data), so the whole
// sync was rejected with "Entity expansion limit exceeded".
const N = 1500;
const responses = Array.from({ length: N }, (_, i) => `
<d:response><d:href>/dav/c/uid${i}.vcf</d:href>
<d:propstat><d:prop><d:getetag>"e${i}"</d:getetag>
<card:address-data>BEGIN:VCARD
VERSION:3.0
UID:uid${i}
ORG:Tom &lt;${i}&gt; Ltd
END:VCARD</card:address-data>
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>
</d:response>`).join('');
const xml = `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">${responses}</d:multistatus>`;
const cards = parseCards(xml, BASE);
expect(cards).toHaveLength(N);
expect(cards[0].vcard).toContain('Tom <0> Ltd'); // entities still decoded
});
});