Skip to content

Commit c876509

Browse files
andris9claude
andcommitted
fix(api): ZMS-89: harden list header parsing for get message endpoints
- salvage valid <URI> entries from partially malformed List-Unsubscribe values instead of discarding the whole header - allow any URI scheme per RFC 2369 and non-ASCII characters in URIs - keep unparseable values in the name property, the address property only ever contains a syntactically valid URI - parse List-ID with the new module as well, addressparser returned the canonical bare <list-id> form as a display name with an empty address - decode encoded word display names inside the module and do not run URI entries through punycode decoding - guard punycode.toUnicode in tools.decodeAddresses, an invalid xn-- label in any address header crashed message detail responses with a RangeError - normalize folding whitespace in repeated header values and comments - run wildduck unit tests in grunt proto via the new unit mochaTest target - clean up the extra message created by the List-Unsubscribe API test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBhfTgWZ9frXDrmenvdrx8
1 parent cc9633a commit c876509

6 files changed

Lines changed: 294 additions & 87 deletions

File tree

Gruntfile.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ module.exports = function (grunt) {
4141
// pop3 tests (do not require server/db)
4242
src: ['test/pop3-*-test.js']
4343
},
44+
unit: {
45+
options: {
46+
reporter: 'spec'
47+
},
48+
// wildduck unit tests (do not require server/db)
49+
src: [
50+
'test/checkrangequery-test.js',
51+
'test/create-decipher-test.js',
52+
'test/filtering-tools-test.js',
53+
'test/hibp-tools-test.js',
54+
'test/list-headers-test.js',
55+
'test/tools-test.js'
56+
]
57+
},
4458
api: {
4559
options: {
4660
reporter: 'spec'
@@ -82,6 +96,6 @@ module.exports = function (grunt) {
8296
// Tasks
8397
grunt.registerTask('default', ['eslint', 'shell:server', 'wait:server', 'mochaTest', 'shell:server:kill']);
8498
grunt.registerTask('testonly', ['shell:server', 'wait:server', 'mochaTest', 'shell:server:kill']);
85-
// proto: run all protocol-level tests (IMAP unit + POP3) without requiring MongoDB/Redis
86-
grunt.registerTask('proto', ['mochaTest:imap-unit', 'mochaTest:pop3']);
99+
// proto: run all protocol-level tests (IMAP unit + POP3 + unit) without requiring MongoDB/Redis
100+
grunt.registerTask('proto', ['mochaTest:imap-unit', 'mochaTest:pop3', 'mochaTest:unit']);
87101
};

lib/api/messages.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const config = require('@zone-eu/wild-config');
44
const log = require('npmlog');
55
const libmime = require('libmime');
66
const Joi = require('joi');
7-
const addressparser = require('nodemailer/lib/addressparser');
87
const MailComposer = require('nodemailer/lib/mail-composer');
98
const { htmlToText } = require('html-to-text');
109
const ObjectId = require('mongodb').ObjectId;
@@ -40,7 +39,7 @@ const { MsgEnvelope, MsgVerificationResults } = require('../schemas/response/mes
4039
const { successRes } = require('../schemas/response/general-schemas');
4140
const { mongopagingFindWrapper, mongopagingAggregateWrapper } = require('../mongopaging-find-wrapper');
4241
const { isEncryptedContentType } = require('../message-handler');
43-
const { parseListUnsubscribe } = require('../list-headers');
42+
const { parseListId, parseListUnsubscribe } = require('../list-headers');
4443

4544
module.exports = (db, server, messageHandler, userHandler, storageHandler, settingsHandler) => {
4645
let maildrop = new Maildropper({
@@ -1587,17 +1586,11 @@ module.exports = (db, server, messageHandler, userHandler, storageHandler, setti
15871586

15881587
let list;
15891588
if (parsedHeader['list-id'] || parsedHeader['list-unsubscribe']) {
1590-
let listId = parsedHeader['list-id'];
1591-
if (listId) {
1592-
listId = addressparser(listId.toString());
1593-
tools.decodeAddresses(listId);
1594-
listId = listId.shift();
1595-
}
1589+
let listId = parseListId(parsedHeader['list-id']) || undefined;
15961590

15971591
let listUnsubscribe = parsedHeader['list-unsubscribe'];
15981592
if (listUnsubscribe) {
15991593
listUnsubscribe = parseListUnsubscribe(listUnsubscribe);
1600-
tools.decodeAddresses(listUnsubscribe);
16011594
}
16021595

16031596
list = {

lib/list-headers.js

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
'use strict';
22

3-
const URI_REGEX = /^(?:https?|ftp|mailto):(?:[a-z0-9._~:/?#[\]@!$&'()*+,;=-]|%[a-f0-9]{2})+$/i;
4-
5-
function skipFoldingWhitespace(value, start) {
6-
let pos = start;
3+
const libmime = require('libmime');
4+
5+
// RFC 2369 allows any URI scheme inside the angle brackets, so only require a generic
6+
// scheme prefix and reject whitespace, angle brackets and control characters
7+
const URI_REGEX = /^[a-z][a-z0-9+.-]*:[^\s<>]+$/i;
8+
9+
// unfold and trim a header value, repeated header values are stored without unfolding
10+
function unfoldValue(value) {
11+
return value
12+
.toString()
13+
.replace(/\s*\r?\n\s*/g, ' ')
14+
.trim();
15+
}
716

8-
while (pos < value.length) {
9-
if (value[pos] === ' ' || value[pos] === '\t') {
10-
pos++;
11-
continue;
17+
// decode encoded words in a display name, keep the value as is on failure
18+
function decodeDisplayName(name) {
19+
if (name.indexOf('=?') >= 0) {
20+
try {
21+
name = libmime.decodeWords(name);
22+
} catch (err) {
23+
// ignore, keep as is
1224
}
25+
}
26+
return name;
27+
}
1328

14-
if (value[pos] === '\r' && value[pos + 1] === '\n' && (value[pos + 2] === ' ' || value[pos + 2] === '\t')) {
15-
pos += 2;
16-
continue;
17-
}
29+
function skipWhitespace(value, start) {
30+
let pos = start;
1831

19-
break;
32+
while (pos < value.length && (value[pos] === ' ' || value[pos] === '\t')) {
33+
pos++;
2034
}
2135

2236
return pos;
@@ -46,7 +60,7 @@ function readComment(value, start) {
4660
depth--;
4761
if (!depth) {
4862
return {
49-
comment: comment.trim(),
63+
comment: comment.replace(/\s+/g, ' ').trim(),
5064
pos: pos + 1
5165
};
5266
}
@@ -61,7 +75,7 @@ function readComment(value, start) {
6175
}
6276

6377
function readComments(value, start, comments) {
64-
let pos = skipFoldingWhitespace(value, start);
78+
let pos = skipWhitespace(value, start);
6579

6680
while (value[pos] === '(') {
6781
const result = readComment(value, pos);
@@ -72,7 +86,7 @@ function readComments(value, start, comments) {
7286
if (result.comment) {
7387
comments.push(result.comment);
7488
}
75-
pos = skipFoldingWhitespace(value, result.pos);
89+
pos = skipWhitespace(value, result.pos);
7690
}
7791

7892
return pos;
@@ -107,7 +121,7 @@ function parseHeaderValue(value) {
107121

108122
entries.push({
109123
address,
110-
name: comments.join(' ')
124+
name: decodeDisplayName(comments.join(' '))
111125
});
112126

113127
if (pos === value.length) {
@@ -123,6 +137,24 @@ function parseHeaderValue(value) {
123137
return false;
124138
}
125139

140+
// last resort scan for valid <URI> segments inside an otherwise malformed value
141+
function salvageUris(value) {
142+
const entries = [];
143+
144+
const re = /<([^<>]+)>/g;
145+
let match;
146+
while ((match = re.exec(value))) {
147+
if (URI_REGEX.test(match[1])) {
148+
entries.push({
149+
address: match[1],
150+
name: ''
151+
});
152+
}
153+
}
154+
155+
return entries;
156+
}
157+
126158
function parseListUnsubscribe(value) {
127159
const entries = [];
128160

@@ -131,23 +163,63 @@ function parseListUnsubscribe(value) {
131163
continue;
132164
}
133165

134-
const source = headerValue.toString();
166+
const source = unfoldValue(headerValue);
135167
if (!source) {
136168
continue;
137169
}
138170

139-
const parsed = parseHeaderValue(source);
140-
entries.push(
141-
...(parsed || [
142-
{
143-
address: source,
144-
name: ''
145-
}
146-
])
147-
);
171+
// strict RFC 2369 parse first, then try to salvage valid <URI> entries from a malformed value
172+
const parsed = parseHeaderValue(source) || salvageUris(source);
173+
if (parsed.length) {
174+
entries.push(...parsed);
175+
} else {
176+
// No valid URI found. Keep the raw value as the display name, the address property
177+
// only ever contains a syntactically valid URI. Consumers must still restrict allowed
178+
// schemes before using the address as a link target
179+
entries.push({
180+
address: '',
181+
name: decodeDisplayName(source)
182+
});
183+
}
148184
}
149185

150186
return entries;
151187
}
152188

153-
module.exports = { parseListUnsubscribe };
189+
function parseListId(value) {
190+
if (Array.isArray(value)) {
191+
// RFC 2919 allows a single List-ID header only, ignore the rest
192+
value = value[0];
193+
}
194+
195+
if (!value) {
196+
return false;
197+
}
198+
199+
const source = unfoldValue(value);
200+
if (!source) {
201+
return false;
202+
}
203+
204+
// RFC 2919: optional display name (phrase) followed by "<" list-id ">"
205+
const match = /^([^<>]*)<([^<>\s]+)>$/.exec(source);
206+
if (!match) {
207+
// not in the expected format, keep the raw value as the display name
208+
return {
209+
address: '',
210+
name: decodeDisplayName(source)
211+
};
212+
}
213+
214+
let name = match[1].trim();
215+
if (name.length > 1 && name[0] === '"' && name[name.length - 1] === '"') {
216+
name = name.slice(1, -1).replace(/\\(.)/g, '$1').trim();
217+
}
218+
219+
return {
220+
address: match[2],
221+
name: decodeDisplayName(name)
222+
};
223+
}
224+
225+
module.exports = { parseListId, parseListUnsubscribe };

lib/tools.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,13 @@ function decodeAddresses(addresses) {
184184
}
185185
}
186186
if (/@xn--/.test(address.address)) {
187-
address.address =
188-
address.address.substr(0, address.address.lastIndexOf('@') + 1) +
189-
punycode.toUnicode(address.address.substr(address.address.lastIndexOf('@') + 1));
187+
try {
188+
address.address =
189+
address.address.substr(0, address.address.lastIndexOf('@') + 1) +
190+
punycode.toUnicode(address.address.substr(address.address.lastIndexOf('@') + 1));
191+
} catch (E) {
192+
//ignore, keep as is
193+
}
190194
}
191195
if (address.group) {
192196
decodeAddresses(address.group);

test/api/messages-test.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ describe('Messages tests', function () {
590590
queryThread = keywordMessageDetails.body.thread;
591591
});
592592

593-
it('should GET /users/:user/mailboxes/:mailbox/messages/:message preserve a malformed List-Unsubscribe value', async () => {
593+
it('should GET /users/:user/mailboxes/:mailbox/messages/:message salvage a malformed List-Unsubscribe value', async () => {
594594
const listUnsubscribe = 'Unsubscribe here <mailto:unsub@example.com>';
595595
const messageResponse = await server
596596
.post(`/users/${user}/mailboxes/${testMailbox}/messages`)
@@ -609,10 +609,13 @@ describe('Messages tests', function () {
609609

610610
expect(messageData.body.list.unsubscribe).to.deep.equal([
611611
{
612-
address: listUnsubscribe,
612+
address: 'mailto:unsub@example.com',
613613
name: ''
614614
}
615615
]);
616+
617+
// remove the extra message so that later tests see an unchanged mailbox
618+
await server.delete(`/users/${user}/mailboxes/${testMailbox}/messages/${messageResponse.body.message.id}`).expect(200);
616619
});
617620

618621
it('should POST /users/:user/mailboxes/:mailbox/messages/:message/submit expect failure / recipient pre-check counts all recipients', async () => {

0 commit comments

Comments
 (0)