Compare commits

...

12 Commits

Author SHA1 Message Date
Dawid Dziurla d35571df19 node_modules: update (#314)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-09-07 07:05:39 +02:00
dependabot[bot] 40eba5cee7 build(deps): bump nodemailer from 9.1.0 to 9.1.1 (#313)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.1.0 to 9.1.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.1.0...v9.1.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 07:04:31 +02:00
dependabot[bot] 773f2c25fb build(deps): bump nodemailer from 9.0.6 to 9.1.0 (#312)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.6 to 9.1.0.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.6...v9.1.0)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 08:36:44 +02:00
dependabot[bot] 0fd6142bc2 build(deps): bump nodemailer from 9.0.5 to 9.0.6 (#311)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.5 to 9.0.6.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 08:13:56 +02:00
dependabot[bot] 0bbdab0966 build(deps): bump nodemailer from 9.0.4 to 9.0.5 (#309)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.4 to 9.0.5.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 08:07:41 +02:00
dependabot[bot] 62b29dec2d build(deps): bump nodemailer from 9.0.3 to 9.0.4 (#308)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.3 to 9.0.4.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.4)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 07:54:46 +02:00
Dawid Dziurla 8de3c31270 node_modules: update (#307)
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
2026-08-07 08:08:22 +02:00
dependabot[bot] 5cdad7c44d build(deps): bump undici from 6.27.0 to 6.28.0 (#306)
Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 15:05:20 +02:00
dependabot[bot] 2e600f3776 build(deps): bump brace-expansion from 5.0.7 to 5.0.9 (#305)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.7 to 5.0.9.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.7...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 12:50:45 +02:00
dependabot[bot] 12335b969a build(deps): bump brace-expansion from 5.0.6 to 5.0.7 (#304)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.6 to 5.0.7.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.7)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 17:50:36 +02:00
dependabot[bot] c50dc4cc84 build(deps): bump nodemailer from 9.0.1 to 9.0.3 (#303)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.1 to 9.0.3.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.0.3)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 07:07:33 +02:00
dependabot[bot] 994f270325 build(deps): bump undici from 6.24.1 to 6.27.0 (#301)
Bumps [undici](https://github.com/nodejs/undici) from 6.24.1 to 6.27.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.24.1...v6.27.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.27.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 23:23:47 +02:00
54 changed files with 1966 additions and 520 deletions
+10 -10
View File
@@ -58,15 +58,15 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -94,9 +94,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.0.0", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.0.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==", "integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -128,9 +128,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.24.1", "version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
+11
View File
@@ -64,6 +64,17 @@ const expansions = expand('{1..100}'.repeat(5), {
// expansions.length will be 100, not 100^5 // expansions.length will be 100, not 100^5
``` ```
The `options` object can also provide a `maxLength` value to cap the
total number of characters across all expansions. This is limited to
`4_000_000` by default, to prevent memory exhaustion from inputs whose
result count stays under `max` while each result grows very long.
```js
const expansions = expand('{a,b}'.repeat(1500), {
maxLength: 10_000,
})
```
Valid expansions are: Valid expansions are:
```js ```js
+2
View File
@@ -1,6 +1,8 @@
export declare const EXPANSION_MAX = 100000; export declare const EXPANSION_MAX = 100000;
export declare const EXPANSION_MAX_LENGTH = 4000000;
export type BraceExpansionOptions = { export type BraceExpansionOptions = {
max?: number; max?: number;
maxLength?: number;
}; };
export declare function expand(str: string, options?: BraceExpansionOptions): string[]; export declare function expand(str: string, options?: BraceExpansionOptions): string[];
//# sourceMappingURL=index.d.ts.map //# sourceMappingURL=index.d.ts.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAwDpC,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAwD7C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"}
+145 -57
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.EXPANSION_MAX = void 0; exports.EXPANSION_MAX_LENGTH = exports.EXPANSION_MAX = void 0;
exports.expand = expand; exports.expand = expand;
const balanced_match_1 = require("balanced-match"); const balanced_match_1 = require("balanced-match");
const escSlash = '\0SLASH' + Math.random() + '\0'; const escSlash = '\0SLASH' + Math.random() + '\0';
@@ -19,6 +19,17 @@ const closePattern = /\\}/g;
const commaPattern = /\\,/g; const commaPattern = /\\,/g;
const periodPattern = /\\\./g; const periodPattern = /\\\./g;
exports.EXPANSION_MAX = 100_000; exports.EXPANSION_MAX = 100_000;
// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An
// input like `'{a,b}'.repeat(1500)` stays under that count - its output is
// truncated to 100k results - while making every result ~1500 characters
// long. The result set, and the intermediate arrays built while combining
// brace sets, then grow large enough to exhaust memory and crash the process
// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of
// characters the accumulator may hold at any point, so memory stays flat no
// matter how many brace groups are chained. The limit sits well above any
// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M
// characters) so legitimate input is unaffected.
exports.EXPANSION_MAX_LENGTH = 4_000_000;
function numeric(str) { function numeric(str) {
return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0);
} }
@@ -68,7 +79,7 @@ function expand(str, options = {}) {
if (!str) { if (!str) {
return []; return [];
} }
const { max = exports.EXPANSION_MAX } = options; const { max = exports.EXPANSION_MAX, maxLength = exports.EXPANSION_MAX_LENGTH } = options;
// I don't know why Bash 4.3 does this, but it does. // I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved // Anything starting with {} will have the first two bytes preserved
// but *only* at the top level, so {},a}b will not expand to anything, // but *only* at the top level, so {},a}b will not expand to anything,
@@ -78,7 +89,7 @@ function expand(str, options = {}) {
if (str.slice(0, 2) === '{}') { if (str.slice(0, 2) === '{}') {
str = '\\{\\}' + str.slice(2); str = '\\{\\}' + str.slice(2);
} }
return expand_(escapeBraces(str), max, true).map(unescapeBraces); return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
} }
function embrace(str) { function embrace(str) {
return '{' + str + '}'; return '{' + str + '}';
@@ -92,55 +103,43 @@ function lte(i, y) {
function gte(i, y) { function gte(i, y) {
return i >= y; return i >= y;
} }
function expand_(str, max, isTop) { // Build `{ acc[a] + pre + values[v] }` for every combination, capping the
/** @type {string[]} */ // number of results at `max` and the total number of characters at `maxLength`.
const expansions = []; // This is the one place output grows, so bounding it here keeps the single
const m = (0, balanced_match_1.balanced)('{', '}', str); // accumulator - and therefore memory - flat regardless of how many brace groups
if (!m) // are combined (CVE-2026-14257).
return [str]; function combine(acc, pre, values, max, maxLength, dropEmpties) {
// no need to expand pre, since it is guaranteed to be free of brace-sets const out = [];
const pre = m.pre; let length = 0;
const post = m.post.length ? expand_(m.post, max, false) : ['']; for (let a = 0; a < acc.length; a++) {
if (/\$$/.test(m.pre)) { for (let v = 0; v < values.length; v++) {
for (let k = 0; k < post.length && k < max; k++) { if (out.length >= max)
const expansion = pre + '{' + m.body + '}' + post[k]; return out;
expansions.push(expansion); const expansion = acc[a] + pre + values[v];
// Bash drops empty results at the top level. Skip them before they count
// against `max`, so `max` bounds the number of *kept* results.
if (dropEmpties && !expansion)
continue;
if (length + expansion.length > maxLength)
return out;
out.push(expansion);
length += expansion.length;
} }
} }
else { return out;
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); }
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)
const isSequence = isNumericSequence || isAlphaSequence; // sequence body.
const isOptions = m.body.indexOf(',') >= 0; function expandSequence(body, isAlphaSequence, max, maxLength) {
if (!isSequence && !isOptions) { const n = body.split(/\.\./);
// {a},b} const N = [];
if (m.post.match(/,(?!,).*\}/)) { // A sequence body always splits into two or three parts, but the compiler
str = m.pre + '{' + m.body + escClose + m.post; // can't know that.
return expand_(str, max, true);
}
return [str];
}
let n;
if (isSequence) {
n = m.body.split(/\.\./);
}
else {
n = parseCommaParts(m.body);
if (n.length === 1 && n[0] !== undefined) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand_(n[0], max, false).map(embrace);
//XXX is this necessary? Can't seem to hit it in tests.
/* c8 ignore start */ /* c8 ignore start */
if (n.length === 1) { if (n[0] === undefined || n[1] === undefined) {
return post.map(p => m.pre + n[0] + p); return N;
} }
/* c8 ignore stop */ /* c8 ignore stop */
}
}
// at this point, n is the parts, and we know it's not a comma set
// with a single entry.
let N;
if (isSequence && n[0] !== undefined && n[1] !== undefined) {
const x = numeric(n[0]); const x = numeric(n[0]);
const y = numeric(n[1]); const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length); const width = Math.max(n[0].length, n[1].length);
@@ -154,7 +153,7 @@ function expand_(str, max, isTop) {
test = gte; test = gte;
} }
const pad = n.some(isPadded); const pad = n.some(isPadded);
N = []; let length = 0;
for (let i = x; test(i, y) && N.length < max; i += incr) { for (let i = x; test(i, y) && N.length < max; i += incr) {
let c; let c;
if (isAlphaSequence) { if (isAlphaSequence) {
@@ -178,24 +177,113 @@ function expand_(str, max, isTop) {
} }
} }
} }
if (length + c.length > maxLength)
break;
N.push(c); N.push(c);
length += c.length;
} }
return N;
}
function expand_(str, max, maxLength, isTop) {
// Consume the string's top-level brace groups left to right, threading a
// running set of combined prefixes (`acc`). Expanding the tail iteratively -
// rather than recursing on `m.post` once per group - keeps the native stack
// depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no
// longer overflow the stack, and leaves a single accumulator whose size
// `maxLength` bounds directly (CVE-2026-14257).
let acc = [''];
// Bash drops empty results, but only when the *first* top-level group is a
// comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
// is on the final strings, so it is applied to whichever `combine` produces
// them (the one with no brace set left in the tail).
let dropEmpties = false;
let firstGroup = true;
for (;;) {
const m = (0, balanced_match_1.balanced)('{', '}', str);
// No brace set left: the rest of the string is literal.
if (!m) {
return combine(acc, str, [''], max, maxLength, dropEmpties);
}
// no need to expand pre, since it is guaranteed to be free of brace-sets
const pre = m.pre;
if (/\$$/.test(pre)) {
acc = combine(acc, pre + '{' + m.body + '}', [''], max, maxLength, dropEmpties && !m.post.length);
firstGroup = false;
if (!m.post.length)
break;
str = m.post;
continue;
}
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
const isSequence = isNumericSequence || isAlphaSequence;
const isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,(?!,).*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post;
isTop = true;
continue;
}
// Nothing here expands, so the whole remaining string is literal.
return combine(acc, pre + '{' + m.body + '}' + m.post, [''], max, maxLength, dropEmpties);
}
if (firstGroup) {
dropEmpties = isTop && !isSequence;
firstGroup = false;
}
let values;
if (isSequence) {
values = expandSequence(m.body, isAlphaSequence, max, maxLength);
} }
else { else {
N = []; let n = parseCommaParts(m.body);
for (let j = 0; j < n.length; j++) { if (n.length === 1 && n[0] !== undefined) {
N.push.apply(N, expand_(n[j], max, false)); // x{{a,b}}y ==> x{a}y x{b}y
n = expand_(n[0], max, maxLength, false).map(embrace);
//XXX is this necessary? Can't seem to hit it in tests.
/* c8 ignore start */
if (n.length === 1) {
acc = combine(acc, pre + n[0], [''], max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
continue;
}
/* c8 ignore stop */
}
// Values that `combine` is going to drop as empty produce no result, so
// they must not count against `max` - otherwise `{a,,b}` with `max: 2`
// would stop at `['a', '']` and yield one result instead of two. Skipping
// them outright keeps `values` bounded while leaving `max` a bound on
// *kept* results.
let dropsEmpties = dropEmpties && !m.post.length && !pre;
for (let d = 0; dropsEmpties && d < acc.length; d++) {
if (acc[d]) {
dropsEmpties = false;
} }
} }
for (let j = 0; j < N.length; j++) { values = [];
for (let k = 0; k < post.length && expansions.length < max; k++) { let valuesLength = 0;
const expansion = pre + N[j] + post[k]; outer: for (let j = 0; j < n.length; j++) {
if (!isTop || isSequence || expansion) { const expanded = expand_(n[j], max, maxLength, false);
expansions.push(expansion); for (let k = 0; k < expanded.length; k++) {
const v = expanded[k];
if (dropsEmpties && !v)
continue;
if (values.length >= max || valuesLength + v.length > maxLength) {
break outer;
}
values.push(v);
valuesLength += v.length;
} }
} }
} }
acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
} }
return expansions; return acc;
} }
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -1,6 +1,8 @@
export declare const EXPANSION_MAX = 100000; export declare const EXPANSION_MAX = 100000;
export declare const EXPANSION_MAX_LENGTH = 4000000;
export type BraceExpansionOptions = { export type BraceExpansionOptions = {
max?: number; max?: number;
maxLength?: number;
}; };
export declare function expand(str: string, options?: BraceExpansionOptions): string[]; export declare function expand(str: string, options?: BraceExpansionOptions): string[];
//# sourceMappingURL=index.d.ts.map //# sourceMappingURL=index.d.ts.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAwDpC,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,aAAa,SAAU,CAAA;AAYpC,eAAO,MAAM,oBAAoB,UAAY,CAAA;AAwD7C,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,YAkBtE"}
+144 -56
View File
@@ -15,6 +15,17 @@ const closePattern = /\\}/g;
const commaPattern = /\\,/g; const commaPattern = /\\,/g;
const periodPattern = /\\\./g; const periodPattern = /\\\./g;
export const EXPANSION_MAX = 100_000; export const EXPANSION_MAX = 100_000;
// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An
// input like `'{a,b}'.repeat(1500)` stays under that count - its output is
// truncated to 100k results - while making every result ~1500 characters
// long. The result set, and the intermediate arrays built while combining
// brace sets, then grow large enough to exhaust memory and crash the process
// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of
// characters the accumulator may hold at any point, so memory stays flat no
// matter how many brace groups are chained. The limit sits well above any
// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M
// characters) so legitimate input is unaffected.
export const EXPANSION_MAX_LENGTH = 4_000_000;
function numeric(str) { function numeric(str) {
return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0); return !isNaN(str) ? parseInt(str, 10) : str.charCodeAt(0);
} }
@@ -64,7 +75,7 @@ export function expand(str, options = {}) {
if (!str) { if (!str) {
return []; return [];
} }
const { max = EXPANSION_MAX } = options; const { max = EXPANSION_MAX, maxLength = EXPANSION_MAX_LENGTH } = options;
// I don't know why Bash 4.3 does this, but it does. // I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved // Anything starting with {} will have the first two bytes preserved
// but *only* at the top level, so {},a}b will not expand to anything, // but *only* at the top level, so {},a}b will not expand to anything,
@@ -74,7 +85,7 @@ export function expand(str, options = {}) {
if (str.slice(0, 2) === '{}') { if (str.slice(0, 2) === '{}') {
str = '\\{\\}' + str.slice(2); str = '\\{\\}' + str.slice(2);
} }
return expand_(escapeBraces(str), max, true).map(unescapeBraces); return expand_(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
} }
function embrace(str) { function embrace(str) {
return '{' + str + '}'; return '{' + str + '}';
@@ -88,55 +99,43 @@ function lte(i, y) {
function gte(i, y) { function gte(i, y) {
return i >= y; return i >= y;
} }
function expand_(str, max, isTop) { // Build `{ acc[a] + pre + values[v] }` for every combination, capping the
/** @type {string[]} */ // number of results at `max` and the total number of characters at `maxLength`.
const expansions = []; // This is the one place output grows, so bounding it here keeps the single
const m = balanced('{', '}', str); // accumulator - and therefore memory - flat regardless of how many brace groups
if (!m) // are combined (CVE-2026-14257).
return [str]; function combine(acc, pre, values, max, maxLength, dropEmpties) {
// no need to expand pre, since it is guaranteed to be free of brace-sets const out = [];
const pre = m.pre; let length = 0;
const post = m.post.length ? expand_(m.post, max, false) : ['']; for (let a = 0; a < acc.length; a++) {
if (/\$$/.test(m.pre)) { for (let v = 0; v < values.length; v++) {
for (let k = 0; k < post.length && k < max; k++) { if (out.length >= max)
const expansion = pre + '{' + m.body + '}' + post[k]; return out;
expansions.push(expansion); const expansion = acc[a] + pre + values[v];
// Bash drops empty results at the top level. Skip them before they count
// against `max`, so `max` bounds the number of *kept* results.
if (dropEmpties && !expansion)
continue;
if (length + expansion.length > maxLength)
return out;
out.push(expansion);
length += expansion.length;
} }
} }
else { return out;
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); }
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)
const isSequence = isNumericSequence || isAlphaSequence; // sequence body.
const isOptions = m.body.indexOf(',') >= 0; function expandSequence(body, isAlphaSequence, max, maxLength) {
if (!isSequence && !isOptions) { const n = body.split(/\.\./);
// {a},b} const N = [];
if (m.post.match(/,(?!,).*\}/)) { // A sequence body always splits into two or three parts, but the compiler
str = m.pre + '{' + m.body + escClose + m.post; // can't know that.
return expand_(str, max, true);
}
return [str];
}
let n;
if (isSequence) {
n = m.body.split(/\.\./);
}
else {
n = parseCommaParts(m.body);
if (n.length === 1 && n[0] !== undefined) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand_(n[0], max, false).map(embrace);
//XXX is this necessary? Can't seem to hit it in tests.
/* c8 ignore start */ /* c8 ignore start */
if (n.length === 1) { if (n[0] === undefined || n[1] === undefined) {
return post.map(p => m.pre + n[0] + p); return N;
} }
/* c8 ignore stop */ /* c8 ignore stop */
}
}
// at this point, n is the parts, and we know it's not a comma set
// with a single entry.
let N;
if (isSequence && n[0] !== undefined && n[1] !== undefined) {
const x = numeric(n[0]); const x = numeric(n[0]);
const y = numeric(n[1]); const y = numeric(n[1]);
const width = Math.max(n[0].length, n[1].length); const width = Math.max(n[0].length, n[1].length);
@@ -150,7 +149,7 @@ function expand_(str, max, isTop) {
test = gte; test = gte;
} }
const pad = n.some(isPadded); const pad = n.some(isPadded);
N = []; let length = 0;
for (let i = x; test(i, y) && N.length < max; i += incr) { for (let i = x; test(i, y) && N.length < max; i += incr) {
let c; let c;
if (isAlphaSequence) { if (isAlphaSequence) {
@@ -174,24 +173,113 @@ function expand_(str, max, isTop) {
} }
} }
} }
if (length + c.length > maxLength)
break;
N.push(c); N.push(c);
length += c.length;
} }
return N;
}
function expand_(str, max, maxLength, isTop) {
// Consume the string's top-level brace groups left to right, threading a
// running set of combined prefixes (`acc`). Expanding the tail iteratively -
// rather than recursing on `m.post` once per group - keeps the native stack
// depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no
// longer overflow the stack, and leaves a single accumulator whose size
// `maxLength` bounds directly (CVE-2026-14257).
let acc = [''];
// Bash drops empty results, but only when the *first* top-level group is a
// comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
// is on the final strings, so it is applied to whichever `combine` produces
// them (the one with no brace set left in the tail).
let dropEmpties = false;
let firstGroup = true;
for (;;) {
const m = balanced('{', '}', str);
// No brace set left: the rest of the string is literal.
if (!m) {
return combine(acc, str, [''], max, maxLength, dropEmpties);
}
// no need to expand pre, since it is guaranteed to be free of brace-sets
const pre = m.pre;
if (/\$$/.test(pre)) {
acc = combine(acc, pre + '{' + m.body + '}', [''], max, maxLength, dropEmpties && !m.post.length);
firstGroup = false;
if (!m.post.length)
break;
str = m.post;
continue;
}
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
const isSequence = isNumericSequence || isAlphaSequence;
const isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,(?!,).*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post;
isTop = true;
continue;
}
// Nothing here expands, so the whole remaining string is literal.
return combine(acc, pre + '{' + m.body + '}' + m.post, [''], max, maxLength, dropEmpties);
}
if (firstGroup) {
dropEmpties = isTop && !isSequence;
firstGroup = false;
}
let values;
if (isSequence) {
values = expandSequence(m.body, isAlphaSequence, max, maxLength);
} }
else { else {
N = []; let n = parseCommaParts(m.body);
for (let j = 0; j < n.length; j++) { if (n.length === 1 && n[0] !== undefined) {
N.push.apply(N, expand_(n[j], max, false)); // x{{a,b}}y ==> x{a}y x{b}y
n = expand_(n[0], max, maxLength, false).map(embrace);
//XXX is this necessary? Can't seem to hit it in tests.
/* c8 ignore start */
if (n.length === 1) {
acc = combine(acc, pre + n[0], [''], max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
continue;
}
/* c8 ignore stop */
}
// Values that `combine` is going to drop as empty produce no result, so
// they must not count against `max` - otherwise `{a,,b}` with `max: 2`
// would stop at `['a', '']` and yield one result instead of two. Skipping
// them outright keeps `values` bounded while leaving `max` a bound on
// *kept* results.
let dropsEmpties = dropEmpties && !m.post.length && !pre;
for (let d = 0; dropsEmpties && d < acc.length; d++) {
if (acc[d]) {
dropsEmpties = false;
} }
} }
for (let j = 0; j < N.length; j++) { values = [];
for (let k = 0; k < post.length && expansions.length < max; k++) { let valuesLength = 0;
const expansion = pre + N[j] + post[k]; outer: for (let j = 0; j < n.length; j++) {
if (!isTop || isSequence || expansion) { const expanded = expand_(n[j], max, maxLength, false);
expansions.push(expansion); for (let k = 0; k < expanded.length; k++) {
const v = expanded[k];
if (dropsEmpties && !v)
continue;
if (values.length >= max || valuesLength + v.length > maxLength) {
break outer;
}
values.push(v);
valuesLength += v.length;
} }
} }
} }
acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length);
if (!m.post.length)
break;
str = m.post;
} }
return expansions; return acc;
} }
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "brace-expansion", "name": "brace-expansion",
"description": "Brace expansion as known from sh/bash", "description": "Brace expansion as known from sh/bash",
"version": "5.0.6", "version": "5.0.9",
"files": [ "files": [
"dist" "dist"
], ],
@@ -46,7 +46,7 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
}, },
"tshy": { "tshy": {
"exports": { "exports": {
@@ -59,6 +59,6 @@
"module": "./dist/esm/index.js", "module": "./dist/esm/index.js",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+ssh://git@github.com/juliangruber/brace-expansion.git" "url": "git+https://github.com/juliangruber/brace-expansion.git"
} }
} }
+86
View File
@@ -1,5 +1,91 @@
# CHANGELOG # CHANGELOG
## [9.1.1](https://github.com/nodemailer/nodemailer/compare/v9.1.0...v9.1.1) (2026-09-01)
### Bug Fixes
* **mailer:** apply the message access policy in resolveContent ([dc48ed3](https://github.com/nodemailer/nodemailer/commit/dc48ed395c4d6c79ee5c95eb6eff17bafe391474))
* **mailer:** keep message data from reopening the access sandbox ([ab7ef34](https://github.com/nodemailer/nodemailer/commit/ab7ef348b9a97b1fd70e7bfbeb56d4ea4a07946b))
* **mime-node:** inherit the access policy from the tree a node hangs in ([262d550](https://github.com/nodemailer/nodemailer/commit/262d550b1e121e3ff4ef6675d6771a5b2b4ddcec))
## [9.1.0](https://github.com/nodemailer/nodemailer/compare/v9.0.6...v9.1.0) (2026-08-31)
### Features
* **mailer:** cap recipients per message with maxRecipients ([7279ac8](https://github.com/nodemailer/nodemailer/commit/7279ac8dee4f66c032981e6e51e3e7210ad0dcbf))
### Bug Fixes
* **addressparser:** handle address lists in linear time ([9116da9](https://github.com/nodemailer/nodemailer/commit/9116da9528c6524cefaed75185602a7e85d20434))
* **addressparser:** terminate the domain at an RFC 5322 comment ([902b63e](https://github.com/nodemailer/nodemailer/commit/902b63e935435c30f4025901c0902dce64cd8880))
* **mime-node:** apply UTS-46 mapping when encoding a domain ([259c32d](https://github.com/nodemailer/nodemailer/commit/259c32d7d266301e3377a212776c3fff993c0148))
* **mime-node:** dedupe envelope recipients in linear time ([7cc38af](https://github.com/nodemailer/nodemailer/commit/7cc38af418ffa6fc7e86085195ca5ca681694b3e))
* **mime-node:** flatten parsed addresses without concat.apply ([83b8c48](https://github.com/nodemailer/nodemailer/commit/83b8c48cbdb8b3116f2e1ba84af755b2c5661c0f))
* **mime-node:** keep the recipient dedupe linear across address headers ([34da642](https://github.com/nodemailer/nodemailer/commit/34da64282dcdc9b0581c721a27ab2fa226673150))
* **mime-node:** keep URL delimiters away from the domain mapper ([b212ac4](https://github.com/nodemailer/nodemailer/commit/b212ac4e27bce8182478044fcb8d1642ccdad46e))
## [9.0.6](https://github.com/nodemailer/nodemailer/compare/v9.0.5...v9.0.6) (2026-08-27)
### Bug Fixes
* **addressparser:** recover the addr-spec from an angle-addr holding whitespace ([e989a22](https://github.com/nodemailer/nodemailer/commit/e989a22ca4f5161929bf37be8fb07de635016fa7))
* harden copies of user supplied keys and URL fetching ([2f667f4](https://github.com/nodemailer/nodemailer/commit/2f667f4272cb2d7cb479b2e3903ab10600fc0eae))
## [9.0.5](https://github.com/nodemailer/nodemailer/compare/v9.0.4...v9.0.5) (2026-08-07)
### Bug Fixes
* **ci:** retrigger the workflows dropped during the Actions outage ([85d16c1](https://github.com/nodemailer/nodemailer/commit/85d16c103ec69e237c7e55c0e3103f439c135ce3))
* **mailer:** escape specials in List-* header comments ([#1842](https://github.com/nodemailer/nodemailer/issues/1842)) ([75913bb](https://github.com/nodemailer/nodemailer/commit/75913bba032623046dd7fa037b8b880e388d8357))
* **mime-funcs:** star the continuation key of a restarted parameter line ([36bcf1a](https://github.com/nodemailer/nodemailer/commit/36bcf1a21a92b5a283c780e35aacd3080292d98d))
* **mime-node:** keep control chars out of header values and msg-id headers ([15cf6d1](https://github.com/nodemailer/nodemailer/commit/15cf6d1c15cdf60f551618fd54fb727ea7aac94c))
* **mime:** encode DEL in header parameters and List-* comments ([cf69430](https://github.com/nodemailer/nodemailer/commit/cf69430ffac1d246bfbab564daf321f31ed9e1dd))
* **mime:** keep control chars out of the remaining header positions ([5ed9d26](https://github.com/nodemailer/nodemailer/commit/5ed9d26f85eecb48f4b3ae74ad33ed2abf002040))
* **mime:** normalize an address parsed out of a string as well ([63685f7](https://github.com/nodemailer/nodemailer/commit/63685f7dd4aefa75cc72a36f983f32f61cd6733e))
* **mime:** normalize an address so header and envelope agree ([a9343b4](https://github.com/nodemailer/nodemailer/commit/a9343b47e42b9ccb27911ad8e73d4119c6170c85))
* **mime:** stop a header key callback and the dkim tags from injecting ([b7d772e](https://github.com/nodemailer/nodemailer/commit/b7d772ea4ec12ee82e65a9b919af882bf0f125d9))
## [9.0.4](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.4) (2026-08-04)
### Bug Fixes
* **mime-funcs:** do not let an unpaired surrogate consume the next character ([9797f7f](https://github.com/nodemailer/nodemailer/commit/9797f7f57d47b1e27d8ae050550ba93600dbf9f4))
* **mime-funcs:** keep any surrogate pair intact when chunking base64 mime words ([#1838](https://github.com/nodemailer/nodemailer/issues/1838)) ([5bd3a65](https://github.com/nodemailer/nodemailer/commit/5bd3a657be2d12df1ac2838a1673cb4885e99016))
* **mime-funcs:** percent encode unpaired surrogates in header parameter values ([78f4aa2](https://github.com/nodemailer/nodemailer/commit/78f4aa253d0ebb9c5ba91301adacef5fb6cf5493))
* **mime-node:** escape backslash and quote in the Content-Type name parameter ([#1837](https://github.com/nodemailer/nodemailer/issues/1837)) ([adcfc4f](https://github.com/nodemailer/nodemailer/commit/adcfc4f46445edc8a14136b2bf3f775943425c13))
* **mime:** encode HT/CR/LF in header parameter values instead of quoting them ([#1840](https://github.com/nodemailer/nodemailer/issues/1840)) ([5bc9cab](https://github.com/nodemailer/nodemailer/commit/5bc9cabddcb8d18d16244701ae9facc8fba942a3))
## [9.0.3](https://github.com/nodemailer/nodemailer/compare/v9.0.2...v9.0.3) (2026-06-30)
### Bug Fixes
* **smtp-connection:** harden STARTTLS upgrade and secure socket handling ([#1835](https://github.com/nodemailer/nodemailer/issues/1835)) ([07d8253](https://github.com/nodemailer/nodemailer/commit/07d8253326ecefff9f7d92c157429ce8bc7335f8))
## [9.0.2](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.0.2) (2026-06-29)
### Bug Fixes
* **addressparser:** keep operator chars inside an address-literal as text ([#1829](https://github.com/nodemailer/nodemailer/issues/1829)) ([9ba1064](https://github.com/nodemailer/nodemailer/commit/9ba1064f3b115cd60cb63aa954a3c0961e86fbb7))
* harden smtp-connection low-severity issues ([22ddcea](https://github.com/nodemailer/nodemailer/commit/22ddcea8ed043e4ea23b8971b75a2df196b5a581))
* harden smtp-connection response parsing and socket lifecycle ([68860b9](https://github.com/nodemailer/nodemailer/commit/68860b94311b5b6837754e5e790f186ca8a00b70))
* prevent SES transport callback double-invocation and hang on sync errors ([#1831](https://github.com/nodemailer/nodemailer/issues/1831)) ([9517bc5](https://github.com/nodemailer/nodemailer/commit/9517bc5dc94e77907bb157e3466bf73a2b327f5c))
* reject CRLF in HTTP proxy CONNECT destination to prevent request injection ([6347b47](https://github.com/nodemailer/nodemailer/commit/6347b47c7d12f9d3acf53d391b921e836f400640))
## [9.0.1](https://github.com/nodemailer/nodemailer/compare/v9.0.0...v9.0.1) (2026-06-17)
### Bug Fixes
* enforce disableFileAccess/disableUrlAccess for raw message option ([a82e060](https://github.com/nodemailer/nodemailer/commit/a82e060d978f27e5f41369a9a9807b1e3dedc2e2))
## [9.0.0](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0) (2026-06-14) ## [9.0.0](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0) (2026-06-14)
+3 -6
View File
@@ -8,10 +8,7 @@ Send emails from Node.js easy as cake! 🍰✉️
See [nodemailer.com](https://nodemailer.com/) for documentation and terms. See [nodemailer.com](https://nodemailer.com/) for documentation and terms.
> [!TIP] > Nodemailer is developed by the team behind **[EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs)**, a self-hosted email API that turns any Gmail, Microsoft 365, or IMAP account into a REST endpoint, with managed OAuth2, webhooks for incoming mail, and built-in [sending](https://emailengine.app/sending-emails?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs). If you would rather call an HTTP API than maintain IMAP and SMTP connections yourself, that is what it is for.
> Check out **[EmailEngine](https://emailengine.app/?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link)** a self-hosted email gateway that allows making **REST requests against IMAP and SMTP servers**. EmailEngine also sends webhooks whenever something changes on the registered accounts.\
> \
> Using the email accounts registered with EmailEngine, you can receive and [send emails](https://emailengine.app/sending-emails?utm_source=github-nodemailer&utm_campaign=nodemailer&utm_medium=readme-link). EmailEngine supports OAuth2, delayed sends, opens and clicks tracking, bounce detection, etc. All on top of regular email accounts without an external MTA service.
## Having an issue? ## Having an issue?
@@ -25,7 +22,7 @@ You are using an older Node.js version than v6.0. Upgrade Node.js to get support
#### I'm having issues with Gmail #### I'm having issues with Gmail
Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/). Gmail either works well, or it does not work at all. It is probably easier to switch to an alternative service instead of fixing issues with Gmail. If Gmail does not work for you, then don't use it. Read more about it [here](https://nodemailer.com/usage/using-gmail/). If the blocker is OAuth2 setup rather than Gmail itself, [EmailEngine](https://emailengine.app/?utm_source=nodemailer-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=faq-gmail) handles the OAuth2 flow and token refresh for you.
#### I get ETIMEDOUT errors #### I get ETIMEDOUT errors
@@ -55,7 +52,7 @@ let configOptions = {
#### I have issues with DNS / hosts file #### I have issues with DNS / hosts file
Node.js uses [c-ares](https://nodejs.org/en/docs/meta/topics/dependencies/#c-ares) to resolve domain names, not the DNS library provided by the system, so if you have some custom DNS routing set up, it might be ignored. Nodemailer runs [dns.resolve4()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve4hostname-options-callback) and [dns.resolve6()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve6hostname-options-callback) to resolve hostname into an IP address. If both calls fail, then Nodemailer will fall back to [dns.lookup()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnslookuphostname-options-callback). If this does not work for you, you can hard code the IP address into the configuration like shown below. In that case, Nodemailer would not perform any DNS lookups. Node.js uses [c-ares](https://github.com/c-ares/c-ares) to resolve domain names, not the DNS library provided by the system, so if you have some custom DNS routing set up, it might be ignored. Nodemailer runs [dns.resolve4()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve4hostname-options-callback) and [dns.resolve6()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnsresolve6hostname-options-callback) to resolve hostname into an IP address. If both calls fail, then Nodemailer will fall back to [dns.lookup()](https://nodejs.org/dist/latest-v16.x/docs/api/dns.html#dnslookuphostname-options-callback). If this does not work for you, you can hard code the IP address into the configuration like shown below. In that case, Nodemailer would not perform any DNS lookups.
```js ```js
let configOptions = { let configOptions = {
+2 -2
View File
@@ -11,8 +11,8 @@ supported way to receive security updates.
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 8.x | :white_check_mark: | | 9.x | :white_check_mark: |
| < 8.0 | :x: | | < 9.0 | :x: |
If you are on an older major, please upgrade. See the migration notes at If you are on an older major, please upgrade. See the migration notes at
<https://nodemailer.com/> before updating. <https://nodemailer.com/> before updating.
+183 -9
View File
@@ -1,5 +1,134 @@
'use strict'; 'use strict';
/**
* Restores the quoting of a local part that was read out of a quoted string.
*
* RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
* on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
* domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
* same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
* recipient separator once the consumer puts the address back into a header.
*
* This module has no dependencies so that it can ship on its own, which is why the two
* grammar tests below are spelled out here instead of shared with lib/mime-node. Keeping
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
* dot-atom rule on top of this when it emits an address.
*
* @param {String} address Address with an unquoted local part
* @return {String} Address with the local part as a quoted-string
*/
function _quoteLocalPart(address) {
const lastAt = address.lastIndexOf('@');
if (lastAt < 0) {
// no domain to split off, nothing can be misrouted
return address;
}
const user = address.substr(0, lastAt);
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
// a local part that carries no special reads the same with or without the quotes,
// and one that is already a complete quoted-string needs nothing either
return address;
}
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
}
/**
* Reached for every parsed address, so it is built once rather than per call.
*/
const HAS_WHITESPACE = /\s/;
/**
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
*/
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
/**
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
*/
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
/**
* The looser reading applied once the strict one finds nothing, which tolerates the
* further '@' that a domain should not have but malformed headers carry anyway.
*/
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
/**
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
*
* A malformed header can put more than a mailbox between the angle brackets, most often
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
* recipient that is sitting right there in the header.
*
* The run that still reads as an addr-spec is kept and whatever is left over becomes
* display text rather than being dropped. Candidates are read strictly first and then
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
* recipient. When several runs qualify the first wins, which is what that branch's looser
* tier does within a token.
*
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
* '<"user name"@example.com>' is well formed and means exactly what it says.
*
* @param {Object} data Collected address parts, mutated in place
*/
function _recoverAddrSpec(data) {
if (!HAS_WHITESPACE.test(data.address)) {
return;
}
let address;
let rest;
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
if (quoted) {
if (!quoted[2]) {
// the whitespace sits inside the quoted local part, this is a well formed mailbox
return;
}
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
// than splitting into the quotes
address = quoted[1];
rest = [quoted[2]];
} else {
if (data.address.indexOf('"') >= 0) {
// Splitting on whitespace loses track of where the quoted string starts and ends,
// and this module does not take addresses out of quoted strings: the run picked out
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
// quotes were hiding. Every well formed shape was already handled above, so what is
// left is wreckage either way and the original is the honest answer
return;
}
const parts = data.address.split(/\s+/);
let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
if (addrIndex < 0) {
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
}
if (addrIndex < 0) {
// nothing in there reads as an address, there is no better answer than the original
return;
}
address = parts.splice(addrIndex, 1)[0];
rest = parts;
}
data.address = address;
data.text = [data.text]
.concat(rest)
.filter(part => part)
.join(' ');
}
/** /**
* Converts tokens for a single address into an address object * Converts tokens for a single address into an address object
* *
@@ -55,7 +184,18 @@ function _handleAddress(tokens, depth) {
token.value = token.value.replace(/^[^<]*<\s*/, ''); token.value = token.value.replace(/^[^<]*<\s*/, '');
} }
if (prevToken && prevToken.noBreak && data[state].length) { // A comment is folding whitespace. It may sit inside an addr-spec, on either side
// of the '@', but it cannot join two atoms into one: gluing across it would read
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
// deliver to a domain the sender never named.
const parts = data[state];
const joins =
prevToken &&
prevToken.noBreak &&
parts.length &&
(prevToken.value !== ')' || parts[parts.length - 1].slice(-1) === '@' || token.value.charAt(0) === '@');
if (joins) {
data[state][data[state].length - 1] += token.value; data[state][data[state].length - 1] += token.value;
if (state === 'text' && insideQuotes) { if (state === 'text' && insideQuotes) {
data.textWasQuoted[data.textWasQuoted.length - 1] = true; data.textWasQuoted[data.textWasQuoted.length - 1] = true;
@@ -103,7 +243,7 @@ function _handleAddress(tokens, depth) {
// Security: Do not extract email addresses from quoted strings. // Security: Do not extract email addresses from quoted strings.
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com. // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
// Extracting emails from quoted text leads to misrouting vulnerabilities. // Extracting emails from quoted text leads to misrouting vulnerabilities.
if (!data.textWasQuoted[i] && /^[^@\s]+@[^@\s]+$/.test(data.text[i])) { if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
data.address = data.text.splice(i, 1); data.address = data.text.splice(i, 1);
data.textWasQuoted.splice(i, 1); data.textWasQuoted.splice(i, 1);
break; break;
@@ -145,10 +285,16 @@ function _handleAddress(tokens, depth) {
data.text = data.text.concat(data.address.splice(1)); data.text = data.text.concat(data.address.splice(1));
} }
// An address is only taken from unquoted text, so anything left in the text at this
// point that still has to serve as the address carries its quoting in this flag
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
// Join values with spaces // Join values with spaces
data.text = data.text.join(' '); data.text = data.text.join(' ');
data.address = data.address.join(' '); data.address = data.address.join(' ');
_recoverAddrSpec(data);
const address = { const address = {
address: data.address || data.text || '', address: data.address || data.text || '',
name: data.text || data.address || '' name: data.text || data.address || ''
@@ -162,6 +308,10 @@ function _handleAddress(tokens, depth) {
} }
} }
if (addressFromQuotedText && address.address) {
address.address = _quoteLocalPart(address.address);
}
addresses.push(address); addresses.push(address);
} }
@@ -181,6 +331,7 @@ class Tokenizer {
this.operatorExpecting = ''; this.operatorExpecting = '';
this.node = null; this.node = null;
this.escaped = false; this.escaped = false;
this.inDomainLiteral = false;
this.list = []; this.list = [];
/** /**
@@ -232,6 +383,21 @@ class Tokenizer {
* @param {String} chr Character from the address field * @param {String} chr Character from the address field
*/ */
checkChar(chr, nextChr) { checkChar(chr, nextChr) {
// Track RFC 5322 domain-literals ("[" *dtext "]"). Operator characters such
// as the ":" of an IPv6 address-literal (user@[IPv6:2001:db8::1]) are dtext
// and must not be treated as the group delimiter while inside the brackets.
// Quoted strings and comments are handled separately via operatorExpecting,
// so only enter this state when no operator is open. The list separators ","
// and ";" are the exception: they always end the literal (and split the
// address list) so that an unclosed "[" cannot swallow later recipients.
if (!this.escaped && !this.operatorExpecting) {
if (!this.inDomainLiteral && chr === '[') {
this.inDomainLiteral = true;
} else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
this.inDomainLiteral = false;
}
}
if (this.escaped) { if (this.escaped) {
// ignore next condition blocks // ignore next condition blocks
} else if (chr === this.operatorExpecting) { } else if (chr === this.operatorExpecting) {
@@ -250,7 +416,7 @@ class Tokenizer {
this.escaped = false; this.escaped = false;
return; return;
} else if (!this.operatorExpecting && chr in this.operators) { } else if (!this.operatorExpecting && !this.inDomainLiteral && chr in this.operators) {
this.node = { this.node = {
type: 'operator', type: 'operator',
value: chr value: chr
@@ -344,8 +510,10 @@ function addressparser(str, options) {
addresses.forEach(addr => { addresses.forEach(addr => {
const handled = _handleAddress(addr, depth); const handled = _handleAddress(addr, depth);
if (handled.length) { // Appended in place. Rebuilding the accumulator with concat() would copy every
parsedAddresses = parsedAddresses.concat(handled); // entry collected so far on each address, making a flat list cost O(n^2).
for (let i = 0; i < handled.length; i++) {
parsedAddresses.push(handled[i]);
} }
}); });
@@ -353,14 +521,20 @@ function addressparser(str, options) {
// "Joe Foo, PhD <joe@example.com>" is split on the comma into // "Joe Foo, PhD <joe@example.com>" is split on the comma into
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. // [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
// Recombine: a name-only entry followed by an entry with both name and address. // Recombine: a name-only entry followed by an entry with both name and address.
for (let i = parsedAddresses.length - 2; i >= 0; i--) { // Walked back to front so that a run of fragments folds into one entry in a single
// pass. Splicing each fragment out of the list instead would cost O(n^2).
const mergedAddresses = [];
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
const current = parsedAddresses[i]; const current = parsedAddresses[i];
const next = parsedAddresses[i + 1]; const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
if (current.address === '' && current.name && !current.group && next.address && next.name) { if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
next.name = current.name + ', ' + next.name; next.name = current.name + ', ' + next.name;
parsedAddresses.splice(i, 1); } else {
mergedAddresses.push(current);
} }
} }
mergedAddresses.reverse();
parsedAddresses = mergedAddresses;
if (options.flatten) { if (options.flatten) {
const flatAddresses = []; const flatAddresses = [];
+6 -1
View File
@@ -10,6 +10,7 @@ const { PassThrough } = require('stream');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { copyOwnKeys } = require('../shared/objects');
const DKIM_ALGO = 'sha256'; const DKIM_ALGO = 'sha256';
const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk const MAX_MESSAGE_SIZE = 2 * 1024 * 1024; // buffer messages larger than this to disk
@@ -225,7 +226,11 @@ class DKIM {
let options = this.options; let options = this.options;
if (extraOptions && Object.keys(extraOptions).length) { if (extraOptions && Object.keys(extraOptions).length) {
options = Object.assign({}, extraOptions, this.options); // extraOptions is mail.data._dkim, caller supplied message data. An own
// "__proto__" key there would let every option this signer reads and the
// transport did not set, such as skipFields, answer from the caller
options = copyOwnKeys({}, extraOptions);
copyOwnKeys(options, this.options);
} }
const signer = new DKIMSigner(options, this.keys, inputStream, output); const signer = new DKIMSigner(options, this.keys, inputStream, output);
+1 -1
View File
@@ -11,7 +11,7 @@ class RelaxedBody extends Transform {
options = options || {}; options = options || {};
this.chunkBuffer = []; this.chunkBuffer = [];
this.chunkBufferLen = 0; this.chunkBufferLen = 0;
this.bodyHash = crypto.createHash(options.hashAlgo || 'sha1'); this.bodyHash = crypto.createHash(options.hashAlgo || 'sha256');
this.remainder = ''; this.remainder = '';
this.byteLength = 0; this.byteLength = 0;
+8 -3
View File
@@ -50,15 +50,20 @@ module.exports = (headers, hashAlgo, bodyHash, options) => {
module.exports.relaxedHeaders = relaxedHeaders; module.exports.relaxedHeaders = relaxedHeaders;
function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) { function generateDKIMHeader(domainName, keySelector, fieldNames, hashAlgo, bodyHash) {
// the caller supplied tag values are interpolated straight into the tag list, and none of
// them has any way to carry a control char, DEL, or one of the delimiters that would close
// the value and open a tag of its own
const cleanTagValue = value => (value || '').toString().replace(/[\x00-\x1f\x7f;=]/g, '');
const dkim = [ const dkim = [
'v=1', 'v=1',
'a=rsa-' + hashAlgo, 'a=rsa-' + hashAlgo,
'c=relaxed/relaxed', 'c=relaxed/relaxed',
'd=' + punycode.toASCII(domainName), 'd=' + punycode.toASCII(cleanTagValue(domainName)),
'q=dns/txt', 'q=dns/txt',
's=' + keySelector, 's=' + cleanTagValue(keySelector),
'bh=' + bodyHash, 'bh=' + bodyHash,
'h=' + fieldNames 'h=' + cleanTagValue(fieldNames)
].join('; '); ].join('; ');
return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b='; return mimeFuncs.foldLines('DKIM-Signature: ' + dkim, 76) + ';\r\n b=';
+1
View File
@@ -38,6 +38,7 @@ const ERROR_CODES = {
// Resource errors // Resource errors
EMAXLIMIT: 'Pool resource limit reached (max messages per connection)', EMAXLIMIT: 'Pool resource limit reached (max messages per connection)',
EMAXRECIPIENTS: 'Recipient count exceeds maxRecipients',
// Transport-specific errors // Transport-specific errors
ESENDMAIL: 'Sendmail command error', ESENDMAIL: 'Sendmail command error',
+115 -5
View File
@@ -9,9 +9,67 @@ const Cookies = require('./cookies');
const packageData = require('../../package.json'); const packageData = require('../../package.json');
const net = require('net'); const net = require('net');
const errors = require('../errors'); const errors = require('../errors');
const { isProtoKey } = require('../shared/objects');
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
// Only genuine TLS settings are taken from options.tls. That object reaches us straight
// from a user supplied attachment (content.tls), so keys like host, port, path, socketPath
// or lookup would otherwise repoint the request at a destination that never went through
// the URL checks below.
//
// The source of truth is the tls.connect() option list in the Node docs. A key missing
// here is dropped silently, so extend this list rather than working around it.
const TLS_OPTION_KEYS = [
'ALPNProtocols',
'ca',
'cert',
'checkServerIdentity',
'ciphers',
'crl',
'dhparam',
'ecdhCurve',
'honorCipherOrder',
'key',
'maxVersion',
'minVersion',
'passphrase',
'pfx',
'rejectUnauthorized',
'secureContext',
'secureOptions',
'secureProtocol',
'servername',
'sessionIdContext',
'sigalgs'
];
/**
* Resolves a URL only if it is one this module is willing to request.
*
* urllib.parse throws for a host that contains forbidden bytes, and it is called for
* every URL that reaches nmfetch, including ones that arrive from a message attachment
* or from a redirect Location header. An uncaught throw here takes the process down,
* so a URL that does not parse is reported the same way as one with a scheme we refuse.
*
* @param {String} url URL to parse
* @returns {Object|Boolean} Parsed URL, or false if it is not a usable http(s) URL
*/
function parseFetchUrl(url) {
let parsed;
try {
parsed = urllib.parse(url);
} catch (_err) {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return parsed;
}
module.exports = function (url, options) { module.exports = function (url, options) {
return nmfetch(url, options); return nmfetch(url, options);
}; };
@@ -26,6 +84,30 @@ function nmfetch(url, options) {
options.redirects = options.redirects || 0; options.redirects = options.redirects || 0;
options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects; options.maxRedirects = isNaN(options.maxRedirects) ? MAX_REDIRECTS : options.maxRedirects;
const fetchRes = options.fetchRes;
const parsed = parseFetchUrl(url);
if (!parsed) {
// Only http(s) URLs can be fetched. Any other scheme (file:, gopher:, a
// protocol-relative redirect target etc.) would otherwise be silently served over
// plain HTTP, possibly against an unintended host. Bail out before the cookie jar
// is touched so a refused URL can not seed it, and release a caller supplied body:
// this is the one exit that runs before the error handler below is attached to it,
// so an error on that stream would have nowhere to go and the fd or socket behind
// it would never be released.
if (options.body && typeof options.body.destroy === 'function') {
options.body.on('error', () => false);
options.body.destroy();
}
setImmediate(() => {
const err = new Error('Unsupported protocol for URL ' + url);
err.code = errors.EFETCH;
err.sourceUrl = url;
fetchRes.emit('error', err);
});
return fetchRes;
}
if (options.cookie) { if (options.cookie) {
[].concat(options.cookie || []).forEach(cookie => { [].concat(options.cookie || []).forEach(cookie => {
options.cookies.set(cookie, url); options.cookies.set(cookie, url);
@@ -33,8 +115,6 @@ function nmfetch(url, options) {
options.cookie = false; options.cookie = false;
} }
const fetchRes = options.fetchRes;
const parsed = urllib.parse(url);
let method = (options.method || '').toString().trim().toUpperCase() || 'GET'; let method = (options.method || '').toString().trim().toUpperCase() || 'GET';
let finished = false; let finished = false;
let cookies; let cookies;
@@ -48,6 +128,10 @@ function nmfetch(url, options) {
}; };
Object.keys(options.headers || {}).forEach(key => { Object.keys(options.headers || {}).forEach(key => {
// options.headers is the caller's httpHeaders, straight off an attachment
if (isProtoKey(key.toLowerCase().trim())) {
return;
}
headers[key.toLowerCase().trim()] = options.headers[key]; headers[key.toLowerCase().trim()] = options.headers[key];
}); });
@@ -131,7 +215,12 @@ function nmfetch(url, options) {
}; };
if (options.tls) { if (options.tls) {
Object.assign(reqOptions, options.tls); // see TLS_OPTION_KEYS
Object.keys(options.tls).forEach(key => {
if (TLS_OPTION_KEYS.includes(key)) {
reqOptions[key] = options.tls[key];
}
});
} }
if ( if (
@@ -216,8 +305,29 @@ function nmfetch(url, options) {
options.method = 'GET'; options.method = 'GET';
options.body = false; options.body = false;
const redirectUrl = urllib.resolve(url, res.headers.location); let redirectUrl;
const redirectParsed = urllib.parse(redirectUrl); try {
redirectUrl = urllib.resolve(url, res.headers.location);
} catch (_err) {
// the legacy resolver throws on a Location the WHATWG parser also refused,
// so fall through to the check below with what the server actually sent
redirectUrl = res.headers.location;
}
const redirectParsed = parseFetchUrl(redirectUrl);
if (!redirectParsed) {
// Refuse the redirect target here rather than leaving it to the recursive
// call: that call gets its own `finished` flag and no handle on this
// request, so this one would stay open and could emit a second error on
// the shared fetchRes once it times out. Callers listen with req.once().
finished = true;
const err = new Error('Unsupported protocol for URL ' + redirectUrl);
err.code = errors.EFETCH;
err.sourceUrl = redirectUrl;
fetchRes.emit('error', err);
req.abort();
return;
}
// Do not forward credentials when the redirect leaves the original // Do not forward credentials when the redirect leaves the original
// security context: a different host, or a downgrade from https to // security context: a different host, or a downgrade from https to
+1 -1
View File
@@ -33,7 +33,7 @@ class JSONTransport {
// Sendmail strips this header line by itself // Sendmail strips this header line by itself
mail.message.keepBcc = true; mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope(); const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId(); const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []); const recipients = [].concat(envelope.to || []);
+10 -4
View File
@@ -4,7 +4,7 @@
const MimeNode = require('../mime-node'); const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs'); const mimeFuncs = require('../mime-funcs');
const { parseDataURI } = require('../shared'); const { parseDataURI, copyOwnKeys } = require('../shared');
/** /**
* Creates the object for composing a MimeNode instance out from the mail options * Creates the object for composing a MimeNode instance out from the mail options
@@ -32,7 +32,11 @@ class MailComposer {
// Compose MIME tree // Compose MIME tree
if (this.mail.raw) { if (this.mail.raw) {
this.message = new MimeNode('message/rfc822', { newline: this.mail.newline }).setRaw(this.mail.raw); this.message = new MimeNode('message/rfc822', {
newline: this.mail.newline,
disableUrlAccess: this.mail.disableUrlAccess,
disableFileAccess: this.mail.disableFileAccess
}).setRaw(this.mail.raw);
} else if (this._useMixed) { } else if (this._useMixed) {
this.message = this._createMixed(); this.message = this._createMixed();
} else if (this._useAlternative) { } else if (this._useAlternative) {
@@ -201,7 +205,9 @@ class MailComposer {
typeof this.mail.icalEvent === 'object' && typeof this.mail.icalEvent === 'object' &&
(this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw) (this.mail.icalEvent.content || this.mail.icalEvent.path || this.mail.icalEvent.href || this.mail.icalEvent.raw)
) { ) {
icalEvent = Object.assign({}, this.mail.icalEvent); // an own "__proto__" key would make the copy inherit path/href from caller
// data, and the mapping below then replaces the content the caller did set
icalEvent = copyOwnKeys({}, this.mail.icalEvent);
} else { } else {
icalEvent = { icalEvent = {
content: this.mail.icalEvent content: this.mail.icalEvent
@@ -596,7 +602,7 @@ class MailComposer {
} }
// Return empty content for excessively long data URLs // Return empty content for excessively long data URLs
return Object.assign({}, element, { return Object.assign(copyOwnKeys({}, element), {
path: false, path: false,
href: false, href: false,
content: Buffer.alloc(0), content: Buffer.alloc(0),
+27
View File
@@ -15,6 +15,13 @@ const net = require('net');
const dns = require('dns'); const dns = require('dns');
const crypto = require('crypto'); const crypto = require('crypto');
/**
* Recipients allowed on one message unless the caller sets its own maxRecipients. A backstop
* against a runaway or hostile recipient list rather than a delivery policy: RFC 5321 only
* asks a server to accept 100, so a real send is bounded far below this.
*/
const DEFAULT_MAX_RECIPIENTS = 100000;
/** /**
* Creates an object for exposing the Mail API * Creates an object for exposing the Mail API
* *
@@ -191,6 +198,26 @@ class Mail extends EventEmitter {
mail.setPriorityHeaders(); mail.setPriorityHeaders();
mail.setListHeaders(); mail.setListHeaders();
const maxRecipients = mail.data.maxRecipients === undefined ? DEFAULT_MAX_RECIPIENTS : mail.data.maxRecipients;
const recipientCount = mail.message.getEnvelope().to.length;
if (maxRecipients && recipientCount > maxRecipients) {
const err = new Error(
`Message has ${recipientCount} recipients, which is over the ${maxRecipients} allowed by maxRecipients`
);
err.code = errors.EMAXRECIPIENTS;
this.logger.error(
{
err,
tnx: 'transport',
action: 'send'
},
'Send Error: %s',
err.message
);
return callback(err);
}
this._processPlugins('stream', mail, err => { this._processPlugins('stream', mail, err => {
if (err) { if (err) {
this.logger.error( this.logger.error(
+67 -36
View File
@@ -4,6 +4,11 @@ const shared = require('../shared');
const MimeNode = require('../mime-node'); const MimeNode = require('../mime-node');
const mimeFuncs = require('../mime-funcs'); const mimeFuncs = require('../mime-funcs');
// Only an own key counts as already set. `key in obj` also matches every member of
// Object.prototype, which silently drops a transporter default legitimately named
// toString or constructor.
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
class MailMessage { class MailMessage {
constructor(mailer, data) { constructor(mailer, data) {
this.mailer = mailer; this.mailer = mailer;
@@ -14,34 +19,53 @@ class MailMessage {
const options = mailer.options || {}; const options = mailer.options || {};
const defaults = mailer._defaults || {}; const defaults = mailer._defaults || {};
Object.assign(this.data, data); shared.copyOwnKeys(this.data, data);
this.data.headers = this.data.headers || {}; this.data.headers = this.data.headers || {};
// apply defaults // Apply defaults. `_defaults` is caller supplied too, it is the second argument of
Object.keys(defaults).forEach(key => { // createTransport, so it needs the same treatment as `data` above
if (!(key in this.data)) { shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
this.data[key] = defaults[key];
} else if (key === 'headers') {
// headers is a special case. Allow setting individual default headers // headers is a special case. Allow setting individual default headers
Object.keys(defaults.headers).forEach(key => { shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
if (!(key in this.data.headers)) {
this.data.headers[key] = defaults.headers[key];
}
});
}
});
// force specific keys from transporter options // force specific keys from transporter options
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey'].forEach(key => { ['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
if (key in options) { if (key in options) {
this.data[key] = options[key]; this.data[key] = options[key];
} }
}); });
// The access flags are a sandbox rather than a message field, so `defaults` counts as
// transporter configuration for them. For a transporter plugin it is the only channel
// there is, createTransport leaves `options` undefined for one, and the defaults copy
// above yields to anything the message already set, which let message data switch the
// sandbox back off. Closing is one way here, same as in resolveContent below: either
// side may switch a flag on, neither can switch off what the other closed.
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
if (!(key in options) && hasOwn(defaults, key)) {
this.data[key] = this.data[key] || defaults[key];
}
});
} }
resolveContent(...args) { resolveContent(data, key, options, callback) {
return shared.resolveContent(...args); // Most plugins call this with the legacy (data, key, callback) signature, which carries
// no access policy. The policy belongs to the message, so apply it here. Explicit
// options may only tighten it, never reopen what the transporter closed.
if (!callback && typeof options === 'function') {
callback = options;
options = false;
}
options = options || {};
const policy = {
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
};
return shared.resolveContent(data, key, policy, callback);
} }
resolveAll(callback) { resolveAll(callback) {
@@ -123,11 +147,12 @@ class MailMessage {
content: value content: value
}; };
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) { if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
Object.keys(args[0][args[1]]).forEach(key => { // The keys are the caller's, so copying them takes the same "__proto__"
if (!(key in node) && !['content', 'path', 'href', 'raw'].includes(key)) { // rule as the constructor. `key in node` stays as the already-set test
node[key] = args[0][args[1]][key]; // here, unlike for the defaults: it also skips the Object.prototype
} // member names, and letting message data land a `toString` string on a
}); // node only buys a TypeError the first time something stringifies it.
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
} }
args[0][args[1]] = node; args[0][args[1]] = node;
@@ -140,7 +165,7 @@ class MailMessage {
} }
normalize(callback) { normalize(callback) {
const envelope = this.data.envelope || this.message.getEnvelope(); const envelope = this.message.getEnvelope();
const messageId = this.message.messageId(); const messageId = this.message.messageId();
this.resolveAll((err, data) => { this.resolveAll((err, data) => {
@@ -186,6 +211,9 @@ class MailMessage {
data.normalizedHeaders = {}; data.normalizedHeaders = {};
Object.keys(data.headers || {}).forEach(key => { Object.keys(data.headers || {}).forEach(key => {
if (shared.isProtoKey(key)) {
return;
}
let value = [].concat(data.headers[key] || []).shift(); let value = [].concat(data.headers[key] || []).shift();
value = (value && value.value) || value; value = (value && value.value) || value;
if (value) { if (value) {
@@ -271,15 +299,16 @@ class MailMessage {
} }
if (value && value.url) { if (value && value.url) {
if (key.toLowerCase().trim() === 'id') { // strip CR/LF so a comment can't inject extra header lines. DEL is neither
// List-ID: "comment" <domain> // qtext nor ctext, so it can not be carried literally by either construct
// strip CR/LF so a comment can't inject extra header lines // and has to become an encoded word like any other non-plaintext value
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' '); let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
if (mimeFuncs.isPlainText(comment)) { const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
comment = '"' + comment + '"';
} else { if (key.toLowerCase().trim() === 'id') {
comment = mimeFuncs.encodeWord(comment); // List-ID: "comment" <domain>, where an unescaped quote or a trailing
} // backslash in the comment would swallow the <domain> behind it
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
// List-ID expects a bare domain-like identifier, so strip the // List-ID expects a bare domain-like identifier, so strip the
// scheme prefix that _formatListUrl adds or passes through // scheme prefix that _formatListUrl adds or passes through
@@ -289,11 +318,11 @@ class MailMessage {
} }
// List-*: <http://domain> (comment) // List-*: <http://domain> (comment)
// strip CR/LF so a comment can't inject extra header lines // the ctext specials go out as quoted-pairs, otherwise a ")" closes the
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' '); // comment early and leaves the rest as junk, an unpaired "(" opens a
if (!mimeFuncs.isPlainText(comment)) { // nested comment that never closes, and a trailing backslash escapes
comment = mimeFuncs.encodeWord(comment); // the closing ")" so the comment swallows whatever follows it
} comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : ''); return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
} }
@@ -307,7 +336,9 @@ class MailMessage {
} }
_formatListUrl(url) { _formatListUrl(url) {
url = url.replace(/[\s<]+|[\s>]+/g, ''); // a url has no way to carry a control char or DEL, and the angle brackets around it
// are not a quoting construct, so anything left here lands in the header raw
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
if (/^(https?|mailto|ftp):/.test(url)) { if (/^(https?|mailto|ftp):/.test(url)) {
return '<' + url + '>'; return '<' + url + '>';
} }
+80 -19
View File
@@ -5,19 +5,39 @@
const base64 = require('../base64'); const base64 = require('../base64');
const qp = require('../qp'); const qp = require('../qp');
const mimeTypes = require('./mime-types'); const mimeTypes = require('./mime-types');
const { isProtoKey } = require('../shared/objects');
module.exports = { module.exports = {
/** /**
* Checks if a value is plaintext string (uses only printable 7bit chars) * Checks if a value is plaintext string (uses only printable 7bit chars)
* *
* When isParam is set the value is destined for a header parameter, so HT, CR and LF
* are not plaintext either: a header parameter has no way to carry them. HT is a valid
* fold point, so folding and unfolding a header would rewrite it as a space, and CR/LF
* cannot appear in a header value at all. DEL is neither a token character nor qtext,
* so it can not be carried bare or quoted. Such values have to go through the rfc2231
* parameter continuation encoding instead, the same way a quote already does.
*
* @param {String} value String to be tested * @param {String} value String to be tested
* @param {Boolean} [isParam] Set to true if the value is a header parameter value
* @returns {Boolean} true if it is a plaintext string * @returns {Boolean} true if it is a plaintext string
*/ */
isPlainText(value, isParam) { isPlainText(value, isParam) {
const re = isParam ? /[\x00-\x08\x0b\x0c\x0e-\x1f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/; const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
return typeof value === 'string' && !re.test(value); return typeof value === 'string' && !re.test(value);
}, },
/**
* Wraps a value into a quoted-string. Inside one a quote would end the string early
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
*
* @param {String} value String to be quoted
* @returns {String} The value as a quoted-string, quotes included
*/
quoteString(value) {
return '"' + (value || '').toString().replace(/["\\]/g, '\\$&') + '"';
},
/** /**
* Checks if a multi line string containes lines longer than the selected value. * Checks if a multi line string containes lines longer than the selected value.
* *
@@ -80,8 +100,10 @@ module.exports = {
for (let i = 0, len = encodedStr.length; i < len; i++) { for (let i = 0, len = encodedStr.length; i < len; i++) {
let chr = encodedStr.charAt(i); let chr = encodedStr.charAt(i);
if (/[\ud83c\ud83d\ud83e]/.test(chr) && i < len - 1) { if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
// composite emoji byte, so add the next byte as well // leading surrogate, so add the trailing surrogate as well
// an unpaired one must not swallow the next unit, that would destroy
// a valid pair following it
chr += encodedStr.charAt(++i); chr += encodedStr.charAt(++i);
} }
@@ -168,10 +190,12 @@ module.exports = {
buildHeaderValue(structured) { buildHeaderValue(structured) {
const paramsArray = []; const paramsArray = [];
Object.keys(structured.params || {}).forEach(param => { Object.keys(structured.params || {}).forEach(key => {
// filename might include unicode characters so it is a special case // filename might include unicode characters so it is a special case
// other values probably do not // other values probably do not
const value = structured.params[param]; const value = structured.params[key];
// a parameter name is a token too and it is emitted without any quoting around it
const param = key.replace(/[\x00-\x1f\x7f]/g, '');
if (!this.isPlainText(value, true) || value.length >= 75) { if (!this.isPlainText(value, true) || value.length >= 75) {
this.buildHeaderParam(param, value, 50).forEach(encodedParam => { this.buildHeaderParam(param, value, 50).forEach(encodedParam => {
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') { if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
@@ -187,7 +211,11 @@ module.exports = {
} }
}); });
return structured.value + (paramsArray.length ? '; ' + paramsArray.join('; ') : ''); // the value ahead of the parameters is a token, it has no way to carry a control
// char or DEL and there is no quoting construct around it to escape one into
const value = typeof structured.value === 'string' ? structured.value.replace(/[\x00-\x1f\x7f]/g, '') : structured.value;
return value + (paramsArray.length ? '; ' + paramsArray.join('; ') : '');
}, },
/** /**
@@ -208,7 +236,7 @@ module.exports = {
buildHeaderParam(key, data, maxLength) { buildHeaderParam(key, data, maxLength) {
const list = []; const list = [];
let encodedStr = typeof data === 'string' ? data : (data || '').toString(); let encodedStr = typeof data === 'string' ? data : (data || '').toString();
let chr, ord; let chr;
let line; let line;
let startPos = 0; let startPos = 0;
let i, len; let i, len;
@@ -245,8 +273,9 @@ module.exports = {
const encodedStrArr = []; const encodedStrArr = [];
for (i = 0, len = encodedStr.length; i < len; i++) { for (i = 0, len = encodedStr.length; i < len; i++) {
chr = encodedStr.charAt(i); chr = encodedStr.charAt(i);
ord = chr.charCodeAt(0); if (/[\ud800-\udbff]/.test(chr) && /[\udc00-\udfff]/.test(encodedStr.charAt(i + 1))) {
if (ord >= 0xd800 && ord <= 0xdbff && i < len - 1) { // an unpaired leading surrogate must not consume the next unit, that
// would tear apart a valid pair following it
chr += encodedStr.charAt(i + 1); chr += encodedStr.charAt(i + 1);
encodedStrArr.push(chr); encodedStrArr.push(chr);
i++; i++;
@@ -284,8 +313,11 @@ module.exports = {
line, line,
encoded encoded
}); });
// the line we start here holds an encoded char, so it has to be
// flagged as one. otherwise it gets an unstarred continuation key
// and a receiver reads the percent escapes as literal text
line = ''; line = '';
startPos = i - 1; encoded = true;
} else { } else {
encoded = true; encoded = true;
i = startPos; i = startPos;
@@ -350,6 +382,16 @@ module.exports = {
value: false, value: false,
params: {} params: {}
}; };
// Parameter names come from a caller supplied contentType/contentDisposition. A
// "__proto__" name would target the prototype chain of the params object instead of
// an own property of it, and read back as Object.prototype, so it is dropped.
const setParam = (name, value) => {
if (!isProtoKey(name)) {
response.params[name] = value;
}
};
let key = false; let key = false;
let value = ''; let value = '';
let type = 'value'; let type = 'value';
@@ -381,7 +423,7 @@ module.exports = {
if (key === false) { if (key === false) {
response.value = value.trim(); response.value = value.trim();
} else { } else {
response.params[key] = value.trim(); setParam(key, value.trim());
} }
type = 'key'; type = 'key';
value = ''; value = '';
@@ -396,10 +438,10 @@ module.exports = {
if (key === false) { if (key === false) {
response.value = value.trim(); response.value = value.trim();
} else { } else {
response.params[key] = value.trim(); setParam(key, value.trim());
} }
} else if (value.trim()) { } else if (value.trim()) {
response.params[value.trim().toLowerCase()] = ''; setParam(value.trim().toLowerCase(), '');
} }
// handle parameter value continuations // handle parameter value continuations
@@ -412,6 +454,14 @@ module.exports = {
actualKey = key.substr(0, match.index); actualKey = key.substr(0, match.index);
nr = Number(match[2] || match[3]) || 0; nr = Number(match[2] || match[3]) || 0;
if (isProtoKey(actualKey)) {
// see setParam. Reading it back would yield Object.prototype, which is
// an object, so the initializer below would be skipped and the write
// that follows would throw out of a header build the caller can not catch
delete response.params[key];
return;
}
if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') { if (!response.params[actualKey] || typeof response.params[actualKey] !== 'object') {
response.params[actualKey] = { response.params[actualKey] = {
charset: false, charset: false,
@@ -538,7 +588,7 @@ module.exports = {
*/ */
splitMimeEncodedString: (str, maxlen) => { splitMimeEncodedString: (str, maxlen) => {
const lines = []; const lines = [];
let curLine, match, chr, done; let curLine, fallbackLine, match, chr, done;
// require at least 12 symbols to fit possible 4 octet UTF-8 sequences // require at least 12 symbols to fit possible 4 octet UTF-8 sequences
maxlen = Math.max(maxlen || 0, 12); maxlen = Math.max(maxlen || 0, 12);
@@ -551,8 +601,14 @@ module.exports = {
curLine = curLine.substr(0, match.index); curLine = curLine.substr(0, match.index);
} }
// Malformed input (a run of stray UTF-8 continuation bytes) has no split point
// that keeps a character sequence whole, so the loop below walks back to an
// empty line looking for one. Keep the widest chunk that at least does not cut
// a "=XX" escape in half, so the part stays a decodable encoded word.
fallbackLine = curLine.length ? curLine : str.substr(0, maxlen);
done = false; done = false;
while (!done) { while (!done && curLine.length) {
done = true; done = true;
// check if not middle of a unicode char sequence // check if not middle of a unicode char sequence
if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) { if ((match = str.substr(curLine.length).match(/^[=]([0-9A-F]{2})/i))) {
@@ -565,9 +621,11 @@ module.exports = {
} }
} }
if (curLine.length) { if (!curLine.length) {
lines.push(curLine); curLine = fallbackLine;
} }
lines.push(curLine);
str = str.substr(curLine.length); str = str.substr(curLine.length);
} }
@@ -600,8 +658,11 @@ module.exports = {
// might throw if we try to encode invalid sequences, eg. partial emoji // might throw if we try to encode invalid sequences, eg. partial emoji
str = encodeURIComponent(str); str = encodeURIComponent(str);
} catch (_E) { } catch (_E) {
// should never run // an unpaired surrogate has no utf-8 representation, so run the value through a
return str.replace(/[^\x00-\x1F *'()<>@,;:\\"[\]?=\u007F-\uFFFF]+/g, ''); // utf-8 roundtrip to get the same U+FFFD every other encoder here produces and
// retry. the value must never come back unencoded, it goes into a header parameter
// where a bare quote or semicolon would break it out into a parameter of its own
str = encodeURIComponent(Buffer.from(str, 'utf-8').toString('utf-8'));
} }
// ensure chars that are not handled by encodeURICompent are converted as well // ensure chars that are not handled by encodeURICompent are converted as well
+287 -59
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const punycode = require('../punycode'); const punycode = require('../punycode');
const { PassThrough } = require('stream'); const { PassThrough } = require('stream');
const shared = require('../shared'); const shared = require('../shared');
const urlModule = require('url');
const mimeFuncs = require('../mime-funcs'); const mimeFuncs = require('../mime-funcs');
const qp = require('../qp'); const qp = require('../qp');
@@ -21,6 +22,59 @@ const LeUnix = require('./le-unix');
const FORMATTED_HEADERS = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References']; const FORMATTED_HEADERS = ['From', 'Sender', 'To', 'Cc', 'Bcc', 'Reply-To', 'Date', 'References'];
// RFC 5321 atext, plus the non-ascii bytes that SMTPUTF8 (RFC 6531) adds to it. A local part
// built from these, with '.' as a separator, is a dot-atom and can be emitted bare
const ATEXT = "[A-Za-z0-9!#$%&'*+\\-/=?^_`{|}~\\x80-\\uFFFF]";
const DOT_ATOM = new RegExp('^' + ATEXT + '+(?:\\.' + ATEXT + '+)*$');
// A complete quoted-string: everything between the outer quotes is either a plain char or
// a quoted-pair. Anchored, so a value that only starts and ends with a quote does not pass
const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
// An address that carries no special anywhere can be emitted bare in a header, everything
// else goes into angle brackets so that the header can not be read as more addresses than
// the envelope carries
const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
// domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
// mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
// controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
// deliverable 'evil.example', which would turn a value the bundled codec leaves as
// unroutable garbage into mail for a domain the sender never named. None of these
// characters are legal in a domain, so keep them away from the mapper.
const URL_PARSER_UNSAFE = /[/\\?#%\x00-\x20\x7F]/;
/**
* Encodes a domain the way browsers, the WHATWG URL Standard and DNS facing resolvers do,
* which is with UTS-46 mapping applied before the Punycode step.
*
* The bundled codec is plain RFC 3492 and maps nothing, so it disagrees with every
* conformant parser on any domain holding a mapped or ignored code point. An invisible
* U+00AD in 'compa\u00ADny.com' encoded to 'xn--company-pka.com' where a validator reads
* 'company.com', which let an allow-listed domain be checked and a different one mailed.
*
* Anything the URL parser does not accept as a hostname, an address literal such as
* '[127.0.0.1]' included, comes back empty and falls through to the bundled codec, which
* leaves those as they were supplied.
*
* @param {String} domain Domain to encode, already lowercased by the caller
* @param {Boolean} toUnicode Return the U-label form instead of the A-label form
* @return {String} Encoded domain
*/
function normalizeDomain(domain, toUnicode) {
// domainToASCII and domainToUnicode landed in Node 7, the bundled codec covers Node 6
const mapper = toUnicode ? urlModule.domainToUnicode : urlModule.domainToASCII;
if (typeof mapper === 'function' && !URL_PARSER_UNSAFE.test(domain)) {
const mapped = mapper(domain);
if (mapped) {
return mapped;
}
}
return toUnicode ? punycode.toUnicode(domain) : punycode.toASCII(domain);
}
/** /**
* Creates a new mime tree node. Assumes 'multipart/*' as the content type * Creates a new mime tree node. Assumes 'multipart/*' as the content type
* if it is a branch, anything else counts as leaf. If rootNode is missing from * if it is a branch, anything else counts as leaf. If rootNode is missing from
@@ -190,6 +244,14 @@ class MimeNode {
* @return {Object} Appended node object * @return {Object} Appended node object
*/ */
appendChild(childNode) { appendChild(childNode) {
// Take the node out of the tree it is in first. Leaving it there keeps it in that
// parent's childNodes, so it still streams as part of the old tree while parentNode
// already points at the new one, and anything read off the parent chain answers for
// the wrong tree.
if (childNode.parentNode && childNode.parentNode !== this) {
childNode.remove();
}
if (childNode.rootNode !== this.rootNode) { if (childNode.rootNode !== this.rootNode) {
childNode.rootNode = this.rootNode; childNode.rootNode = this.rootNode;
childNode._nodeId = ++this.rootNode.nodeCounter; childNode._nodeId = ++this.rootNode.nodeCounter;
@@ -519,11 +581,10 @@ class MimeNode {
const formattedHeaders = FORMATTED_HEADERS; const formattedHeaders = FORMATTED_HEADERS;
if (value && typeof value === 'object' && !formattedHeaders.includes(key)) { if (value && typeof value === 'object' && !formattedHeaders.includes(key)) {
Object.keys(value).forEach(key => { // the keys come from a caller supplied header object and `options.prepared`
if (key !== 'value') { // below decides whether the value is emitted raw, so an own "__proto__" key
options[key] = value[key]; // here would turn an unfolded value into header injection
} shared.copyOwnKeys(options, value, optionKey => optionKey === 'value');
});
value = (value.value || '').toString(); value = (value.value || '').toString();
if (!value.trim()) { if (!value.trim()) {
return; return;
@@ -552,6 +613,11 @@ class MimeNode {
case 'Content-Type': case 'Content-Type':
structured = mimeFuncs.parseHeaderValue(value); structured = mimeFuncs.parseHeaderValue(value);
// the type token decides multipart and charset below, so clean it before
// those run and not just on the way out, otherwise a control char makes
// the checks miss and the header ends up claiming a type it is not set up for
structured.value = (structured.value || '').toString().replace(/[\x00-\x1f\x7f]/g, '');
this._handleContentType(structured); this._handleContentType(structured);
if ( if (
@@ -568,11 +634,18 @@ class MimeNode {
// add support for non-compliant clients like QQ webmail // add support for non-compliant clients like QQ webmail
// we can't build the value with buildHeaderValue as the value is non standard and // we can't build the value with buildHeaderValue as the value is non standard and
// would be converted to parameter continuation encoding that we do not want // would be converted to parameter continuation encoding that we do not want
param = this._encodeWords(this.filename); // control chars can not be quoted here: HT is a fold point that unfolding would
// turn into a space, CR/LF can not appear in a header at all and DEL is not
// qtext, so force the mime encoded word that a non-ascii filename would get anyway
param = /[\x00-\x1f\x7f]/.test(this.filename)
? mimeFuncs.encodeWord(this.filename, this._getTextEncoding(this.filename), 52)
: this._encodeWords(this.filename);
if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) { if (param !== this.filename || /[\s'"\\;:/=(),<>@[\]?]|^-/.test(param)) {
// include value in quotes if needed // include value in quotes if needed, escaping backslashes and quotes as
param = '"' + param + '"'; // quoted-pairs exactly like buildHeaderValue does for filename=, otherwise
// a trailing backslash would escape the closing quote
param = JSON.stringify(param);
} }
value += '; name=' + param; value += '; name=' + param;
} }
@@ -595,8 +668,12 @@ class MimeNode {
if (typeof this.normalizeHeaderKey === 'function') { if (typeof this.normalizeHeaderKey === 'function') {
const normalized = this.normalizeHeaderKey(key, value); const normalized = this.normalizeHeaderKey(key, value);
if (normalized && typeof normalized === 'string' && normalized.length) { // the result replaces the key on the way into the header, so it gets the same
key = normalized; // treatment the key it replaces already had. a line break here would end the
// header and start one of the caller's own
const cleaned = typeof normalized === 'string' ? normalized.replace(/[\x00-\x1f\x7f]/g, '') : '';
if (cleaned) {
key = cleaned;
} }
} }
@@ -837,26 +914,23 @@ class MimeNode {
if (envelope.from) { if (envelope.from) {
list = []; list = [];
this._convertAddresses(this._parseAddresses(envelope.from), list); this._convertAddresses(this._parseEnvelopeAddresses(envelope.from), list);
list = list.filter(address => address && address.address); list = list.filter(address => address && address.address);
if (list.length && list[0]) { if (list.length && list[0]) {
this._envelope.from = list[0].address; this._envelope.from = list[0].address;
} }
} }
const seenRecipients = new Set();
['to', 'cc', 'bcc'].forEach(key => { ['to', 'cc', 'bcc'].forEach(key => {
if (envelope[key]) { if (envelope[key]) {
this._convertAddresses(this._parseAddresses(envelope[key]), this._envelope.to); this._convertAddresses(this._parseEnvelopeAddresses(envelope[key]), this._envelope.to, seenRecipients);
} }
}); });
this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address); this._envelope.to = this._envelope.to.map(to => to.address).filter(address => address);
const standardFields = ['to', 'cc', 'bcc', 'from']; const standardFields = ['to', 'cc', 'bcc', 'from'];
Object.keys(envelope).forEach(key => { shared.copyOwnKeys(this._envelope, envelope, key => standardFields.includes(key));
if (!standardFields.includes(key)) {
this._envelope[key] = envelope[key];
}
});
return this; return this;
} }
@@ -868,15 +942,17 @@ class MimeNode {
*/ */
getAddresses() { getAddresses() {
const addresses = {}; const addresses = {};
const seenByKey = new Map();
this._headers.forEach(header => { this._headers.forEach(header => {
const key = header.key.toLowerCase(); const key = header.key.toLowerCase();
if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) { if (['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].includes(key)) {
if (!Array.isArray(addresses[key])) { if (!Array.isArray(addresses[key])) {
addresses[key] = []; addresses[key] = [];
seenByKey.set(key, new Set());
} }
this._convertAddresses(this._parseAddresses(header.value), addresses[key]); this._convertAddresses(this._parseAddresses(header.value), addresses[key], seenByKey.get(key));
} }
}); });
@@ -897,6 +973,12 @@ class MimeNode {
from: false, from: false,
to: [] to: []
}; };
// Built once and carried across the headers. Letting _convertAddresses seed it per
// call would cost O(headers x recipients), and a message can carry many address
// headers: `headers: { to: [...] }` emits one To per entry.
const seenRecipients = new Set();
this._headers.forEach(header => { this._headers.forEach(header => {
const list = []; const list = [];
if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) { if (header.key === 'From' || (!envelope.from && ['Reply-To', 'Sender'].includes(header.key))) {
@@ -905,7 +987,7 @@ class MimeNode {
envelope.from = list[0].address; envelope.from = list[0].address;
} }
} else if (['To', 'Cc', 'Bcc'].includes(header.key)) { } else if (['To', 'Cc', 'Bcc'].includes(header.key)) {
this._convertAddresses(this._parseAddresses(header.value), envelope.to); this._convertAddresses(this._parseAddresses(header.value), envelope.to, seenRecipients);
} }
}); });
@@ -952,6 +1034,26 @@ class MimeNode {
/////// PRIVATE METHODS /////// PRIVATE METHODS
/**
* Checks an access policy flag for this node and every node above it. The flags are set
* from the options the node was built with, and createChild only ever sees the options
* the caller passed, so a child of a closed tree starts out open. Reading the answer off
* the parent chain keeps it right whatever order the tree was assembled in.
*
* @param {String} flag Either 'disableFileAccess' or 'disableUrlAccess'
* @return {Boolean} true if this node or an ancestor closed that access
*/
_accessDisabled(flag) {
let node = this;
while (node) {
if (node[flag]) {
return true;
}
node = node.parentNode;
}
return false;
}
/** /**
* Detects and returns handle to a stream related with the content. * Detects and returns handle to a stream related with the content.
* *
@@ -982,7 +1084,7 @@ class MimeNode {
} }
if (content && typeof content.path === 'string' && !content.href) { if (content && typeof content.path === 'string' && !content.href) {
if (this.disableFileAccess) { if (this._accessDisabled('disableFileAccess')) {
contentStream = new PassThrough(); contentStream = new PassThrough();
setImmediate(() => { setImmediate(() => {
const err = new Error('File access rejected for ' + content.path); const err = new Error('File access rejected for ' + content.path);
@@ -996,7 +1098,7 @@ class MimeNode {
} }
if (content && typeof content.href === 'string') { if (content && typeof content.href === 'string') {
if (this.disableUrlAccess) { if (this._accessDisabled('disableUrlAccess')) {
contentStream = new PassThrough(); contentStream = new PassThrough();
setImmediate(() => { setImmediate(() => {
const err = new Error('Url access rejected for ' + content.href); const err = new Error('Url access rejected for ' + content.href);
@@ -1005,7 +1107,9 @@ class MimeNode {
}); });
return contentStream; return contentStream;
} }
// fetch URL // fetch URL. nmfetch refuses any scheme that is not http(s), and it decides
// that on the parsed URL. Testing the raw string here instead would reject
// forms the parser accepts, such as a leading space or a slash-less authority
return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls }); return nmfetch(content.href, { headers: content.httpHeaders, tls: content.tls });
} }
@@ -1030,17 +1134,81 @@ class MimeNode {
* @return {Array} An array of address objects * @return {Array} An array of address objects
*/ */
_parseAddresses(addresses) { _parseAddresses(addresses) {
return [].concat.apply( // Collected into one list as we go. concat.apply spreads the entries into arguments
[], // and throws a RangeError once a recipient array is long enough to pass the
[].concat(addresses).map(address => { // argument limit, which a large Bcc list reaches on its own.
const flattened = [];
[].concat(addresses).forEach(address => {
if (address && address.address) { if (address && address.address) {
address.address = this._normalizeAddress(address.address); const normalized = this._normalizeAddress(address.address);
address.name = address.name || ''; if (normalized === address.address && typeof address.name === 'string') {
return [address]; // there is nothing to rewrite, so there is nothing to keep off the original
flattened.push(address);
return;
} }
return addressparser(address);
}) // rewriting would land on the object the caller passed in and might
); // still hold a reference to, so rewrite a copy of it instead. An own
// "__proto__" key would make the copy inherit from caller data, and
// _convertAddresses reads `group` off it straight into the envelope
const copy = shared.copyOwnKeys({}, address);
copy.address = normalized;
copy.name = address.name || '';
flattened.push(copy);
return;
}
const parsed = this._normalizeParsedAddresses(addressparser(address));
for (let i = 0; i < parsed.length; i++) {
flattened.push(parsed[i]);
}
});
return flattened;
}
/**
* Normalizes the addresses of a freshly parsed address list, groups included.
*
* Everything this method returns carries a normalized address, whether it arrived as an
* object or was parsed out of a header value. Without this the two shapes disagree, and
* a consumer reading the parsed form back is handed the ambiguous
* 'user@evil.com@good.com' that the header and the envelope no longer carry.
*
* @param {Array} parsed An array of address objects, as returned by addressparser
* @return {Array} The same array, with every address normalized
*/
_normalizeParsedAddresses(parsed) {
// addressparser builds these objects, so no caller holds a reference to rewrite around
parsed.forEach(entry => {
if (entry.address) {
entry.address = this._normalizeAddress(entry.address);
} else if (entry.group) {
this._normalizeParsedAddresses(entry.group);
}
});
return parsed;
}
/**
* Parses the addresses of an explicitly set envelope.
*
* An envelope value is an addr-spec and never a display name, so a bare local username
* such as 'root' is the address here. Header parsing has to read the same value as a
* display name, as a value with no '@' in it can not be an addr-spec in a header.
*
* @param {Mixed} addresses Addresses to be parsed
* @return {Array} An array of address objects
*/
_parseEnvelopeAddresses(addresses) {
return this._parseAddresses(addresses).map(entry => {
if (entry.address || entry.group || !entry.name || /[\s@]/.test(entry.name)) {
return entry;
}
return { address: this._normalizeAddress(entry.name), name: '' };
});
} }
/** /**
@@ -1054,6 +1222,9 @@ class MimeNode {
.toString() .toString()
// no newlines in keys // no newlines in keys
.replace(/\r?\n|\r/g, ' ') .replace(/\r?\n|\r/g, ' ')
// a field name is printable ascii without the colon, so a control char or DEL
// can only be dropped, there is no quoting construct around a field name
.replace(/[\x00-\x1f\x7f]/g, '')
.trim() .trim()
.toLowerCase() .toLowerCase()
// use uppercase words, except MIME // use uppercase words, except MIME
@@ -1114,7 +1285,13 @@ class MimeNode {
case 'Message-ID': case 'Message-ID':
case 'In-Reply-To': case 'In-Reply-To':
case 'Content-Id': case 'Content-Id':
value = (value || '').toString().replace(/\r?\n|\r/g, ' '); // a msg-id is structured, so an encoded word inside the angle brackets would
// be read as literal text. drop the characters that can not appear in a header
// at all, but leave HT alone, it separates the ids of a multi id value
value = (value || '')
.toString()
.replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
if (value.charAt(0) !== '<') { if (value.charAt(0) !== '<') {
value = '<' + value; value = '<' + value;
@@ -1134,6 +1311,7 @@ class MimeNode {
elm = (elm || '') elm = (elm || '')
.toString() .toString()
.replace(/\r?\n|\r/g, ' ') .replace(/\r?\n|\r/g, ' ')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.trim(); .trim();
return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/); return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
}) })
@@ -1156,7 +1334,7 @@ class MimeNode {
} }
value = (value || '').toString().replace(/\r?\n|\r/g, ' '); value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
return this._encodeWords(value); return this._encodeHeaderText(value);
case 'Content-Type': case 'Content-Type':
case 'Content-Disposition': case 'Content-Disposition':
@@ -1165,8 +1343,7 @@ class MimeNode {
default: default:
value = (value || '').toString().replace(/\r?\n|\r/g, ' '); value = (value || '').toString().replace(/\r?\n|\r/g, ' ');
// encodeWords only encodes if needed, otherwise the original string is returned return this._encodeHeaderText(value);
return this._encodeWords(value);
} }
} }
@@ -1177,26 +1354,44 @@ class MimeNode {
* @param {Array} [uniqueList] An array to be populated with addresses * @param {Array} [uniqueList] An array to be populated with addresses
* @return {String} address string * @return {String} address string
*/ */
_convertAddresses(addresses, uniqueList) { _convertAddresses(addresses, uniqueList, seenAddresses) {
const values = []; const values = [];
uniqueList = uniqueList || []; uniqueList = uniqueList || [];
// Membership is checked once per address, so scanning uniqueList itself would make
// a recipient list cost O(n^2). Groups recurse with the same set so that a nested
// group still dedupes against the addresses collected around it, and a caller that
// passes a partly filled list (To, then Cc, then Bcc) keeps deduping across headers.
if (!seenAddresses) {
seenAddresses = new Set();
for (let i = 0; i < uniqueList.length; i++) {
seenAddresses.add(uniqueList[i].address);
}
}
[].concat(addresses || []).forEach(address => { [].concat(addresses || []).forEach(address => {
if (address.address) { if (address.address) {
address.address = this._normalizeAddress(address.address); address.address = this._normalizeAddress(address.address);
if (!address.name) { if (!address.name) {
values.push(address.address.indexOf(' ') >= 0 ? `<${address.address}>` : `${address.address}`); // an address that carries a special, be it a quoted local part or a domain
// that could not be normalized, is only unambiguous inside angle brackets.
// Without them a ',' or a ';' anywhere in it reads as a recipient separator
// and the header would list more recipients than the envelope carries
values.push(PLAIN_ADDRESS.test(address.address) ? address.address : `<${address.address}>`);
} else { } else {
values.push(`${this._encodeAddressName(address.name)} <${address.address}>`); values.push(`${this._encodeAddressName(address.name)} <${address.address}>`);
} }
if (!uniqueList.some(a => a.address === address.address)) { if (!seenAddresses.has(address.address)) {
seenAddresses.add(address.address);
uniqueList.push(address); uniqueList.push(address);
} }
} else if (address.group) { } else if (address.group) {
const groupListAddresses = (address.group.length ? this._convertAddresses(address.group, uniqueList) : '').trim(); const groupListAddresses = (
address.group.length ? this._convertAddresses(address.group, uniqueList, seenAddresses) : ''
).trim();
values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`); values.push(`${this._encodeAddressName(address.name)}:${groupListAddresses};`);
} }
}); });
@@ -1213,45 +1408,63 @@ class MimeNode {
_normalizeAddress(address) { _normalizeAddress(address) {
address = (address || '') address = (address || '')
.toString() .toString()
.replace(/[\x00-\x1F<>]+/g, ' ') // remove unallowed characters .replace(/[\x00-\x1F\x7F<>]+/g, ' ') // remove unallowed characters
.trim(); .trim();
const lastAt = address.lastIndexOf('@'); if (!address) {
if (lastAt < 0) { // callers use an empty value to detect a missing address
// Bare username
return address; return address;
} }
let user = address.substr(0, lastAt); const lastAt = address.lastIndexOf('@');
if (lastAt < 0) {
// Bare username, there is no domain to split off
return this._normalizeLocalPart(address);
}
const user = address.substr(0, lastAt);
const domain = address.substr(lastAt + 1); const domain = address.substr(lastAt + 1);
// Usernames are not touched and are kept as is even if these include unicode. // Unicode in the local part is kept as is, see _normalizeLocalPart for the rest of it.
// A domain has no quoting construct to fall back on, so whatever is not a valid domain
// is kept as supplied and it is _convertAddresses that keeps such an address unambiguous.
// Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee'). // Domains are punycoded when the local part is ASCII ('safe@jõgeva.ee' -> 'safe@xn--jgeva-dua.ee').
// When the local part contains non-ASCII bytes the address already requires SMTPUTF8, // When the local part contains non-ASCII bytes the address already requires SMTPUTF8,
// so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'. // so the domain is kept (or decoded back) as UTF-8 for symmetry on both sides of '@'.
let encodedDomain = domain; let encodedDomain = domain;
// A non-ASCII local part already requires SMTPUTF8, so the domain stays UTF-8 for
// symmetry on both sides of the '@' rather than being encoded to an A-label
const smtputf8 = /[\x80-\uFFFF]/.test(user);
try { try {
if (/[\x80-\uFFFF]/.test(user)) { encodedDomain = normalizeDomain(domain.toLowerCase(), smtputf8);
encodedDomain = punycode.toUnicode(domain.toLowerCase());
} else {
encodedDomain = punycode.toASCII(domain.toLowerCase());
}
} catch (_err) { } catch (_err) {
// keep domain as supplied // keep domain as supplied
} }
if (user.indexOf(' ') >= 0) { return `${this._normalizeLocalPart(user)}@${encodedDomain}`;
if (user.charAt(0) !== '"') {
user = '"' + user;
}
if (user.substr(-1) !== '"') {
user = user + '"';
}
} }
return `${user}@${encodedDomain}`; /**
* Normalizes the local part of an address into a form that can be emitted as is.
*
* A local part is either a dot-atom or a quoted-string, anything else is not a valid
* addr-spec. The quotes of a quoted local part get lost along the way, and a bare
* 'user@evil.com@good.com' leaves it to the receiver which '@' splits the domain off,
* while the split here is always at the last one. So whatever is not already one of
* the two valid forms goes back out as a quoted-string.
*
* @param {String} user Local part of an address
* @return {String} Local part as a dot-atom or as a quoted-string
*/
_normalizeLocalPart(user) {
if (DOT_ATOM.test(user) || QUOTED_STRING.test(user)) {
return user;
}
return mimeFuncs.quoteString(user);
} }
/** /**
@@ -1263,7 +1476,7 @@ class MimeNode {
_encodeAddressName(name) { _encodeAddressName(name) {
if (!/^[\w ]*$/.test(name)) { if (!/^[\w ]*$/.test(name)) {
if (/^[\x20-\x7e]*$/.test(name)) { if (/^[\x20-\x7e]*$/.test(name)) {
return '"' + name.replace(/([\\"])/g, '\\$1') + '"'; return mimeFuncs.quoteString(name);
} else { } else {
return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52); return mimeFuncs.encodeWord(name, this._getTextEncoding(name), 52);
} }
@@ -1271,6 +1484,21 @@ class MimeNode {
return name; return name;
} }
/**
* Encodes an unstructured header value. Such a value can only carry VCHAR and WSP, so a
* control char or DEL has to be forced into the mime encoded word that a non-ascii value
* would get anyway. HT stays as it is, it is valid folding whitespace here.
*
* @param {String} value Header value to encode
* @returns {String} Mime word encoded string if needed
*/
_encodeHeaderText(value) {
return /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
? mimeFuncs.encodeWord(value, this._getTextEncoding(value), 52)
: // encodeWords only encodes if needed, otherwise the original string is returned
this._encodeWords(value);
}
/** /**
* If needed, mime encodes the name part * If needed, mime encodes the name part
* *
+1 -1
View File
@@ -250,7 +250,7 @@ const decode = function (input) {
// Main decoding loop: start just after the last delimiter if any basic code // Main decoding loop: start just after the last delimiter if any basic code
// points were copied; start at the beginning otherwise. // points were copied; start at the beginning otherwise.
for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */; ) { for (let index = basic > 0 ? basic + 1 : 0; index < inputLength /* no final expression */;) {
// `index` is the index of the next character to be consumed. // `index` is the index of the next character to be consumed.
// Decode a generalized variable-length integer into `delta`, // Decode a generalized variable-length integer into `delta`,
// which gets added to `i`. The overflow checking is easier // which gets added to `i`. The overflow checking is easier
+5 -2
View File
@@ -62,14 +62,17 @@ class SendmailTransport {
// Sendmail strips this header line by itself // Sendmail strips this header line by itself
mail.message.keepBcc = true; mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope(); const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId(); const messageId = mail.message.messageId();
let returned; let returned;
const hasInvalidAddresses = [] const hasInvalidAddresses = []
.concat(envelope.from || []) .concat(envelope.from || [])
.concat(envelope.to || []) .concat(envelope.to || [])
.some(addr => /^-/.test(addr)); // a local part is either a dot-atom or a quoted-string, so a leading dash sits at
// offset 0 or, behind the opening quote, at offset 1. Only the first shape is read
// as an option by sendmail, but both are the address this guard keeps out of argv
.some(addr => /^"?-/.test(addr));
if (hasInvalidAddresses) { if (hasInvalidAddresses) {
const err = new Error('Can not send mail. Invalid envelope addresses.'); const err = new Error('Can not send mail. Invalid envelope addresses.');
err.code = errors.ESENDMAIL; err.code = errors.ESENDMAIL;
+45 -14
View File
@@ -43,11 +43,12 @@ class SESTransport extends EventEmitter {
getRegion(cb) { getRegion(cb) {
if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') { if (this.ses.sesClient.config && typeof this.ses.sesClient.config.region === 'function') {
// promise // Resolve the region provider. Use the two-argument form of then() so that a
return this.ses.sesClient.config // synchronous throw from cb is not recaught here and used to invoke cb a second time.
.region() return this.ses.sesClient.config.region().then(
.then(region => cb(null, region)) region => cb(null, region),
.catch(err => cb(err)); err => cb(err)
);
} }
return cb(null, false); return cb(null, false);
} }
@@ -65,7 +66,7 @@ class SESTransport extends EventEmitter {
fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value)); fromHeader = mimeNode._convertAddresses(mimeNode._parseAddresses(fromHeader.value));
} }
const envelope = mail.data.envelope || mail.message.getEnvelope(); const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId(); const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []); const recipients = [].concat(envelope.to || []);
@@ -129,7 +130,8 @@ class SESTransport extends EventEmitter {
return callback(err); return callback(err);
} }
const sesMessage = Object.assign( // mail.data.ses is caller supplied message data, so copy its own keys only
const sesMessage = shared.copyOwnKeys(
{ {
Content: { Content: {
Raw: { Raw: {
@@ -142,7 +144,7 @@ class SESTransport extends EventEmitter {
ToAddresses: envelope.to ToAddresses: envelope.to
} }
}, },
mail.data.ses || {} mail.data.ses
); );
this.getRegion((err, region) => { this.getRegion((err, region) => {
@@ -150,8 +152,27 @@ class SESTransport extends EventEmitter {
region = 'us-east-1'; region = 'us-east-1';
} }
let sendPromise;
try {
// command construction or dispatch can throw synchronously on a
// misconfigured SDK; surface it as a single error callback instead
// of letting it escape into getRegion's promise chain
const command = new this.ses.SendEmailCommand(sesMessage); const command = new this.ses.SendEmailCommand(sesMessage);
const sendPromise = this.ses.sesClient.send(command); sendPromise = this.ses.sesClient.send(command);
} catch (err) {
tagSesError(err);
this.logger.error(
{
err,
tnx: 'send'
},
'Send error for %s: %s',
messageId,
err.message
);
setImmediate(() => callback(err));
return;
}
sendPromise sendPromise
.then(data => { .then(data => {
@@ -159,7 +180,7 @@ class SESTransport extends EventEmitter {
region = 'email'; region = 'email';
} }
callback(null, { const info = {
envelope: { envelope: {
from: envelope.from, from: envelope.from,
to: envelope.to to: envelope.to
@@ -167,7 +188,11 @@ class SESTransport extends EventEmitter {
messageId: '<' + data.MessageId + (!/@/.test(data.MessageId) ? '@' + region + '.amazonses.com' : '') + '>', messageId: '<' + data.MessageId + (!/@/.test(data.MessageId) ? '@' + region + '.amazonses.com' : '') + '>',
response: data.MessageId, response: data.MessageId,
raw raw
}); };
// invoke the callback outside the promise chain so a throw from it
// is not recaught by .catch() and used to call it a second time
setImmediate(() => callback(null, info));
}) })
.catch(err => { .catch(err => {
tagSesError(err); tagSesError(err);
@@ -180,7 +205,7 @@ class SESTransport extends EventEmitter {
messageId, messageId,
err.message err.message
); );
callback(err); setImmediate(() => callback(err));
}); });
}); });
}) })
@@ -222,10 +247,16 @@ class SESTransport extends EventEmitter {
// the region value is not used for anything when verifying, but the lookup // the region value is not used for anything when verifying, but the lookup
// exercises the client configuration the same way as send() does // exercises the client configuration the same way as send() does
this.getRegion(() => { this.getRegion(() => {
let sendPromise;
try {
const command = new this.ses.SendEmailCommand(sesMessage); const command = new this.ses.SendEmailCommand(sesMessage);
const sendPromise = this.ses.sesClient.send(command); sendPromise = this.ses.sesClient.send(command);
} catch (err) {
setImmediate(() => cb(err));
return;
}
sendPromise.then(() => cb(null)).catch(err => cb(err)); sendPromise.then(() => setImmediate(() => cb(null))).catch(err => setImmediate(() => cb(err)));
}); });
return promise; return promise;
+29 -13
View File
@@ -7,10 +7,15 @@ const util = require('util');
const fs = require('fs'); const fs = require('fs');
const nmfetch = require('../fetch'); const nmfetch = require('../fetch');
const errors = require('../errors'); const errors = require('../errors');
const objects = require('./objects');
const dns = require('dns'); const dns = require('dns');
const net = require('net'); const net = require('net');
const os = require('os'); const os = require('os');
// re-exported for the callers that already depend on this module, see ./objects
const isProtoKey = (module.exports.isProtoKey = objects.isProtoKey);
module.exports.copyOwnKeys = objects.copyOwnKeys;
const DNS_TTL = 5 * 60 * 1000; const DNS_TTL = 5 * 60 * 1000;
const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups const CACHE_CLEANUP_INTERVAL = 30 * 1000; // Minimum 30 seconds between cleanups
const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache const MAX_CACHE_SIZE = 1000; // Maximum number of entries in cache
@@ -355,7 +360,9 @@ module.exports.parseConnectionUrl = str => {
return; return;
} }
if (!(lKey in obj)) { // `in` already keeps "__proto__" out, but only as a side effect of it being an
// Object.prototype member. Say it, so the protection survives a change to the check
if (!isProtoKey(lKey) && !(lKey in obj)) {
obj[lKey] = value; obj[lKey] = value;
} }
}); });
@@ -470,7 +477,7 @@ module.exports.parseDataURI = uri => {
// Ensure there's a key before the '=' // Ensure there's a key before the '='
const key = entry.substring(0, sepPos).trim(); const key = entry.substring(0, sepPos).trim();
const value = entry.substring(sepPos + 1).trim(); const value = entry.substring(sepPos + 1).trim();
if (key) { if (key && !isProtoKey(key)) {
params[key] = value; params[key] = value;
} }
} }
@@ -561,19 +568,24 @@ function resolveContentValue(data, key, options, callback) {
} }
callback(null, value); callback(null, value);
}); });
} else if (/^https?:\/\//i.test(content.path || content.href)) {
if (options.disableUrlAccess) {
return setImmediate(() => {
const err = new Error('Url access rejected for ' + (content.path || content.href));
err.code = errors.EURLACCESS;
callback(err);
});
}
return resolveStream(nmfetch(content.path || content.href, { headers: content.httpHeaders, tls: content.tls }), callback);
} else if (/^data:/i.test(content.path || content.href)) { } else if (/^data:/i.test(content.path || content.href)) {
const parsedDataUri = module.exports.parseDataURI(content.path || content.href); const parsedDataUri = module.exports.parseDataURI(content.path || content.href);
return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0)); return callback(null, parsedDataUri && parsedDataUri.data ? parsedDataUri.data : Buffer.alloc(0));
} else if (content.href || /^https?:\/\//i.test(content.path)) {
// An href is always a URL, and so is a path that looks like one. Let nmfetch
// decide whether it is fetchable, it validates the parsed URL. Testing the raw
// string here instead would let a file: href fall through to the "return as is"
// default below and travel on inside the resolved message.
const url = content.href || content.path;
if (options.disableUrlAccess) {
return setImmediate(() => {
const err = new Error('Url access rejected for ' + url);
err.code = errors.EURLACCESS;
callback(err);
});
}
return resolveStream(nmfetch(url, { headers: content.httpHeaders, tls: content.tls }), callback);
} else if (content.path) { } else if (content.path) {
if (options.disableFileAccess) { if (options.disableFileAccess) {
return setImmediate(() => { return setImmediate(() => {
@@ -603,10 +615,14 @@ module.exports.assign = function (/* target, ... sources */) {
args.forEach(source => { args.forEach(source => {
Object.keys(source || {}).forEach(key => { Object.keys(source || {}).forEach(key => {
if (isProtoKey(key)) {
return;
}
if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') { if (['tls', 'auth'].includes(key) && source[key] && typeof source[key] === 'object') {
// tls and auth are special keys that need to be enumerated separately // tls and auth are special keys that need to be enumerated separately
// other objects are passed as is // other objects are passed as is. Enumerating is a copy of user supplied
target[key] = Object.assign(target[key] || {}, source[key]); // keys just like the loop above, so it gets the same treatment
target[key] = module.exports.copyOwnKeys(target[key] || {}, source[key]);
} else { } else {
target[key] = source[key]; target[key] = source[key];
} }
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Safe copying of objects whose keys come from the caller.
//
// This lives in its own leaf module, like ./url.js, so that every layer can reach it.
// lib/shared/index.js requires lib/fetch, so lib/fetch can not require lib/shared back,
// and lib/mime-funcs is a leaf that would otherwise pull in dns/net/os/fs for a string
// comparison. lib/shared/index.js re-exports both functions for the callers that already
// depend on it.
/**
* Detects a key that can not be copied onto a plain object with `target[key] = value`.
*
* "__proto__" is the only one: assigning it runs the inherited setter and replaces the
* prototype of the target instead of adding a property to it, so a caller can smuggle
* values past validation that only inspects own keys. JSON.parse produces such a key
* where an object literal can not. "constructor" and "prototype" have no such setter and
* become ordinary own properties, so dropping them would only discard legitimate values.
*
* @param {String} key Key to check
* @returns {Boolean} true if the key must not be copied
*/
module.exports.isProtoKey = key => key === '__proto__';
/**
* Copies own enumerable keys from a source object to a target object. Every copy that
* walks the keys of user supplied data goes through here, see isProtoKey.
*
* @param {Object} target Object to copy the keys to
* @param {Object} source Object to copy the keys from
* @param {Function} [skip] Optional predicate, return true to leave a key out
* @returns {Object} The target object
*/
module.exports.copyOwnKeys = (target, source, skip) => {
Object.keys(source || {}).forEach(key => {
if (module.exports.isProtoKey(key) || (skip && skip(key))) {
return;
}
target[key] = source[key];
});
return target;
};
+21
View File
@@ -9,6 +9,10 @@ const tls = require('tls');
const urllib = require('../shared/url'); const urllib = require('../shared/url');
const errors = require('../errors'); const errors = require('../errors');
// Cap the CONNECT response we buffer before the header terminator, so a proxy that
// never sends \r\n\r\n cannot grow memory unboundedly before the socket times out.
const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
/** /**
* Establishes proxied connection to destinationPort * Establishes proxied connection to destinationPort
* *
@@ -29,6 +33,16 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
} }
tlsOptions = tlsOptions || {}; tlsOptions = tlsOptions || {};
// Reject CRLF in the destination before it reaches the CONNECT request line
// and Host header. A tainted host/port could otherwise inject additional
// request headers into the proxy connection (HTTP request splitting).
destinationPort = Number(destinationPort) || 0;
if (!destinationPort || /[\r\n]/.test(destinationHost)) {
const err = new Error('Invalid proxy destination');
err.code = errors.EPROXY;
return setImmediate(() => callback(err));
}
const proxy = urllib.parse(proxyUrl); const proxy = urllib.parse(proxyUrl);
const connectOptions = { const connectOptions = {
@@ -140,6 +154,13 @@ function httpProxyClient(proxyUrl, destinationPort, destinationHost, tlsOptions,
return callback(null, socket); return callback(null, socket);
} }
if (headers.length > MAX_RESPONSE_HEADER_BYTES) {
socket.removeListener('data', onSocketData);
const err = new Error('Proxy response headers too large');
err.code = errors.EPROXY;
return tempSocketErr(err);
}
}; };
socket.on('data', onSocketData); socket.on('data', onSocketData);
}); });
+80 -22
View File
@@ -298,6 +298,20 @@ class SMTPConnection extends EventEmitter {
try { try {
this._socket.connect(this.port, this.host, () => { this._socket.connect(this.port, this.host, () => {
this._socket.setKeepAlive(true); this._socket.setKeepAlive(true);
// a `secure` connection over a caller-provided socket must still
// perform the TLS handshake, otherwise AUTH and the message body
// would be sent in cleartext despite the caller requesting TLS
if (this.secureConnection && !this.alreadySecured) {
return this._upgradeConnection(err => {
if (err) {
this._onError(new Error('Error initiating TLS - ' + (err.message || err)), 'ETLS', false, 'CONN');
return;
}
this._onConnect();
});
}
this._onConnect(); this._onConnect();
}); });
this._setupConnectionHandlers(); this._setupConnectionHandlers();
@@ -365,6 +379,14 @@ class SMTPConnection extends EventEmitter {
* @param {Boolean} secure Whether to use TLS * @param {Boolean} secure Whether to use TLS
*/ */
_connectToHost(opts, secure) { _connectToHost(opts, secure) {
// If the client was closed while DNS resolution was in flight, do not open
// a socket here: close() ran with this._socket still unset and so had
// nothing to tear down, and _onConnect's remedial close() is a no-op once
// _closing is set — the freshly connected socket would leak.
if (this._destroyed || this._closing) {
return;
}
this._connectionAttemptId++; this._connectionAttemptId++;
const currentAttemptId = this._connectionAttemptId; const currentAttemptId = this._connectionAttemptId;
@@ -431,6 +453,9 @@ class SMTPConnection extends EventEmitter {
if (this._socket) { if (this._socket) {
try { try {
this._socket.removeListener('error', this._onConnectionSocketError); this._socket.removeListener('error', this._onConnectionSocketError);
// Absorb any late teardown error (e.g. a TLS fallback socket emitting
// after destroy), mirroring the guard used in close()
this._socket.on('error', TEARDOWN_NOOP);
this._socket.destroy(); this._socket.destroy();
} catch (_E) { } catch (_E) {
// ignore // ignore
@@ -757,6 +782,11 @@ class SMTPConnection extends EventEmitter {
* @param {Function} callback Callback to return once connection is reset * @param {Function} callback Callback to return once connection is reset
*/ */
reset(callback) { reset(callback) {
const isDestroyedMessage = this._isDestroyedMessage('reset');
if (isDestroyedMessage) {
return callback(this._formatError(isDestroyedMessage, 'ECONNECTION', false, 'API'));
}
this._sendCommand('RSET'); this._sendCommand('RSET');
this._responseActions.push(str => { this._responseActions.push(str => {
if (str.charAt(0) !== '2') { if (str.charAt(0) !== '2') {
@@ -805,6 +835,9 @@ class SMTPConnection extends EventEmitter {
this._socket.removeListener('end', this._onSocketEnd); this._socket.removeListener('end', this._onSocketEnd);
// Switch from connection-phase error handler to normal error handler // Switch from connection-phase error handler to normal error handler
this._socket.removeListener('error', this._onConnectionSocketError); this._socket.removeListener('error', this._onConnectionSocketError);
// _upgradeConnection (options.connection + secure) may already have attached
// the normal handler; remove it first so we never end up with a duplicate
this._socket.removeListener('error', this._onSocketError);
this._socket.on('error', this._onSocketError); this._socket.on('error', this._onSocketError);
this._socket.on('data', this._onSocketData); this._socket.on('data', this._onSocketData);
@@ -994,6 +1027,8 @@ class SMTPConnection extends EventEmitter {
return; return;
} }
this._destroyed = true; this._destroyed = true;
// keep the documented public flag in sync with the private state
this.destroyed = true;
this.emit('end'); this.emit('end');
} }
@@ -1004,6 +1039,15 @@ class SMTPConnection extends EventEmitter {
* has been secured * has been secured
*/ */
_upgradeConnection(callback) { _upgradeConnection(callback) {
// RFC 3207 section 6: the client MUST discard any knowledge obtained from
// the server that was not received over the TLS-protected session. Drop any
// buffered input received before the handshake so a man-in-the-middle cannot
// inject plaintext bytes after the "220" reply (e.g. a CRLF-free fragment that
// would otherwise be prepended to the first post-TLS response and parsed as
// part of the secured EHLO capabilities). STARTTLS response injection.
this._remainder = '';
this._responseQueue = [];
// do not remove all listeners or it breaks node v0.10 as there's // do not remove all listeners or it breaks node v0.10 as there's
// apparently a 'finish' event set that would be cleared as well // apparently a 'finish' event set that would be cleared as well
@@ -1032,6 +1076,9 @@ class SMTPConnection extends EventEmitter {
socketPlain.removeListener('close', this._onSocketClose); socketPlain.removeListener('close', this._onSocketClose);
socketPlain.removeListener('end', this._onSocketEnd); socketPlain.removeListener('end', this._onSocketEnd);
socketPlain.removeListener('error', this._onSocketError); socketPlain.removeListener('error', this._onSocketError);
// the connection-phase handler is attached when upgrading a pre-opened
// options.connection socket; strip it so nothing lingers on the plain socket
socketPlain.removeListener('error', this._onConnectionSocketError);
}; };
this.upgrading = true; this.upgrading = true;
@@ -1064,18 +1111,27 @@ class SMTPConnection extends EventEmitter {
/** /**
* Processes queued responses from the server * Processes queued responses from the server
*
* @param {Boolean} force If true, ignores _processing flag
*/ */
_processResponse() { _processResponse() {
if (!this._responseQueue.length) { if (!this._responseQueue.length) {
return false; return false;
} }
let str = (this.lastServerResponse = decodeServerResponse((this._responseQueue.shift() || '').toString())); const raw = (this._responseQueue.shift() || '').toString();
// Skip unexpected empty lines without consuming a response action or
// overwriting lastServerResponse; reprocess whatever else is queued.
if (!raw.trim()) {
setImmediate(() => this._processResponse());
return;
}
let str = (this.lastServerResponse = decodeServerResponse(raw));
if (/^\d+-/.test(str.split('\n').pop())) { if (/^\d+-/.test(str.split('\n').pop())) {
// keep waiting for the final part of multiline response // last line is still a continuation: put the partial response back on the
// queue and wait for the rest rather than dropping it
this._responseQueue.unshift(raw);
return; return;
} }
@@ -1088,11 +1144,6 @@ class SMTPConnection extends EventEmitter {
); );
} }
if (!str.trim()) {
// skip unexpected empty lines
setImmediate(() => this._processResponse());
}
const action = this._responseActions.shift(); const action = this._responseActions.shift();
if (typeof action === 'function') { if (typeof action === 'function') {
@@ -1189,6 +1240,23 @@ class SMTPConnection extends EventEmitter {
} }
} }
// RFC 8689: validate REQUIRETLS eligibility before queuing the MAIL FROM
// response action, so a rejection here cannot leave an orphaned action in
// _responseActions (which would consume the next reply and desync a reused
// connection).
if (this._envelope.requireTLSExtensionEnabled) {
if (!this.secure) {
return callback(
this._formatError('REQUIRETLS can only be used over TLS connections (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
);
}
if (!this._supportedExtensions.includes('REQUIRETLS')) {
return callback(
this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
);
}
}
this._responseActions.push(str => { this._responseActions.push(str => {
this._actionMAIL(str, callback); this._actionMAIL(str, callback);
}); });
@@ -1225,20 +1293,10 @@ class SMTPConnection extends EventEmitter {
} }
} }
// RFC 8689: If the envelope requests REQUIRETLS extension // RFC 8689: append the REQUIRETLS keyword to MAIL FROM. Eligibility
// then append REQUIRETLS keyword to the MAIL FROM command // (TLS connection + server support) was already validated above, before
// Note: REQUIRETLS can only be used over TLS connections and requires server support // the response action was queued.
if (this._envelope.requireTLSExtensionEnabled) { if (this._envelope.requireTLSExtensionEnabled) {
if (!this.secure) {
return callback(
this._formatError('REQUIRETLS can only be used over TLS connections (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
);
}
if (!this._supportedExtensions.includes('REQUIRETLS')) {
return callback(
this._formatError('Server does not support REQUIRETLS extension (RFC 8689)', 'EREQUIRETLS', false, 'MAIL FROM')
);
}
args.push('REQUIRETLS'); args.push('REQUIRETLS');
} }
+1 -1
View File
@@ -42,7 +42,7 @@ class StreamTransport {
// We probably need this in the output // We probably need this in the output
mail.message.keepBcc = true; mail.message.keepBcc = true;
const envelope = mail.data.envelope || mail.message.getEnvelope(); const envelope = mail.message.getEnvelope();
const messageId = mail.message.messageId(); const messageId = mail.message.messageId();
const recipients = [].concat(envelope.to || []); const recipients = [].concat(envelope.to || []);
+14
View File
@@ -577,6 +577,20 @@
"port": 587 "port": 587
}, },
"TurboSMTP": {
"description": "TurboSMTP",
"host": "pro.turbo-smtp.com",
"port": 465,
"secure": true
},
"TurboSMTP-EU": {
"description": "TurboSMTP (EU region)",
"host": "pro.eu.turbo-smtp.com",
"port": 465,
"secure": true
},
"Tutanota": { "Tutanota": {
"description": "Tutanota (Tuta Mail)", "description": "Tutanota (Tuta Mail)",
"domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"], "domains": ["tutanota.com", "tuta.com", "tutanota.de", "tuta.io"],
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodemailer", "name": "nodemailer",
"version": "9.0.0", "version": "9.1.1",
"description": "Easy as cake e-mail sending from your Node.js applications", "description": "Easy as cake e-mail sending from your Node.js applications",
"main": "lib/nodemailer.js", "main": "lib/nodemailer.js",
"scripts": { "scripts": {
@@ -27,19 +27,19 @@
}, },
"homepage": "https://nodemailer.com/", "homepage": "https://nodemailer.com/",
"devDependencies": { "devDependencies": {
"@aws-sdk/client-sesv2": "3.1065.0", "@aws-sdk/client-sesv2": "3.1121.0",
"bunyan": "1.8.15", "bunyan": "1.8.15",
"c8": "11.0.0", "c8": "12.0.0",
"eslint": "10.4.1", "eslint": "10.9.1",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"globals": "17.6.0", "globals": "17.11.0",
"libbase64": "1.3.0", "libbase64": "1.3.0",
"libmime": "5.3.8", "libmime": "5.4.2",
"libqp": "2.1.1", "libqp": "2.1.1",
"prettier": "3.8.4", "prettier": "3.9.6",
"proxy": "1.0.2", "proxy": "1.0.2",
"proxy-test-server": "1.0.0", "proxy-test-server": "1.0.0",
"smtp-server": "3.18.5" "smtp-server": "3.19.4"
}, },
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
+3
View File
@@ -26,6 +26,9 @@ Returns: `Client`
* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds. * **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds.
* **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. * **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB.
* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. * **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
* **webSocket** `WebSocketOptions` (optional) - WebSocket-specific configuration options.
* **maxFragments** `number` (optional) - Default: `131072` - Maximum number of fragments in a message. Set to 0 to disable the limit.
* **maxPayloadSize** `number` (optional) - Default: `134217728` (128 MB) - Maximum allowed payload size in bytes for WebSocket messages. Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages. Set to 0 to disable the limit.
* **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections.
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
* **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body.
+12 -1
View File
@@ -350,7 +350,13 @@ function processHeader (request, key, val) {
} else if (typeof val[i] === 'object') { } else if (typeof val[i] === 'object') {
throw new InvalidArgumentError(`invalid ${key} header`) throw new InvalidArgumentError(`invalid ${key} header`)
} else { } else {
arr.push(`${val[i]}`) // Coerce primitives (and reject unsafe coercions such as functions
// with a crafted toString/Symbol.toPrimitive).
const str = `${val[i]}`
if (!isValidHeaderValue(str)) {
throw new InvalidArgumentError(`invalid ${key} header`)
}
arr.push(str)
} }
} }
val = arr val = arr
@@ -361,7 +367,12 @@ function processHeader (request, key, val) {
} else if (val === null) { } else if (val === null) {
val = '' val = ''
} else { } else {
// Coerce primitives (and reject unsafe coercions such as functions
// with a crafted toString/Symbol.toPrimitive).
val = `${val}` val = `${val}`
if (!isValidHeaderValue(val)) {
throw new InvalidArgumentError(`invalid ${key} header`)
}
} }
if (headerName === 'host') { if (headerName === 'host') {
+2 -2
View File
@@ -24,8 +24,6 @@ function defaultFactory (origin, opts) {
class Agent extends DispatcherBase { class Agent extends DispatcherBase {
constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) { constructor ({ factory = defaultFactory, maxRedirections = 0, connect, ...options } = {}) {
super()
if (typeof factory !== 'function') { if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.') throw new InvalidArgumentError('factory must be a function.')
} }
@@ -38,6 +36,8 @@ class Agent extends DispatcherBase {
throw new InvalidArgumentError('maxRedirections must be a positive number') throw new InvalidArgumentError('maxRedirections must be a positive number')
} }
super(options)
if (connect && typeof connect !== 'function') { if (connect && typeof connect !== 'function') {
connect = { ...connect } connect = { ...connect }
} }
+144 -18
View File
@@ -10,6 +10,7 @@ const {
RequestContentLengthMismatchError, RequestContentLengthMismatchError,
ResponseContentLengthMismatchError, ResponseContentLengthMismatchError,
RequestAbortedError, RequestAbortedError,
InvalidArgumentError,
HeadersTimeoutError, HeadersTimeoutError,
HeadersOverflowError, HeadersOverflowError,
SocketError, SocketError,
@@ -57,6 +58,9 @@ const EMPTY_BUF = Buffer.alloc(0)
const FastBuffer = Buffer[Symbol.species] const FastBuffer = Buffer[Symbol.species]
const addListener = util.addListener const addListener = util.addListener
const removeAllListeners = util.removeAllListeners const removeAllListeners = util.removeAllListeners
const kIdleSocketValidation = Symbol('kIdleSocketValidation')
const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
const kSocketUsed = Symbol('kSocketUsed')
let extractBody let extractBody
@@ -279,15 +283,60 @@ class Parser {
const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr const offset = llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr
if (ret !== constants.ERROR.OK) {
const body = data.subarray(offset)
if (ret === constants.ERROR.PAUSED_UPGRADE) { if (ret === constants.ERROR.PAUSED_UPGRADE) {
this.onUpgrade(data.slice(offset)) this.onUpgrade(body)
} else if (ret === constants.ERROR.PAUSED) { } else if (ret === constants.ERROR.PAUSED) {
this.paused = true this.paused = true
socket.unshift(data.slice(offset)) socket.unshift(body)
} else if (ret !== constants.ERROR.OK) { } else {
throw this.createError(ret, body)
}
}
} catch (err) {
util.destroy(socket, err)
}
}
finish () {
assert(currentParser === null)
assert(this.ptr != null)
assert(!this.paused)
const { llhttp } = this
let ret
try {
currentParser = this
ret = llhttp.llhttp_finish(this.ptr)
} finally {
currentParser = null
}
if (ret === constants.ERROR.OK) {
return null
}
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
this.paused = true
return null
}
return this.createError(ret, EMPTY_BUF)
}
createError (ret, data) {
const { llhttp, contentLength, bytesRead } = this
if (contentLength && bytesRead !== parseInt(contentLength, 10)) {
return new ResponseContentLengthMismatchError()
}
const ptr = llhttp.llhttp_get_error_reason(this.ptr) const ptr = llhttp.llhttp_get_error_reason(this.ptr)
let message = '' let message = ''
/* istanbul ignore else: difficult to make a test case for */
if (ptr) { if (ptr) {
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
message = message =
@@ -295,11 +344,8 @@ class Parser {
Buffer.from(llhttp.memory.buffer, ptr, len).toString() + Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
')' ')'
} }
throw new HTTPParserError(message, constants.ERROR[ret], data.slice(offset))
} return new HTTPParserError(message, constants.ERROR[ret], data)
} catch (err) {
util.destroy(socket, err)
}
} }
destroy () { destroy () {
@@ -329,6 +375,11 @@ class Parser {
return -1 return -1
} }
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]] const request = client[kQueue][client[kRunningIdx]]
if (!request) { if (!request) {
return -1 return -1
@@ -432,6 +483,11 @@ class Parser {
return -1 return -1
} }
if (client[kRunning] === 0) {
util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket)))
return -1
}
const request = client[kQueue][client[kRunningIdx]] const request = client[kQueue][client[kRunningIdx]]
/* istanbul ignore next: difficult to make a test case for */ /* istanbul ignore next: difficult to make a test case for */
@@ -605,6 +661,7 @@ class Parser {
request.onComplete(headers) request.onComplete(headers)
client[kQueue][client[kRunningIdx]++] = null client[kQueue][client[kRunningIdx]++] = null
socket[kSocketUsed] = true
if (socket[kWriting]) { if (socket[kWriting]) {
assert(client[kRunning] === 0) assert(client[kRunning] === 0)
@@ -663,6 +720,9 @@ async function connectH1 (client, socket) {
socket[kWriting] = false socket[kWriting] = false
socket[kReset] = false socket[kReset] = false
socket[kBlocking] = false socket[kBlocking] = false
socket[kIdleSocketValidation] = 0
socket[kIdleSocketValidationTimeout] = null
socket[kSocketUsed] = false
socket[kParser] = new Parser(client, socket, llhttpInstance) socket[kParser] = new Parser(client, socket, llhttpInstance)
addListener(socket, 'error', function (err) { addListener(socket, 'error', function (err) {
@@ -673,8 +733,11 @@ async function connectH1 (client, socket) {
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
// to the user. // to the user.
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) { if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so for as a valid response. const parserErr = parser.finish()
parser.onMessageComplete() if (parserErr) {
this[kError] = parserErr
this[kClient][kOnError](parserErr)
}
return return
} }
@@ -693,8 +756,10 @@ async function connectH1 (client, socket) {
const parser = this[kParser] const parser = this[kParser]
if (parser.statusCode && !parser.shouldKeepAlive) { if (parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response. const parserErr = parser.finish()
parser.onMessageComplete() if (parserErr) {
util.destroy(this, parserErr)
}
return return
} }
@@ -704,10 +769,11 @@ async function connectH1 (client, socket) {
const client = this[kClient] const client = this[kClient]
const parser = this[kParser] const parser = this[kParser]
clearIdleSocketValidation(this)
if (parser) { if (parser) {
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
// We treat all incoming data so far as a valid response. this[kError] = parser.finish() || this[kError]
parser.onMessageComplete()
} }
this[kParser].destroy() this[kParser].destroy()
@@ -770,7 +836,7 @@ async function connectH1 (client, socket) {
return socket.destroyed return socket.destroyed
}, },
busy (request) { busy (request) {
if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { if (socket[kWriting] || socket[kReset] || socket[kBlocking] || socket[kIdleSocketValidation] === 1) {
return true return true
} }
@@ -808,6 +874,31 @@ async function connectH1 (client, socket) {
} }
} }
function clearIdleSocketValidation (socket) {
if (socket[kIdleSocketValidationTimeout]) {
clearTimeout(socket[kIdleSocketValidationTimeout])
socket[kIdleSocketValidationTimeout] = null
}
socket[kIdleSocketValidation] = 0
}
function scheduleIdleSocketValidation (client, socket) {
socket[kIdleSocketValidation] = 1
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
socket[kIdleSocketValidationTimeout] = null
socket[kIdleSocketValidation] = 2
if (client[kSocket] === socket && !socket.destroyed) {
client[kResume]()
}
}, 0)
socket[kIdleSocketValidationTimeout].unref?.()
}
/**
* @param {import('./client.js')} client
*/
function resumeH1 (client) { function resumeH1 (client) {
const socket = client[kSocket] const socket = client[kSocket]
@@ -822,6 +913,32 @@ function resumeH1 (client) {
socket[kNoRef] = false socket[kNoRef] = false
} }
if (client[kRunning] === 0 && client[kPending] > 0 && socket[kSocketUsed]) {
if (socket[kIdleSocketValidation] === 0) {
scheduleIdleSocketValidation(client, socket)
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
if (socket[kIdleSocketValidation] === 1) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
return
}
}
if (client[kRunning] === 0) {
socket[kParser].readMore()
if (socket.destroyed) {
return
}
}
if (client[kSize] === 0) { if (client[kSize] === 0) {
if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) {
socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE) socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE)
@@ -877,8 +994,16 @@ function writeH1 (client, request) {
} }
body = bodyStream.stream body = bodyStream.stream
contentLength = bodyStream.length contentLength = bodyStream.length
} else if (util.isBlobLike(body) && request.contentType == null && body.type) { } else if (util.isBlobLike(body) && request.contentType == null) {
headers.push('content-type', body.type) const contentType = body.type
if (contentType) {
const contentTypeValue = `${contentType}`
if (!util.isValidHeaderValue(contentTypeValue)) {
util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header'))
return false
}
headers.push('content-type', contentTypeValue)
}
} }
if (body && typeof body.read === 'function') { if (body && typeof body.read === 'function') {
@@ -915,6 +1040,7 @@ function writeH1 (client, request) {
} }
const socket = client[kSocket] const socket = client[kSocket]
clearIdleSocketValidation(socket)
const abort = (err) => { const abort = (err) => {
if (request.aborted || request.completed) { if (request.aborted || request.completed) {
+3 -2
View File
@@ -106,9 +106,10 @@ class Client extends DispatcherBase {
autoSelectFamilyAttemptTimeout, autoSelectFamilyAttemptTimeout,
// h2 // h2
maxConcurrentStreams, maxConcurrentStreams,
allowH2 allowH2,
webSocket
} = {}) { } = {}) {
super() super({ webSocket })
if (keepAlive !== undefined) { if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
+10 -1
View File
@@ -11,15 +11,24 @@ const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = requ
const kOnDestroyed = Symbol('onDestroyed') const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed') const kOnClosed = Symbol('onClosed')
const kInterceptedDispatch = Symbol('Intercepted Dispatch') const kInterceptedDispatch = Symbol('Intercepted Dispatch')
const kWebSocketOptions = Symbol('webSocketOptions')
class DispatcherBase extends Dispatcher { class DispatcherBase extends Dispatcher {
constructor () { constructor (opts) {
super() super()
this[kDestroyed] = false this[kDestroyed] = false
this[kOnDestroyed] = null this[kOnDestroyed] = null
this[kClosed] = false this[kClosed] = false
this[kOnClosed] = [] this[kOnClosed] = []
this[kWebSocketOptions] = opts?.webSocket ?? {}
}
get webSocketOptions () {
return {
maxFragments: this[kWebSocketOptions].maxFragments ?? 131072,
maxPayloadSize: this[kWebSocketOptions].maxPayloadSize ?? 128 * 1024 * 1024
}
} }
get destroyed () { get destroyed () {
+2 -2
View File
@@ -19,8 +19,8 @@ const kRemoveClient = Symbol('remove client')
const kStats = Symbol('stats') const kStats = Symbol('stats')
class PoolBase extends DispatcherBase { class PoolBase extends DispatcherBase {
constructor () { constructor (opts) {
super() super(opts)
this[kQueue] = new FixedQueue() this[kQueue] = new FixedQueue()
this[kClients] = [] this[kClients] = []
+2 -2
View File
@@ -37,8 +37,6 @@ class Pool extends PoolBase {
allowH2, allowH2,
...options ...options
} = {}) { } = {}) {
super()
if (connections != null && (!Number.isFinite(connections) || connections < 0)) { if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
throw new InvalidArgumentError('invalid connections') throw new InvalidArgumentError('invalid connections')
} }
@@ -63,6 +61,8 @@ class Pool extends PoolBase {
}) })
} }
super(options)
this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool) this[kInterceptors] = options.interceptors?.Pool && Array.isArray(options.interceptors.Pool)
? options.interceptors.Pool ? options.interceptors.Pool
: [] : []
+34
View File
@@ -15,6 +15,28 @@ function calculateRetryAfterHeader (retryAfter) {
return new Date(retryAfter).getTime() - current return new Date(retryAfter).getTime() - current
} }
function validatePartialResponseContentLength (headers, range, statusCode, retryCount) {
const contentLength = headers['content-length']
if (contentLength == null) {
return null
}
if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) {
return null
}
const length = Number(contentLength)
const expectedLength = range.end - range.start + 1
if (!Number.isFinite(length) || length !== expectedLength) {
return new RequestRetryError('Content-Length mismatch', statusCode, {
headers,
data: { count: retryCount }
})
}
return null
}
class RetryHandler { class RetryHandler {
constructor (opts, handlers) { constructor (opts, handlers) {
const { retryOptions, ...dispatchOpts } = opts const { retryOptions, ...dispatchOpts } = opts
@@ -229,6 +251,12 @@ class RetryHandler {
return false return false
} }
const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount)
if (contentLengthError != null) {
this.abort(contentLengthError)
return false
}
const { start, size, end = size - 1 } = contentRange const { start, size, end = size - 1 } = contentRange
assert(this.start === start, 'content-range mismatch') assert(this.start === start, 'content-range mismatch')
@@ -252,6 +280,12 @@ class RetryHandler {
) )
} }
const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount)
if (contentLengthError != null) {
this.abort(contentLengthError)
return false
}
const { start, size, end = size - 1 } = range const { start, size, end = size - 1 } = range
assert( assert(
start != null && Number.isFinite(start), start != null && Number.isFinite(start),
+15 -22
View File
@@ -275,32 +275,25 @@ function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {})
// If the attribute-name case-insensitively matches the string // If the attribute-name case-insensitively matches the string
// "SameSite", the user agent MUST process the cookie-av as follows: // "SameSite", the user agent MUST process the cookie-av as follows:
// 1. Let enforcement be "Default".
let enforcement = 'Default'
const attributeValueLowercase = attributeValue.toLowerCase() const attributeValueLowercase = attributeValue.toLowerCase()
// 1. If cookie-av's attribute-value is a case-insensitive match for
// "None", append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of "None".
if (attributeValueLowercase === 'none') {
cookieAttributeList.sameSite = 'None'
} else if (attributeValueLowercase === 'strict') {
// 2. If cookie-av's attribute-value is a case-insensitive match for // 2. If cookie-av's attribute-value is a case-insensitive match for
// "None", set enforcement to "None". // "Strict", append an attribute to the cookie-attribute-list with
if (attributeValueLowercase.includes('none')) { // an attribute-name of "SameSite" and an attribute-value of
enforcement = 'None' // "Strict".
} cookieAttributeList.sameSite = 'Strict'
} else if (attributeValueLowercase === 'lax') {
// 3. If cookie-av's attribute-value is a case-insensitive match for // 3. If cookie-av's attribute-value is a case-insensitive match for
// "Strict", set enforcement to "Strict". // "Lax", append an attribute to the cookie-attribute-list with an
if (attributeValueLowercase.includes('strict')) { // attribute-name of "SameSite" and an attribute-value of "Lax".
enforcement = 'Strict' cookieAttributeList.sameSite = 'Lax'
} }
// 4. If cookie-av's attribute-value is a case-insensitive match for
// "Lax", set enforcement to "Lax".
if (attributeValueLowercase.includes('lax')) {
enforcement = 'Lax'
}
// 5. Append an attribute to the cookie-attribute-list with an
// attribute-name of "SameSite" and an attribute-value of
// enforcement.
cookieAttributeList.sameSite = enforcement
} else { } else {
cookieAttributeList.unparsed ??= [] cookieAttributeList.unparsed ??= []
+79 -9
View File
@@ -105,7 +105,7 @@ function validateCookiePath (path) {
if ( if (
code < 0x20 || // exclude CTLs (0-31) code < 0x20 || // exclude CTLs (0-31)
code === 0x7F || // DEL code > 0x7E || // exclude DEL and non-ascii
code === 0x3B // ; code === 0x3B // ;
) { ) {
throw new Error('Invalid cookie path') throw new Error('Invalid cookie path')
@@ -114,16 +114,80 @@ function validateCookiePath (path) {
} }
/** /**
* I have no idea why these values aren't allowed to be honest, * <let-dig> ::= <letter> | <digit>
* but Deno tests these. - Khafra *
* <letter> ::= any one of the 52 alphabetic characters A through Z in
* upper case and a through z in lower case
*
* <digit> ::= any one of the ten digits 0 through 9r
*
* @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
* @param {number} code
*/
function isLetterOrDigit (code) {
return (
(code >= 0x30 && code <= 0x39) || // 0-9
(code >= 0x41 && code <= 0x5A) || // A-Z
(code >= 0x61 && code <= 0x7A) // a-z
)
}
/**
* Validates a cookie domain against the "preferred name syntax".
*
* <domain> ::= <subdomain> | " "
* <subdomain> ::= <label> | <subdomain> "." <label>
* <label> ::= <let-dig> [ [ <ldh-str> ] <let-dig> ]
* <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
* <let-dig-hyp> ::= <let-dig> | "-"
*
* @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5
* @see https://www.rfc-editor.org/rfc/rfc1123#section-2.1
* @see https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4
* @param {string} domain * @param {string} domain
*/ */
function validateCookieDomain (domain) { function validateCookieDomain (domain) {
if ( // <domain> ::= <subdomain> | " "
domain.startsWith('-') || if (domain === ' ') {
domain.endsWith('.') || return
domain.endsWith('-') }
) {
if (domain.length > 255) {
throw new Error('Invalid cookie domain')
}
let labelLength = 0
for (let i = 0; i < domain.length; ++i) {
const code = domain.charCodeAt(i)
if (code === 0x2E) {
if (labelLength === 0) {
throw new Error('Invalid cookie domain')
}
if (domain.charCodeAt(i - 1) === 0x2D) { // "-"
throw new Error('Invalid cookie domain')
}
labelLength = 0
continue
}
if (labelLength === 0 && !isLetterOrDigit(code)) {
throw new Error('Invalid cookie domain')
}
if (!isLetterOrDigit(code) && code !== 0x2D) { // "-"
throw new Error('Invalid cookie domain')
}
if (++labelLength > 63) {
throw new Error('Invalid cookie domain')
}
}
if (labelLength === 0 || domain.charCodeAt(domain.length - 1) === 0x2D) { // "-"
throw new Error('Invalid cookie domain') throw new Error('Invalid cookie domain')
} }
} }
@@ -266,7 +330,13 @@ function stringify (cookie) {
const [key, ...value] = part.split('=') const [key, ...value] = part.split('=')
out.push(`${key.trim()}=${value.join('=')}`) const trimmedKey = key.trim()
const joinedValue = value.join('=')
validateCookieName(trimmedKey)
validateCookieValue(joinedValue)
out.push(`${trimmedKey}=${joinedValue}`)
} }
return out.join('; ') return out.join('; ')
+13 -31
View File
@@ -8,40 +8,35 @@ const tail = Buffer.from([0x00, 0x00, 0xff, 0xff])
const kBuffer = Symbol('kBuffer') const kBuffer = Symbol('kBuffer')
const kLength = Symbol('kLength') const kLength = Symbol('kLength')
// Default maximum decompressed message size: 4 MB
const kDefaultMaxDecompressedSize = 4 * 1024 * 1024
class PerMessageDeflate { class PerMessageDeflate {
/** @type {import('node:zlib').InflateRaw} */ /** @type {import('node:zlib').InflateRaw} */
#inflate #inflate
#options = {} #options = {}
/** @type {boolean} */ #maxPayloadSize = 0
#aborted = false
/** @type {Function|null} */
#currentCallback = null
/** /**
* @param {Map<string, string>} extensions * @param {Map<string, string>} extensions
*/ */
constructor (extensions) { constructor (extensions, options) {
this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover')
this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits')
this.#maxPayloadSize = options.maxPayloadSize
} }
/**
* Decompress a compressed payload.
* @param {Buffer} chunk Compressed data
* @param {boolean} fin Final fragment flag
* @param {Function} callback Callback function
*/
decompress (chunk, fin, callback) { decompress (chunk, fin, callback) {
// An endpoint uses the following algorithm to decompress a message. // An endpoint uses the following algorithm to decompress a message.
// 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the
// payload of the message. // payload of the message.
// 2. Decompress the resulting data using DEFLATE. // 2. Decompress the resulting data using DEFLATE.
if (this.#aborted) {
callback(new MessageSizeExceededError())
return
}
if (!this.#inflate) { if (!this.#inflate) {
let windowBits = Z_DEFAULT_WINDOWBITS let windowBits = Z_DEFAULT_WINDOWBITS
@@ -64,23 +59,12 @@ class PerMessageDeflate {
this.#inflate[kLength] = 0 this.#inflate[kLength] = 0
this.#inflate.on('data', (data) => { this.#inflate.on('data', (data) => {
if (this.#aborted) {
return
}
this.#inflate[kLength] += data.length this.#inflate[kLength] += data.length
if (this.#inflate[kLength] > kDefaultMaxDecompressedSize) { if (this.#maxPayloadSize > 0 && this.#inflate[kLength] > this.#maxPayloadSize) {
this.#aborted = true callback(new MessageSizeExceededError())
this.#inflate.removeAllListeners() this.#inflate.removeAllListeners()
this.#inflate.destroy()
this.#inflate = null this.#inflate = null
if (this.#currentCallback) {
const cb = this.#currentCallback
this.#currentCallback = null
cb(new MessageSizeExceededError())
}
return return
} }
@@ -93,14 +77,13 @@ class PerMessageDeflate {
}) })
} }
this.#currentCallback = callback
this.#inflate.write(chunk) this.#inflate.write(chunk)
if (fin) { if (fin) {
this.#inflate.write(tail) this.#inflate.write(tail)
} }
this.#inflate.flush(() => { this.#inflate.flush(() => {
if (this.#aborted || !this.#inflate) { if (!this.#inflate) {
return return
} }
@@ -108,7 +91,6 @@ class PerMessageDeflate {
this.#inflate[kBuffer].length = 0 this.#inflate[kBuffer].length = 0
this.#inflate[kLength] = 0 this.#inflate[kLength] = 0
this.#currentCallback = null
callback(null, full) callback(null, full)
}) })
+98 -12
View File
@@ -18,6 +18,12 @@ const {
const { WebsocketFrameSend } = require('./frame') const { WebsocketFrameSend } = require('./frame')
const { closeWebSocketConnection } = require('./connection') const { closeWebSocketConnection } = require('./connection')
const { PerMessageDeflate } = require('./permessage-deflate') const { PerMessageDeflate } = require('./permessage-deflate')
const { MessageSizeExceededError } = require('../../core/errors')
function failWebsocketConnectionWithCode (ws, code, reason) {
closeWebSocketConnection(ws, code, reason, Buffer.byteLength(reason))
failWebsocketConnection(ws, reason)
}
// This code was influenced by ws released under the MIT license. // This code was influenced by ws released under the MIT license.
// Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com> // Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
@@ -26,6 +32,7 @@ const { PerMessageDeflate } = require('./permessage-deflate')
class ByteParser extends Writable { class ByteParser extends Writable {
#buffers = [] #buffers = []
#fragmentsBytes = 0
#byteOffset = 0 #byteOffset = 0
#loop = false #loop = false
@@ -37,18 +44,27 @@ class ByteParser extends Writable {
/** @type {Map<string, PerMessageDeflate>} */ /** @type {Map<string, PerMessageDeflate>} */
#extensions #extensions
/** @type {number} */
#maxFragments
/** @type {number} */
#maxPayloadSize
/** /**
* @param {import('./websocket').WebSocket} ws * @param {import('./websocket').WebSocket} ws
* @param {Map<string, string>|null} extensions * @param {Map<string, string>|null} extensions
* @param {{ maxFragments?: number, maxPayloadSize?: number }} [options]
*/ */
constructor (ws, extensions) { constructor (ws, extensions, options = {}) {
super() super()
this.ws = ws this.ws = ws
this.#extensions = extensions == null ? new Map() : extensions this.#extensions = extensions == null ? new Map() : extensions
this.#maxFragments = options.maxFragments ?? 0
this.#maxPayloadSize = options.maxPayloadSize ?? 0
if (this.#extensions.has('permessage-deflate')) { if (this.#extensions.has('permessage-deflate')) {
this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions, options))
} }
} }
@@ -64,6 +80,19 @@ class ByteParser extends Writable {
this.run(callback) this.run(callback)
} }
#validatePayloadLength () {
if (
this.#maxPayloadSize > 0 &&
!isControlFrame(this.#info.opcode) &&
this.#info.payloadLength + this.#fragmentsBytes > this.#maxPayloadSize
) {
failWebsocketConnectionWithCode(this.ws, 1009, 'Payload size exceeds maximum allowed size')
return false
}
return true
}
/** /**
* Runs whenever a new chunk is received. * Runs whenever a new chunk is received.
* Callback is called whenever there are no more chunks buffering, * Callback is called whenever there are no more chunks buffering,
@@ -152,6 +181,10 @@ class ByteParser extends Writable {
if (payloadLength <= 125) { if (payloadLength <= 125) {
this.#info.payloadLength = payloadLength this.#info.payloadLength = payloadLength
this.#state = parserStates.READ_DATA this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (payloadLength === 126) { } else if (payloadLength === 126) {
this.#state = parserStates.PAYLOADLENGTH_16 this.#state = parserStates.PAYLOADLENGTH_16
} else if (payloadLength === 127) { } else if (payloadLength === 127) {
@@ -176,6 +209,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = buffer.readUInt16BE(0) this.#info.payloadLength = buffer.readUInt16BE(0)
this.#state = parserStates.READ_DATA this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.PAYLOADLENGTH_64) { } else if (this.#state === parserStates.PAYLOADLENGTH_64) {
if (this.#byteOffset < 8) { if (this.#byteOffset < 8) {
return callback() return callback()
@@ -198,6 +235,10 @@ class ByteParser extends Writable {
this.#info.payloadLength = lower this.#info.payloadLength = lower
this.#state = parserStates.READ_DATA this.#state = parserStates.READ_DATA
if (!this.#validatePayloadLength()) {
return
}
} else if (this.#state === parserStates.READ_DATA) { } else if (this.#state === parserStates.READ_DATA) {
if (this.#byteOffset < this.#info.payloadLength) { if (this.#byteOffset < this.#info.payloadLength) {
return callback() return callback()
@@ -210,27 +251,43 @@ class ByteParser extends Writable {
this.#state = parserStates.INFO this.#state = parserStates.INFO
} else { } else {
if (!this.#info.compressed) { if (!this.#info.compressed) {
this.#fragments.push(body) if (!this.writeFragments(body)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
// If the frame is not fragmented, a message has been received. // If the frame is not fragmented, a message has been received.
// If the frame is fragmented, it will terminate with a fin bit set // If the frame is fragmented, it will terminate with a fin bit set
// and an opcode of 0 (continuation), therefore we handle that when // and an opcode of 0 (continuation), therefore we handle that when
// parsing continuation frames, not here. // parsing continuation frames, not here.
if (!this.#info.fragmented && this.#info.fin) { if (!this.#info.fragmented && this.#info.fin) {
const fullMessage = Buffer.concat(this.#fragments) websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
websocketMessageReceived(this.ws, this.#info.binaryType, fullMessage)
this.#fragments.length = 0
} }
this.#state = parserStates.INFO this.#state = parserStates.INFO
} else { } else {
this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => { this.#extensions.get('permessage-deflate').decompress(
body,
this.#info.fin,
(error, data) => {
if (error) { if (error) {
failWebsocketConnection(this.ws, error.message) const code = error instanceof MessageSizeExceededError ? 1009 : 1007
failWebsocketConnectionWithCode(this.ws, code, error.message)
return return
} }
this.#fragments.push(data) if (!this.writeFragments(data)) {
return
}
if (this.#maxPayloadSize > 0 && this.#fragmentsBytes > this.#maxPayloadSize) {
failWebsocketConnectionWithCode(this.ws, 1009, new MessageSizeExceededError().message)
return
}
if (!this.#info.fin) { if (!this.#info.fin) {
this.#state = parserStates.INFO this.#state = parserStates.INFO
@@ -239,13 +296,13 @@ class ByteParser extends Writable {
return return
} }
websocketMessageReceived(this.ws, this.#info.binaryType, Buffer.concat(this.#fragments)) websocketMessageReceived(this.ws, this.#info.binaryType, this.consumeFragments())
this.#loop = true this.#loop = true
this.#state = parserStates.INFO this.#state = parserStates.INFO
this.#fragments.length = 0
this.run(callback) this.run(callback)
}) }
)
this.#loop = false this.#loop = false
break break
@@ -297,6 +354,35 @@ class ByteParser extends Writable {
return buffer return buffer
} }
writeFragments (fragment) {
if (
this.#maxFragments > 0 &&
this.#fragments.length === this.#maxFragments
) {
failWebsocketConnectionWithCode(this.ws, 1008, 'Too many message fragments')
return false
}
this.#fragmentsBytes += fragment.length
this.#fragments.push(fragment)
return true
}
consumeFragments () {
const fragments = this.#fragments
if (fragments.length === 1) {
this.#fragmentsBytes = 0
return fragments.shift()
}
const output = Buffer.concat(fragments, this.#fragmentsBytes)
this.#fragments = []
this.#fragmentsBytes = 0
return output
}
parseCloseBody (data) { parseCloseBody (data) {
assert(data.length !== 1) assert(data.length !== 1)
+8 -1
View File
@@ -435,7 +435,14 @@ class WebSocket extends EventTarget {
// once this happens, the connection is open // once this happens, the connection is open
this[kResponse] = response this[kResponse] = response
const parser = new ByteParser(this, parsedExtensions) const webSocketOptions = this[kController]?.dispatcher?.webSocketOptions
const maxFragments = webSocketOptions?.maxFragments
const maxPayloadSize = webSocketOptions?.maxPayloadSize
const parser = new ByteParser(this, parsedExtensions, {
maxFragments,
maxPayloadSize
})
parser.on('drain', onParserDrain) parser.on('drain', onParserDrain)
parser.on('error', onParserError.bind(this)) parser.on('error', onParserError.bind(this))
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "undici", "name": "undici",
"version": "6.24.1", "version": "6.28.0",
"description": "An HTTP/1.1 client, written from scratch for Node.js", "description": "An HTTP/1.1 client, written from scratch for Node.js",
"homepage": "https://undici.nodejs.org", "homepage": "https://undici.nodejs.org",
"bugs": { "bugs": {
+17
View File
@@ -78,6 +78,8 @@ export declare namespace Client {
localAddress?: string; localAddress?: string;
/** Max response body size in bytes, -1 is disabled */ /** Max response body size in bytes, -1 is disabled */
maxResponseSize?: number; maxResponseSize?: number;
/** WebSocket-specific options */
webSocket?: Client.WebSocketOptions;
/** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */ /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */
autoSelectFamily?: boolean; autoSelectFamily?: boolean;
/** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */ /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */
@@ -103,6 +105,21 @@ export declare namespace Client {
bytesWritten?: number bytesWritten?: number
bytesRead?: number bytesRead?: number
} }
export interface WebSocketOptions {
/**
* Maximum number of fragments in a message.
* Set to 0 to disable the limit.
* @default 131072
*/
maxFragments?: number;
/**
* Maximum allowed payload size in bytes for WebSocket messages.
* Applied to uncompressed messages, compressed frame payloads, and decompressed (permessage-deflate) messages.
* Set to 0 to disable the limit.
* @default 134217728 (128 MB)
*/
maxPayloadSize?: number;
}
} }
export default Client; export default Client;
+11 -11
View File
@@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1", "@actions/core": "^3.0.1",
"@actions/glob": "^0.7.0", "@actions/glob": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.1.1",
"showdown": "^2.1.0" "showdown": "^2.1.0"
} }
}, },
@@ -67,15 +67,15 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -103,9 +103,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.0.1", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", "integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -137,9 +137,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.24.1", "version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=18.17"
+1 -1
View File
@@ -5,7 +5,7 @@
"dependencies": { "dependencies": {
"@actions/core": "^3.0.1", "@actions/core": "^3.0.1",
"@actions/glob": "^0.7.0", "@actions/glob": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.1.1",
"showdown": "^2.1.0" "showdown": "^2.1.0"
} }
} }