@blamejs/core 0.14.17 → 0.14.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -256,10 +256,10 @@ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
256
256
  // The primitive composes the lookup; per-cert chain verification is
257
257
  // the operator's responsibility (or future b.network.smtp.policy.dane.
258
258
  // verifyChain extension).
259
- async function _fetchDaneTlsa(mxHost, daneMode, auditEmit) {
259
+ async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
260
260
  if (daneMode === "off") return null;
261
261
  try {
262
- var tlsa = await smtpPolicy().dane.tlsa(mxHost, DEFAULT_PORT_SMTP);
262
+ var tlsa = await smtpPolicy().dane.tlsa(mxHost, port || DEFAULT_PORT_SMTP);
263
263
  return tlsa && tlsa.length > 0 ? tlsa : null;
264
264
  } catch (e) {
265
265
  auditEmit("mail.send.deliver.dane.skip", "warn",
@@ -281,7 +281,7 @@ async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
281
281
  var factory = opts.transportFactory || mailModule().smtpTransport;
282
282
  var transport = factory({
283
283
  host: mxHost,
284
- port: DEFAULT_PORT_SMTP,
284
+ port: opts.port || DEFAULT_PORT_SMTP,
285
285
  ehloName: hostnameLocal,
286
286
  timeoutMs: opts.perHostTimeoutMs || DEFAULT_PER_HOST_TIMEOUT_MS,
287
287
  requireTls: envelope.requireTls === true,
@@ -327,7 +327,7 @@ async function _deliverOne(envelope, recipient, ctx) {
327
327
  // composes directly into smtpTransport.dane); this branch carries
328
328
  // the discovery so the audit chain records the policy posture
329
329
  // applied to each delivery attempt.
330
- await _fetchDaneTlsa(mx.exchange, ctx.policy.dane, ctx.auditEmit);
330
+ await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane, ctx.auditEmit);
331
331
  try {
332
332
  var rv = await _tryHost({
333
333
  from: envelope.from,
@@ -396,6 +396,7 @@ async function _deliverOne(envelope, recipient, ctx) {
396
396
  *
397
397
  * @opts
398
398
  * hostname: string, // required — local hostname for HELO/EHLO + DSN Reporting-MTA
399
+ * port: number, // default 25 (IANA SMTP, RFC 5321) — set 587 (RFC 6409 submission) or 465 (RFC 8314 implicit-TLS) for a smarthost relay
399
400
  * resolver: object | null, // optional — b.network.dns.resolver handle; falls back to node:dns when omitted
400
401
  * policy: {
401
402
  * mtaSts: "enforce" | "testing" | "off", // default "enforce" — RFC 8461 posture
@@ -438,11 +439,19 @@ function create(opts) {
438
439
  throw new DeliverError("deliver/bad-opts", "mail.send.deliver.create: opts is required");
439
440
  }
440
441
  validateOpts(opts,
441
- ["hostname", "resolver", "policy", "retry", "dsn", "timeouts", "audit", "transportFactory"],
442
+ ["hostname", "resolver", "policy", "retry", "dsn", "timeouts", "audit", "transportFactory", "port"],
442
443
  "mail.send.deliver.create");
443
444
  validateOpts.requireNonEmptyString(opts.hostname,
444
445
  "mail.send.deliver.create: hostname (local HELO/EHLO + DSN Reporting-MTA)",
445
446
  DeliverError, "deliver/bad-hostname");
447
+ // Submission/smarthost relays listen on 587 (RFC 6409) or implicit-TLS
448
+ // 465 (RFC 8314) rather than the IANA SMTP port 25 (RFC 5321 §2.3.4)
449
+ // that direct MX delivery uses. Operators routing through such a relay
450
+ // set opts.port; the value is range-checked here (RFC 6335 §6) so a
451
+ // typo fails at config time, not on the first connect attempt.
452
+ validateOpts.optionalPort(opts.port,
453
+ "mail.send.deliver.create: port", DeliverError, "deliver/bad-port");
454
+ var port = opts.port || DEFAULT_PORT_SMTP;
446
455
 
447
456
  var policy = opts.policy || {};
448
457
  validateOpts(policy, ["mtaSts", "dane"], "mail.send.deliver.create.policy");
@@ -516,6 +525,7 @@ function create(opts) {
516
525
  resolver: opts.resolver || null,
517
526
  policy: { mtaSts: policyMtaSts, dane: policyDane },
518
527
  hostname: opts.hostname,
528
+ port: port,
519
529
  mxLookupTimeoutMs: mxLookupTimeoutMs,
520
530
  perHostTimeoutMs: perHostTimeoutMs,
521
531
  transportFactory: opts.transportFactory || null,
package/lib/mail-sieve.js CHANGED
@@ -61,6 +61,7 @@ var safeSieve = require("./safe-sieve");
61
61
  var { defineClass } = require("./framework-error");
62
62
  var numericBounds = require("./numeric-bounds");
63
63
  var validateOpts = require("./validate-opts");
64
+ var codepointClass = require("./codepoint-class");
64
65
 
65
66
  var MailSieveError = defineClass("MailSieveError", { alwaysPermanent: true });
66
67
 
@@ -122,7 +123,7 @@ function _envelopeAddresses(env, key) {
122
123
  // ---- match-type ---------------------------------------------------------
123
124
 
124
125
  function _escapeRe(s) {
125
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ return codepointClass.escapeRegExp(s);
126
127
  }
127
128
 
128
129
  function _wildcardToRe(pattern, caseInsensitive) {
@@ -19,9 +19,14 @@
19
19
  * - "html" — when the response Content-Type is HTML,
20
20
  * injects a <div role="status" ...> banner
21
21
  * immediately after the <body> tag plus a
22
- * <meta> tag inside <head>. Skipped when
23
- * response is already past headers OR not
24
- * text/html.
22
+ * <meta> tag inside <head>. Handles both a
23
+ * string and a Buffer body (the common server-
24
+ * render path); a Buffer is decoded under the
25
+ * response charset, injected, and re-encoded
26
+ * for utf-8 / ascii / latin1. Other charsets
27
+ * warn once and serve the original bytes (the
28
+ * disclosure headers still carry the notice).
29
+ * Skipped when the response is not text/html.
25
30
  *
26
31
  * The middleware does NOT alter the response when:
27
32
  * - response status >= 400 (operator's error pages stay clean)
@@ -41,6 +46,24 @@ var requestHelpers = require("../request-helpers");
41
46
 
42
47
  var aiActMod = lazyRequire(function () { return require("../compliance-ai-act"); });
43
48
  var audit = lazyRequire(function () { return require("../audit"); });
49
+ var logger = lazyRequire(function () { return require("../log").boot("ai-act-disclosure"); });
50
+
51
+ // Charsets whose byte<->string round-trip is lossless for the inject
52
+ // operation: utf-8 (and its ascii / latin1 subsets, which Node decodes
53
+ // byte-for-byte). Other charsets (utf-16le, big5, gb18030, …) are not
54
+ // safe to decode→inject→re-encode without a transcoder we don't vendor,
55
+ // so the Buffer path warns once and serves the original bytes untouched
56
+ // rather than risk corrupting the page.
57
+ var SAFE_INJECT_ENCODINGS = { "utf-8": "utf8", "utf8": "utf8", "us-ascii": "utf8", "ascii": "utf8", "latin1": "latin1", "iso-8859-1": "latin1" };
58
+
59
+ // Read the charset token out of a Content-Type header, lowercased and
60
+ // stripped of surrounding quotes. Returns "" when absent (the caller
61
+ // treats a missing charset as the HTML default, utf-8).
62
+ function _charsetOf(contentType) {
63
+ if (typeof contentType !== "string") return "";
64
+ var m = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType);
65
+ return m ? m[1].trim().toLowerCase() : "";
66
+ }
44
67
 
45
68
  /**
46
69
  * @primitive b.middleware.aiActDisclosure
@@ -53,7 +76,10 @@ var audit = lazyRequire(function () { return require("../audit"); });
53
76
  * responses. In `mode: "header"` (default) it sets `AI-Act-Notice` and
54
77
  * `AI-Act-Article` response headers — cheapest, works for both JSON
55
78
  * and HTML. In `mode: "html"` it additionally inserts a status banner
56
- * after `<body>` and a `<meta>` inside `<head>` for HTML responses.
79
+ * after `<body>` for HTML responses, handling both a string and a
80
+ * Buffer body (a Buffer is decoded under the response charset, injected,
81
+ * and re-encoded for utf-8 / ascii / latin1; other charsets warn once
82
+ * and serve the original bytes with the disclosure headers still set).
57
83
  * Skips error pages, redirects, requests bearing the configured
58
84
  * skip-header, and responses opted out via `res.locals.aiActSkip`.
59
85
  * Emits `compliance.aiact.disclosed` audits on success.
@@ -138,22 +164,29 @@ function create(opts) {
138
164
  res.end = function (chunk, encoding) {
139
165
  try {
140
166
  var ctype = (res.getHeader && res.getHeader("Content-Type")) || "";
141
- if (typeof ctype === "string" && ctype.indexOf("text/html") !== -1 &&
142
- chunk && Buffer.isBuffer(chunk) === false &&
143
- typeof chunk === "string") {
144
- var bannerHtml = aiActMod().transparency.htmlBanner({
145
- kind: opts.kind || "ai-interaction",
146
- lang: opts.lang || "en",
147
- });
148
- // Inject after <body> if present, else prepend.
149
- var bodyOpen = chunk.indexOf("<body");
150
- if (bodyOpen !== -1) {
151
- var afterTag = chunk.indexOf(">", bodyOpen);
152
- if (afterTag !== -1) {
153
- chunk = chunk.slice(0, afterTag + 1) + bannerHtml + chunk.slice(afterTag + 1);
167
+ if (typeof ctype === "string" && ctype.indexOf("text/html") !== -1 && chunk) {
168
+ if (typeof chunk === "string") {
169
+ chunk = _injectBanner(chunk, opts);
170
+ } else if (Buffer.isBuffer(chunk)) {
171
+ // res.end(Buffer.from(html)) is the common server-render path
172
+ // (b.render serves a Buffer). Decode under the response charset,
173
+ // inject the Art. 50 banner, re-encode — but only for charsets
174
+ // whose round-trip is lossless. Unknown charsets warn once and
175
+ // serve the original bytes (no transcoder is vendored).
176
+ var charset = _charsetOf(ctype) || "utf-8";
177
+ var nodeEnc = SAFE_INJECT_ENCODINGS[charset];
178
+ if (nodeEnc) {
179
+ var injected = _injectBanner(chunk.toString(nodeEnc), opts);
180
+ chunk = Buffer.from(injected, nodeEnc);
181
+ // Content-Length, if the operator pre-set it, now understates
182
+ // the body — clear it so the runtime recomputes / chunks.
183
+ if (res.getHeader && res.getHeader("Content-Length") != null &&
184
+ typeof res.removeHeader === "function") {
185
+ res.removeHeader("Content-Length");
186
+ }
187
+ } else {
188
+ _warnUnsafeCharset(charset);
154
189
  }
155
- } else {
156
- chunk = bannerHtml + chunk;
157
190
  }
158
191
  }
159
192
  } catch (_e) { /* injection best-effort */ }
@@ -186,6 +219,42 @@ function create(opts) {
186
219
  };
187
220
  }
188
221
 
222
+ // Insert the EU AI Act Art. 50 status banner into an HTML string. The
223
+ // banner goes immediately after the opening <body> tag when present, else
224
+ // it is prepended. Returns the original string unchanged on any builder
225
+ // error (best-effort injection — the disclosure header still carries the
226
+ // machine-readable notice).
227
+ function _injectBanner(html, opts) {
228
+ var bannerHtml = aiActMod().transparency.htmlBanner({
229
+ kind: opts.kind || "ai-interaction",
230
+ lang: opts.lang || "en",
231
+ });
232
+ var bodyOpen = html.indexOf("<body");
233
+ if (bodyOpen !== -1) {
234
+ var afterTag = html.indexOf(">", bodyOpen);
235
+ if (afterTag !== -1) {
236
+ return html.slice(0, afterTag + 1) + bannerHtml + html.slice(afterTag + 1);
237
+ }
238
+ }
239
+ return bannerHtml + html;
240
+ }
241
+
242
+ // Warn once per process per charset that a Buffer HTML body in an
243
+ // unsupported charset was served without the banner injected, so an
244
+ // operator can switch the response to utf-8 (or accept the header-only
245
+ // disclosure). Drop-silent if the logger is unavailable.
246
+ var _warnedCharsets = Object.create(null);
247
+ function _warnUnsafeCharset(charset) {
248
+ if (_warnedCharsets[charset]) return;
249
+ _warnedCharsets[charset] = true;
250
+ try {
251
+ logger().warn("ai-act-disclosure: HTML response body is a Buffer in charset '" +
252
+ charset + "'; the Art. 50 banner was not injected (no transcoder for that " +
253
+ "charset). The disclosure headers are still set. Serve text/html as utf-8 to " +
254
+ "get the in-page banner.");
255
+ } catch (_e) { /* drop-silent — logger optional */ }
256
+ }
257
+
189
258
  function _articleFor(kind) {
190
259
  switch (kind) {
191
260
  case "ai-interaction": return "Art. 50(1)";
@@ -22,9 +22,42 @@
22
22
  var nodeCrypto = require("node:crypto");
23
23
  var validateOpts = require("../validate-opts");
24
24
  var lazyRequire = require("../lazy-require");
25
+ var safeUrl = require("../safe-url");
25
26
  var { defineClass } = require("../framework-error");
26
27
  var AsyncApiError = defineClass("AsyncApiError", { alwaysPermanent: true });
27
28
 
29
+ // Validate an operator-supplied accessControl.allowOrigin and return the
30
+ // canonical `scheme://host[:port]` string for the Access-Control-Allow-
31
+ // Origin response header. CORS (Fetch Standard §3.2.1) requires a single
32
+ // concrete origin with no path / query / fragment; the empty-string and
33
+ // "*" wildcard forms are spelled separately ("same-origin" / "public").
34
+ // Parsing through safeUrl rejects header-injection bytes (CR/LF) and
35
+ // userinfo, and confirms the value is a real http(s) origin. Throws so
36
+ // the operator catches a typo'd allowOrigin at boot.
37
+ function _canonicalAllowOrigin(value, label) {
38
+ if (typeof value !== "string" || value.length === 0) {
39
+ throw new AsyncApiError("asyncapi/bad-access-control",
40
+ label + ": accessControl.allowOrigin must be a non-empty origin string " +
41
+ "(e.g. \"https://docs.example.com\")");
42
+ }
43
+ var parsed;
44
+ try {
45
+ parsed = safeUrl.parse(value, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
46
+ } catch (e) {
47
+ throw new AsyncApiError("asyncapi/bad-access-control",
48
+ label + ": accessControl.allowOrigin '" + value + "' is not a valid " +
49
+ "http(s) origin: " + ((e && e.message) || String(e)));
50
+ }
51
+ var path = parsed.pathname || "";
52
+ if ((path !== "" && path !== "/") || parsed.search || parsed.hash) {
53
+ throw new AsyncApiError("asyncapi/bad-access-control",
54
+ label + ": accessControl.allowOrigin must be a bare origin " +
55
+ "(scheme://host[:port]) with no path / query / fragment; got '" + value + "'");
56
+ }
57
+ var port = parsed.port;
58
+ return parsed.protocol + "//" + parsed.hostname.toLowerCase() + (port ? ":" + port : "");
59
+ }
60
+
28
61
  var openapiYaml = lazyRequire(function () { return require("../openapi-yaml"); });
29
62
  var audit = lazyRequire(function () { return require("../audit"); });
30
63
 
@@ -38,8 +71,12 @@ var audit = lazyRequire(function () { return require("../audit"); });
38
71
  * configurable JSON + YAML mount points. Matches `openapiServe`
39
72
  * behaviour: GET/HEAD only, SHA3-512 ETag with conditional 304,
40
73
  * operator-controlled CORS gate, falls through on unmatched paths
41
- * or methods. Use to publish channel + operation + message + schema
42
- * specs for event-driven APIs (Kafka, AMQP, MQTT, WebSocket).
74
+ * or methods. `accessControl: "public"` (default) emits
75
+ * `Access-Control-Allow-Origin: *`; `same-origin` omits the header;
76
+ * `{ allowOrigin: "https://docs.example.com" }` echoes one validated
77
+ * origin with `Vary: Origin`. Use to publish channel + operation +
78
+ * message + schema specs for event-driven APIs (Kafka, AMQP, MQTT,
79
+ * WebSocket).
43
80
  *
44
81
  * @opts
45
82
  * {
@@ -79,6 +116,17 @@ function create(opts) {
79
116
  var cacheControl = (typeof opts.cacheControl === "string" && opts.cacheControl.length > 0)
80
117
  ? opts.cacheControl : "public, max-age=300";
81
118
  var accessControl = opts.accessControl || "public";
119
+ // Resolve the Access-Control-Allow-Origin value once at config time.
120
+ // "public" → "*"; an { allowOrigin } object → the canonical origin
121
+ // (validated, throws on a bad value); "same-origin" / anything else →
122
+ // null (no CORS header emitted).
123
+ var allowOriginHeader = null;
124
+ if (accessControl === "public") {
125
+ allowOriginHeader = "*";
126
+ } else if (accessControl && typeof accessControl === "object" &&
127
+ typeof accessControl.allowOrigin === "string") {
128
+ allowOriginHeader = _canonicalAllowOrigin(accessControl.allowOrigin, "asyncapiServe");
129
+ }
82
130
  var auditOn = opts.audit !== false;
83
131
 
84
132
  if (typeof pathJson !== "string" || pathJson.charAt(0) !== "/") {
@@ -117,8 +165,12 @@ function create(opts) {
117
165
  "Cache-Control": cacheControl,
118
166
  "ETag": etag,
119
167
  };
120
- if (accessControl === "public") {
121
- headers["Access-Control-Allow-Origin"] = "*";
168
+ if (allowOriginHeader !== null) {
169
+ headers["Access-Control-Allow-Origin"] = allowOriginHeader;
170
+ // A specific (non-"*") origin makes the response vary by Origin;
171
+ // advertise it so shared caches don't serve one operator's allowed
172
+ // origin to another's request (Fetch Standard §3.2.1).
173
+ if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
122
174
  }
123
175
  res.writeHead(200, headers); // HTTP 200
124
176
  res.end(body);
@@ -33,18 +33,27 @@
33
33
  *
34
34
  * Options:
35
35
  * {
36
- * cookieName: 'blamejs_session' (cookie name to read)
37
- * tokenFrom: 'both' | 'cookie' | 'header' (default 'both')
38
- * sealed: false (use cookies.readSealed)
39
- * vault: b.vault (required when sealed)
40
- * userLoader: async (verifiedSession) => user (REQUIRED)
41
- * audit: true (emit user-load audits)
36
+ * cookieName: 'blamejs_session' (cookie name to read)
37
+ * tokenFrom: 'both' | 'cookie' | 'header' (default 'both')
38
+ * bearerScheme: 'Bearer' (Authorization scheme token; RFC 6750 §2.1)
39
+ * tokenExtractor: (req) => token | null (overrides header extraction entirely)
40
+ * sealed: false (use cookies.readSealed)
41
+ * vault: b.vault (required when sealed)
42
+ * userLoader: async (verifiedSession) => user (REQUIRED)
43
+ * audit: true (emit user-load audits)
42
44
  * }
45
+ *
46
+ * The Authorization-header path matches the `Bearer` scheme by default
47
+ * (RFC 6750 §2.1). Operators behind a gateway that issues a different
48
+ * scheme (e.g. `Token`, `DPoP` per RFC 9449) set bearerScheme, or pass
49
+ * tokenExtractor(req) to read the credential from anywhere on the
50
+ * request. The scheme match is case-insensitive (RFC 9110 §11.1).
43
51
  */
44
52
  var lazyRequire = require("../lazy-require");
45
53
  var cookies = require("../cookies");
46
54
  var requestHelpers = require("../request-helpers");
47
55
  var validateOpts = require("../validate-opts");
56
+ var codepointClass = require("../codepoint-class");
48
57
  var session = lazyRequire(function () { return require("../session"); });
49
58
  var audit = lazyRequire(function () { return require("../audit"); });
50
59
 
@@ -56,10 +65,17 @@ function _readCookie(cookieHeader, name) {
56
65
  return Object.prototype.hasOwnProperty.call(jar, name) ? jar[name] : null;
57
66
  }
58
67
 
59
- function _readBearer(authHeader) {
68
+ // Read the credential after an Authorization scheme token. Default scheme
69
+ // is "Bearer" (RFC 6750 §2.1); operators fronted by a gateway that mints
70
+ // "Token", "DPoP" (RFC 9449), or a custom scheme pass that name so the
71
+ // header is consumed instead of silently ignored. The scheme match is
72
+ // case-insensitive per RFC 9110 §11.1 (auth-scheme is case-insensitive).
73
+ function _readBearer(authHeader, scheme) {
60
74
  if (!authHeader || typeof authHeader !== "string") return null;
61
- // case-insensitive scheme match
62
- var m = authHeader.match(/^Bearer\s+(.+)$/i);
75
+ var schemeTok = (typeof scheme === "string" && scheme.length > 0) ? scheme : "Bearer";
76
+ // allow:dynamic-regex schemeTok is RegExp-escaped via codepointClass.escapeRegExp,
77
+ // so the operator-supplied scheme matches literally and cannot inject a pattern
78
+ var m = authHeader.match(new RegExp("^" + codepointClass.escapeRegExp(schemeTok) + "\\s+(.+)$", "i"));
63
79
  return m ? m[1].trim() : null;
64
80
  }
65
81
 
@@ -86,6 +102,8 @@ function _readBearer(authHeader) {
86
102
  * userLoader: async function(session): user|null, // required
87
103
  * cookieName: string, // default "blamejs_session"
88
104
  * tokenFrom: "both"|"cookie"|"header", // default "both"
105
+ * bearerScheme: string, // default "Bearer" (RFC 6750); set "Token"/"DPoP"/etc. for a gateway scheme
106
+ * tokenExtractor: function, // (req) → token|null; fully owns header extraction when supplied
89
107
  * sealed: boolean,
90
108
  * vault: object, // required when sealed
91
109
  * requireFingerprintMatch: boolean,
@@ -108,15 +126,28 @@ function create(opts) {
108
126
  validateOpts(opts, [
109
127
  "cookieName", "tokenFrom", "sealed", "vault", "userLoader", "audit",
110
128
  "requireFingerprintMatch", "maxAnomalyScore", "scorer",
129
+ "bearerScheme", "tokenExtractor",
111
130
  ], "middleware.attachUser");
112
131
  if (typeof opts.userLoader !== "function") {
113
132
  throw new Error("middleware.attachUser: opts.userLoader is required " +
114
133
  "(async function (verifiedSession) → user | null)");
115
134
  }
135
+ validateOpts.optionalNonEmptyString(opts.bearerScheme,
136
+ "middleware.attachUser: opts.bearerScheme (the Authorization scheme token, " +
137
+ "e.g. \"Bearer\", \"Token\", \"DPoP\")");
138
+ validateOpts.optionalFunction(opts.tokenExtractor,
139
+ "middleware.attachUser: opts.tokenExtractor (req) → token | null");
116
140
  var cookieName = opts.cookieName || "blamejs_session";
117
141
  var tokenFrom = opts.tokenFrom || "both";
118
142
  var auditOn = opts.audit !== false;
119
143
  var sealed = !!opts.sealed;
144
+ // Authorization-header scheme token (default "Bearer", RFC 6750 §2.1).
145
+ // tokenExtractor, when supplied, fully owns header-token extraction so
146
+ // gateway-specific schemes (a forwarded JWT in a non-standard header,
147
+ // DPoP-bound tokens, etc.) work without the framework assuming the
148
+ // RFC 6750 shape.
149
+ var bearerScheme = opts.bearerScheme || "Bearer";
150
+ var tokenExtractor = typeof opts.tokenExtractor === "function" ? opts.tokenExtractor : null;
120
151
  // Fingerprint-drift / IP-UA pin / anomaly-score opts thread through
121
152
  // session.verify so the documented session.create({ req,
122
153
  // fingerprintFields }) defenses actually engage on every verify
@@ -153,7 +184,11 @@ function create(opts) {
153
184
  // the header re-read here avoids the duplicate verify and the
154
185
  // confusing "session.verify failed" audit row that would land
155
186
  // when the bearer token is a JWT or API key, not a session ID.
156
- token = _readBearer(req.headers && req.headers.authorization);
187
+ if (tokenExtractor) {
188
+ token = tokenExtractor(req) || null;
189
+ } else {
190
+ token = _readBearer(req.headers && req.headers.authorization, bearerScheme);
191
+ }
157
192
  }
158
193
  if (!token) return next();
159
194
 
@@ -69,6 +69,11 @@
69
69
  * document: { maxBytes: b.constants.BYTES.mib(25) },
70
70
  * },
71
71
  *
72
+ * // RFC 5987 filename* charsets to decode. utf-8 is always
73
+ * // supported (RFC 8187 §3.2); add "iso-8859-1" (RFC 5987 §3.2)
74
+ * // to accept legacy senders that encode filenames in Latin-1.
75
+ * filenameCharsets: ["utf-8"],
76
+ *
72
77
  * // When wired, fileFilter rejections emit body-parser.multipart.file_rejected
73
78
  * // on the audit chain with the field, filename, mime, and reason.
74
79
  * audit: b.audit,
@@ -121,6 +126,7 @@ var safeBuffer = require("../safe-buffer");
121
126
  var safeJson = require("../safe-json");
122
127
  var structuredFields = require("../structured-fields");
123
128
  var validateOpts = require("../validate-opts");
129
+ var codepointClass = require("../codepoint-class");
124
130
  var C = require("../constants");
125
131
  var { defineClass } = require("../framework-error");
126
132
 
@@ -196,6 +202,7 @@ var DEFAULTS = Object.freeze({
196
202
  fileFilter: null, // fn({ field, filename, mimeType, partHeaders }) → bool | { reject, code, message }
197
203
  fields: null, // per-field overrides: { name: { maxBytes?, mimeTypes? } }
198
204
  audit: null, // when wired, file-rejection emits an audit event
205
+ filenameCharsets: ["utf-8"], // RFC 5987 filename* charsets to decode; add "iso-8859-1" to opt that legacy charset in
199
206
  contentTypes: ["multipart/form-data"],
200
207
  },
201
208
  });
@@ -344,9 +351,16 @@ function _detectSmuggling(req) {
344
351
  function _writeError(res, status, message, code) {
345
352
  if (res.headersSent) return;
346
353
  var body = JSON.stringify({ error: message, code: code });
354
+ // Connection: close on a body-parse rejection (malformed JSON, poisoned
355
+ // key, oversize payload) so an upstream proxy can't reuse a socket whose
356
+ // request stream we abandoned mid-body. Pairing the 4xx with a forced
357
+ // socket teardown closes the desync window a partially-consumed body
358
+ // would otherwise leave open (RFC 9112 §9.6 — a server MAY close after
359
+ // an error response; doing so here prevents request-smuggling reuse).
347
360
  res.writeHead(status, {
348
361
  "Content-Type": "application/json; charset=utf-8",
349
362
  "Content-Length": Buffer.byteLength(body),
363
+ "Connection": "close",
350
364
  });
351
365
  res.end(body);
352
366
  }
@@ -600,28 +614,58 @@ function _parseMultipartHeaders(rawHeaders) {
600
614
  return out;
601
615
  }
602
616
 
617
+ // Percent-decode an RFC 5987 ext-value's value segment under iso-8859-1.
618
+ // RFC 5987 §3.2 / RFC 2231 §7 define iso-8859-1 (Latin-1) as the only
619
+ // non-utf-8 charset a recipient is expected to understand: each decoded
620
+ // byte is itself the Latin-1 code point, so a byte b maps directly to
621
+ // U+00bb. We percent-unescape to raw bytes, then map each byte to its
622
+ // code point. Returns null on a malformed `%`-escape.
623
+ function _percentDecodeLatin1(encoded) {
624
+ var out = "";
625
+ for (var i = 0; i < encoded.length; i += 1) {
626
+ var ch = encoded.charAt(i);
627
+ if (ch === "%") {
628
+ var hex = encoded.substr(i + 1, 2);
629
+ if (hex.length !== 2 || !codepointClass.HEX_PAIR_RE.test(hex)) return null;
630
+ out += String.fromCharCode(parseInt(hex, 16));
631
+ i += 2;
632
+ } else {
633
+ out += ch;
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+
603
639
  // RFC 5987 / 8187 — `filename*=UTF-8''percent%20encoded.txt` extended
604
- // parameter form for non-ASCII filenames. Charset MUST be `UTF-8`
605
- // (case-insensitive); we refuse other charsets to keep the decode
606
- // path single-encoding. Language tag (between the two `'`s) is
607
- // permitted but ignored.
608
- function _decodeRfc5987(raw) {
640
+ // parameter form for non-ASCII filenames. utf-8 is always accepted
641
+ // (RFC 8187 §3.2 mandates support); iso-8859-1 (RFC 5987 §3.2 / RFC 2231
642
+ // §7) decodes only when the operator opts it in via
643
+ // `multipart.filenameCharsets`. Any other charset is refused to keep the
644
+ // decode path bounded to the two RFC-defined encodings. The language tag
645
+ // (between the two `'`s) is permitted but ignored. `allowed` is a
646
+ // lower-cased charset allowlist; it always includes "utf-8".
647
+ function _decodeRfc5987(raw, allowed) {
609
648
  if (typeof raw !== "string") return null;
610
649
  var firstTick = raw.indexOf("'");
611
650
  if (firstTick === -1) return null;
612
651
  var secondTick = raw.indexOf("'", firstTick + 1);
613
652
  if (secondTick === -1) return null;
614
653
  var charset = raw.slice(0, firstTick).toLowerCase();
615
- if (charset !== "utf-8") return null; // RFC 5987 mandated charset; refuse anything else
616
654
  var encoded = raw.slice(secondTick + 1);
617
- try {
618
- return decodeURIComponent(encoded);
619
- } catch (_e) {
620
- return null;
655
+ if (charset === "utf-8") {
656
+ try {
657
+ return decodeURIComponent(encoded);
658
+ } catch (_e) {
659
+ return null;
660
+ }
621
661
  }
662
+ if (charset === "iso-8859-1" && allowed && allowed.indexOf("iso-8859-1") !== -1) {
663
+ return _percentDecodeLatin1(encoded);
664
+ }
665
+ return null; // charset not enabled — refuse
622
666
  }
623
667
 
624
- function _parseHeaderParams(headerValue) {
668
+ function _parseHeaderParams(headerValue, filenameCharsets) {
625
669
  // Content-Disposition: form-data; name="field"; filename="x.txt"
626
670
  // Returns { _value: "form-data", name: "field", filename: "x.txt" }
627
671
  // RFC 5987 / 8187 — when a `filename*=UTF-8''...` extended parameter
@@ -646,7 +690,7 @@ function _parseHeaderParams(headerValue) {
646
690
  var _unq = structuredFields.unquoteSfString(v);
647
691
  if (_unq !== null) v = _unq;
648
692
  if (k.charAt(k.length - 1) === "*") {
649
- var decoded = _decodeRfc5987(v);
693
+ var decoded = _decodeRfc5987(v, filenameCharsets);
650
694
  if (decoded !== null) {
651
695
  var bareKey = k.slice(0, -1);
652
696
  if (bareKey === "filename") extName = decoded;
@@ -682,6 +726,17 @@ async function _parseMultipart(req, opts, ctParams) {
682
726
  true, HTTP_STATUS.BAD_REQUEST
683
727
  );
684
728
  }
729
+ // RFC 5987 filename* charsets the operator opts into decoding. utf-8 is
730
+ // always present (RFC 8187 §3.2 mandates it); operators add "iso-8859-1"
731
+ // for legacy senders. Lower-cased once here so the per-part decode does
732
+ // a plain membership check.
733
+ var filenameCharsets = ["utf-8"];
734
+ if (Array.isArray(opts.filenameCharsets)) {
735
+ filenameCharsets = opts.filenameCharsets.map(function (c) {
736
+ return String(c).toLowerCase();
737
+ });
738
+ if (filenameCharsets.indexOf("utf-8") === -1) filenameCharsets.push("utf-8");
739
+ }
685
740
  // storage: "memory" buffers file parts in RAM (capped by fileSize ×
686
741
  // fileCount, the same DoS bound as disk mode) and exposes each file as
687
742
  // req.files[].buffer with no filesystem touch — the read-only /
@@ -870,7 +925,7 @@ async function _parseMultipart(req, opts, ctParams) {
870
925
  }
871
926
  pending = pending.slice(headEnd + 4);
872
927
  // Decode Content-Disposition.
873
- var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
928
+ var cd = _parseHeaderParams(currentHeaders["content-disposition"], filenameCharsets);
874
929
  if (cd._value !== "form-data" || typeof cd.name !== "string" || cd.name.length === 0) {
875
930
  done(new BodyParserError("body-parser/multipart-bad-disposition",
876
931
  "multipart part missing form-data Content-Disposition", true, HTTP_STATUS.BAD_REQUEST));
@@ -1211,7 +1266,8 @@ async function _parseMultipart(req, opts, ctParams) {
1211
1266
  * raw: false | { limit, contentTypes },
1212
1267
  * multipart: false | {
1213
1268
  * storage, tmpDir, fileSize, totalSize, fileCount, fieldCount,
1214
- * fieldSize, mimeAllowlist, fileFilter, fields, audit, contentTypes,
1269
+ * fieldSize, mimeAllowlist, fileFilter, fields, audit,
1270
+ * filenameCharsets, contentTypes,
1215
1271
  * },
1216
1272
  * keepRawBody: boolean, // expose req.bodyRaw for webhook signing
1217
1273
  * }
@@ -99,9 +99,21 @@ function _normalizeOne(reportLike) {
99
99
  * `csp.violation`, and forwarded to the operator's `onReport`
100
100
  * callback for metrics or alerting.
101
101
  *
102
+ * The rejection paths (405 / 413 / 400) are otherwise empty-bodied —
103
+ * the spec'd Reporting API (W3C Reporting API §3.1) ignores the
104
+ * response body, so there's nothing for the browser to read. `onReject`
105
+ * surfaces these refusals to the operator for the same metrics /
106
+ * alerting use as `onReport`: a flood of 413s signals a misconfigured
107
+ * `Reporting-Endpoints` URL or a report-bomb. It receives
108
+ * `(req, res, { status, reason })` where `reason` is one of
109
+ * `method-not-allowed` / `payload-too-large` / `invalid-json`. Invoked
110
+ * after the rejection response is written; a throwing hook is swallowed
111
+ * so a broken metrics sink can't crash the endpoint.
112
+ *
102
113
  * @opts
103
114
  * {
104
115
  * onReport: function(report): void,
116
+ * onReject: function(req, res, { status, reason }): void,
105
117
  * maxBytes: number, // default 64 KiB
106
118
  * audit: boolean, // default true
107
119
  * }
@@ -118,23 +130,38 @@ function _normalizeOne(reportLike) {
118
130
  */
119
131
  function create(opts) {
120
132
  opts = opts || {};
121
- validateOpts(opts, ["audit", "onReport", "maxBytes"], "middleware.cspReport");
133
+ validateOpts(opts, ["audit", "onReport", "onReject", "maxBytes"], "middleware.cspReport");
134
+ if (opts.onReject !== undefined && opts.onReject !== null &&
135
+ typeof opts.onReject !== "function") {
136
+ throw new TypeError("middleware.cspReport: opts.onReject must be a function");
137
+ }
122
138
  var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
123
139
  ? opts.maxBytes : DEFAULT_MAX_BYTES;
124
140
  var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
141
+ var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
142
+
143
+ // Drop-silent observability sink — the rejection response is already
144
+ // on the wire; a throwing metrics hook must not crash the endpoint.
145
+ function _emitReject(req, res, status, reason) {
146
+ if (!onReject) return;
147
+ try { onReject(req, res, { status: status, reason: reason }); }
148
+ catch (_e) { /* hook best-effort */ }
149
+ }
125
150
 
126
151
  return async function cspReport(req, res, _next) {
127
152
  if (req.method !== "POST") {
128
153
  res.writeHead(405, { "Allow": "POST" }); // HTTP 405 status
129
154
  res.end();
155
+ _emitReject(req, res, 405, "method-not-allowed");
130
156
  return;
131
157
  }
132
158
  var body;
133
159
  try {
134
- body = await safeBuffer.boundedChunkCollector(req, { maxBytes: maxBytes });
160
+ body = await safeBuffer.collectStream(req, { maxBytes: maxBytes });
135
161
  } catch (_e) {
136
162
  res.writeHead(413); // HTTP 413 status
137
163
  res.end();
164
+ _emitReject(req, res, 413, "payload-too-large");
138
165
  return;
139
166
  }
140
167
  var parsed;
@@ -142,6 +169,7 @@ function create(opts) {
142
169
  catch (_e) {
143
170
  res.writeHead(400); // HTTP 400 status
144
171
  res.end();
172
+ _emitReject(req, res, 400, "invalid-json");
145
173
  return;
146
174
  }
147
175
  var reports = Array.isArray(parsed) ? parsed : [parsed];