@blamejs/core 0.8.90 → 0.9.0

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.
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.clientHints
4
+ * @nav HTTP
5
+ * @title Sec-CH-UA Client Hints
6
+ * @order 316
7
+ *
8
+ * @intro
9
+ * User-Agent Client Hints parser. Browsers replacing the
10
+ * freeform `User-Agent` string send a family of `Sec-CH-UA-*`
11
+ * request headers carrying structured-fields data per RFC 8941:
12
+ *
13
+ * Sec-CH-UA: "Chromium";v="124", "Not-A.Brand";v="99", "Google Chrome";v="124"
14
+ * Sec-CH-UA-Mobile: ?0
15
+ * Sec-CH-UA-Platform: "Windows"
16
+ * Sec-CH-UA-Platform-Version: "15.0.0"
17
+ * Sec-CH-UA-Arch: "x86"
18
+ * Sec-CH-UA-Bitness: "64"
19
+ * Sec-CH-UA-Model: ""
20
+ * Sec-CH-UA-Full-Version-List: "Chromium";v="124.0.6367.91", ...
21
+ * Sec-CH-UA-WoW64: ?0
22
+ * Sec-CH-UA-Form-Factors: "Desktop"
23
+ *
24
+ * `parse(headers)` walks an HTTP request's headers map and returns
25
+ * a normalized object — brand list with versions, mobile boolean,
26
+ * platform / platform-version / arch / bitness / model / form-
27
+ * factors strings — plus the raw RFC 8941 parsed shape for any
28
+ * header the operator wants to inspect verbatim.
29
+ *
30
+ * Operators use it to:
31
+ * - Replace freeform UA-string parsing (deprecated; brittle).
32
+ * - Negotiate per-platform CSS / JS bundles (Sec-CH-UA-Platform).
33
+ * - Detect mobile-class clients without UA-sniffing.
34
+ * - Audit fingerprinting-style header negotiation.
35
+ *
36
+ * The primitive treats every header as defensive request-shape
37
+ * input — returns `null` for absent / malformed headers, throws
38
+ * only on explicit control-character / header-injection-shape
39
+ * input. Operators upstream of this primitive (proxies, framework
40
+ * middleware) already split CRLF; the in-string control-byte
41
+ * check is a defense-in-depth layer.
42
+ *
43
+ * `acceptList()` builds the `Accept-CH` response header so the
44
+ * operator advertises which client-hint headers the page wants.
45
+ *
46
+ * @card
47
+ * RFC 8941 / W3C Client Hints — parse `Sec-CH-UA*` request headers
48
+ * (brand list, mobile, platform, arch, model, form-factors) and
49
+ * build the `Accept-CH` response header for hint negotiation.
50
+ */
51
+
52
+ var structuredFields = require("./structured-fields");
53
+ var { defineClass } = require("./framework-error");
54
+
55
+ var ClientHintsError = defineClass("ClientHintsError",
56
+ { alwaysPermanent: true });
57
+
58
+ // Well-known Sec-CH-UA-* header names (W3C UA-CH spec + IETF
59
+ // draft-davidben-http-client-hint-reliability). Operators looking up
60
+ // the canonical name for an Accept-CH response use this constant; we
61
+ // keep it as a frozen array so a future hint addition is one-line.
62
+ var KNOWN_HINTS = Object.freeze([
63
+ "Sec-CH-UA",
64
+ "Sec-CH-UA-Mobile",
65
+ "Sec-CH-UA-Platform",
66
+ "Sec-CH-UA-Platform-Version",
67
+ "Sec-CH-UA-Arch",
68
+ "Sec-CH-UA-Bitness",
69
+ "Sec-CH-UA-Model",
70
+ "Sec-CH-UA-Full-Version-List",
71
+ "Sec-CH-UA-WoW64",
72
+ "Sec-CH-UA-Form-Factors",
73
+ "Sec-CH-Prefers-Reduced-Motion",
74
+ "Sec-CH-Prefers-Reduced-Transparency",
75
+ "Sec-CH-Prefers-Color-Scheme",
76
+ "Sec-CH-Prefers-Contrast",
77
+ "Sec-CH-Save-Data",
78
+ "Sec-CH-Viewport-Width",
79
+ "Sec-CH-Viewport-Height",
80
+ "Sec-CH-DPR",
81
+ "Sec-CH-Width",
82
+ "Sec-CH-Downlink",
83
+ "Sec-CH-RTT",
84
+ "Sec-CH-ECT",
85
+ ]);
86
+
87
+ var KNOWN_HINTS_LC = {};
88
+ for (var _h = 0; _h < KNOWN_HINTS.length; _h += 1) {
89
+ KNOWN_HINTS_LC[KNOWN_HINTS[_h].toLowerCase()] = KNOWN_HINTS[_h];
90
+ }
91
+
92
+ function _scanControlBytes(s, headerName) {
93
+ structuredFields.refuseControlBytes(s, {
94
+ ErrorClass: ClientHintsError,
95
+ code: "client-hints/bad-header-value",
96
+ label: "parse: " + headerName,
97
+ });
98
+ }
99
+
100
+ // RFC 8941 §3.3.3 sf-string — quoted-string with backslash-escape for
101
+ // `"` and `\`. Defensive parser that tolerates unquoted bare tokens
102
+ // (some operators forward a header whose quote-shape was already
103
+ // stripped by an upstream proxy).
104
+ function _parseSfString(s) {
105
+ var t = s.trim();
106
+ if (t.length === 0) return "";
107
+ if (t.charAt(0) === "\"") {
108
+ if (t.charAt(t.length - 1) !== "\"") return null;
109
+ return t.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
110
+ }
111
+ return t;
112
+ }
113
+
114
+ // RFC 8941 §3.3.6 sf-boolean — `?1` (true) / `?0` (false). Returns
115
+ // null for any other shape so callers can detect malformed values.
116
+ function _parseSfBoolean(s) {
117
+ var t = s.trim();
118
+ if (t === "?1") return true;
119
+ if (t === "?0") return false;
120
+ return null;
121
+ }
122
+
123
+ // RFC 8941 §3.1.1 sf-list — comma-separated members, each potentially
124
+ // carrying `;key=value;flag` parameters. For Sec-CH-UA the members are
125
+ // sf-strings with a `;v="<version>"` parameter giving the brand
126
+ // version. Returns `[ { brand, version, params } ]` or `null` for
127
+ // malformed input.
128
+ function _parseSfBrandList(s) {
129
+ var t = s.trim();
130
+ if (t.length === 0) return [];
131
+ // Walk via the shared quote-aware top-level `,` splitter. RFC 8941
132
+ // sf-list members don't allow parenthesized inner-list values in
133
+ // the Sec-CH-UA grammar (only sf-string + parameters), so the
134
+ // simple top-level comma split suffices — no `depth` tracking
135
+ // needed (the earlier inline shape carried defensive paren
136
+ // tracking left over from a generic sf-list walker prototype).
137
+ var pieces = structuredFields.splitTopLevel(t, ",");
138
+ var out = [];
139
+ for (var i = 0; i < pieces.length; i += 1) {
140
+ var piece = pieces[i].trim();
141
+ if (piece.length === 0) continue;
142
+ var parsed = _parseBrandMember(piece);
143
+ if (parsed !== null) out.push(parsed);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function _parseBrandMember(piece) {
149
+ // member ::= sf-string ( ';' parameter )*
150
+ // RFC 8941 §4.1.1.4 — parameter values can be sf-string. A bare
151
+ // `piece.split(";")` would slice through `;v="1.2; 3"` and corrupt
152
+ // the parameter value. Use the shared quote-aware splitter so the
153
+ // tracking shape stays consistent across every framework parser.
154
+ var params = structuredFields.splitTopLevel(piece, ";");
155
+ if (params.length === 0) return null;
156
+ var brand = _parseSfString(params[0].trim());
157
+ if (brand === null) return null;
158
+ var member = { brand: brand, version: null, params: {} };
159
+ for (var i = 1; i < params.length; i += 1) {
160
+ var kv = params[i].trim();
161
+ if (kv.length === 0) continue;
162
+ var eq = kv.indexOf("=");
163
+ if (eq === -1) { member.params[kv.toLowerCase()] = true; continue; }
164
+ var k = kv.slice(0, eq).trim().toLowerCase();
165
+ var v = _parseSfString(kv.slice(eq + 1));
166
+ member.params[k] = v;
167
+ if (k === "v" && v !== null) member.version = v;
168
+ }
169
+ return member;
170
+ }
171
+
172
+ /**
173
+ * @primitive b.clientHints.parse
174
+ * @signature b.clientHints.parse(headers)
175
+ * @since 0.8.91
176
+ * @status stable
177
+ * @related b.clientHints.acceptList, b.clientHints.isKnownHint
178
+ *
179
+ * Parse the Sec-CH-UA-* family from an HTTP request's headers object.
180
+ * `headers` is the Node `req.headers` shape (header names are
181
+ * already lowercased per Node convention). Returns a normalized
182
+ * `{ brands, mobile, platform, platformVersion, arch, bitness, model,
183
+ * fullVersionList, wow64, formFactors, raw }` shape:
184
+ *
185
+ * - `brands`: `[ { brand, version, params } ]` from `Sec-CH-UA`
186
+ * - `mobile`: boolean from `Sec-CH-UA-Mobile` (?1/?0) — null if absent / malformed
187
+ * - `platform`: sf-string from `Sec-CH-UA-Platform`
188
+ * - `platformVersion`: sf-string from `Sec-CH-UA-Platform-Version`
189
+ * - `arch`: sf-string from `Sec-CH-UA-Arch`
190
+ * - `bitness`: sf-string from `Sec-CH-UA-Bitness`
191
+ * - `model`: sf-string from `Sec-CH-UA-Model`
192
+ * - `fullVersionList`: brand-list from `Sec-CH-UA-Full-Version-List`
193
+ * - `wow64`: boolean from `Sec-CH-UA-WoW64`
194
+ * - `formFactors`: brand-list from `Sec-CH-UA-Form-Factors`
195
+ * - `raw`: `{ "<header-name-lc>": "<raw-value>" }` for every Sec-CH-*
196
+ * header in the input, so operators can audit the full set without
197
+ * re-walking `req.headers`.
198
+ *
199
+ * Returns `null` when `headers` is not an object. Individual fields
200
+ * are `null` when the corresponding header is absent or malformed
201
+ * (defensive request-shape reader). Refuses control characters in
202
+ * any present Sec-CH-* value (header-injection defense).
203
+ *
204
+ * @example
205
+ * var ch = b.clientHints.parse(req.headers);
206
+ * if (ch && ch.mobile === true) renderMobilePage(req, res);
207
+ * else if (ch && ch.platform === "Windows") renderWindowsPage(req, res);
208
+ * else renderDefaultPage(req, res);
209
+ */
210
+ function parse(headers) {
211
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return null;
212
+ var raw = {};
213
+ var keys = Object.keys(headers);
214
+ for (var k = 0; k < keys.length; k += 1) {
215
+ var name = keys[k].toLowerCase();
216
+ if (name.indexOf("sec-ch-") !== 0) continue;
217
+ var val = headers[keys[k]];
218
+ if (val === undefined || val === null) continue;
219
+ if (typeof val !== "string") val = String(val);
220
+ _scanControlBytes(val, name);
221
+ raw[name] = val;
222
+ }
223
+
224
+ return {
225
+ brands: raw["sec-ch-ua"] ? _parseSfBrandList(raw["sec-ch-ua"]) : null,
226
+ mobile: raw["sec-ch-ua-mobile"] !== undefined ? _parseSfBoolean(raw["sec-ch-ua-mobile"]) : null,
227
+ platform: raw["sec-ch-ua-platform"] !== undefined ? _parseSfString(raw["sec-ch-ua-platform"]) : null,
228
+ platformVersion: raw["sec-ch-ua-platform-version"] !== undefined ? _parseSfString(raw["sec-ch-ua-platform-version"]) : null,
229
+ arch: raw["sec-ch-ua-arch"] !== undefined ? _parseSfString(raw["sec-ch-ua-arch"]) : null,
230
+ bitness: raw["sec-ch-ua-bitness"] !== undefined ? _parseSfString(raw["sec-ch-ua-bitness"]) : null,
231
+ model: raw["sec-ch-ua-model"] !== undefined ? _parseSfString(raw["sec-ch-ua-model"]) : null,
232
+ fullVersionList: raw["sec-ch-ua-full-version-list"] ? _parseSfBrandList(raw["sec-ch-ua-full-version-list"]) : null,
233
+ wow64: raw["sec-ch-ua-wow64"] !== undefined ? _parseSfBoolean(raw["sec-ch-ua-wow64"]) : null,
234
+ formFactors: raw["sec-ch-ua-form-factors"] ? _parseSfBrandList(raw["sec-ch-ua-form-factors"]) : null,
235
+ raw: raw,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * @primitive b.clientHints.acceptList
241
+ * @signature b.clientHints.acceptList(hintNames)
242
+ * @since 0.8.91
243
+ * @status stable
244
+ * @related b.clientHints.parse, b.clientHints.isKnownHint
245
+ *
246
+ * Build the `Accept-CH` response header value advertising which
247
+ * client-hint request headers the operator wants the browser to
248
+ * include on subsequent requests. `hintNames` is an array of
249
+ * canonical Sec-CH-* header names; the primitive refuses unknown
250
+ * hint names (typo defense — `accept-ch: Sec-CH-UA-Plateform`
251
+ * silently neuters the negotiation).
252
+ *
253
+ * Operators set `Accept-CH` on the HTML document response. The
254
+ * browser sends the listed hints on every subsequent same-origin
255
+ * navigation / sub-resource request.
256
+ *
257
+ * @example
258
+ * res.setHeader("Accept-CH", b.clientHints.acceptList([
259
+ * "Sec-CH-UA-Platform",
260
+ * "Sec-CH-UA-Platform-Version",
261
+ * "Sec-CH-UA-Mobile",
262
+ * ]));
263
+ * // → "Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Mobile"
264
+ */
265
+ function acceptList(hintNames) {
266
+ if (!Array.isArray(hintNames) || hintNames.length === 0) {
267
+ throw new ClientHintsError("client-hints/bad-hint-list",
268
+ "acceptList: hintNames must be a non-empty array of Sec-CH-* names");
269
+ }
270
+ var seen = {};
271
+ var out = [];
272
+ for (var i = 0; i < hintNames.length; i += 1) {
273
+ var n = hintNames[i];
274
+ if (typeof n !== "string" || n.length === 0) {
275
+ throw new ClientHintsError("client-hints/bad-hint-name",
276
+ "acceptList: hintNames[" + i + "] must be a non-empty string");
277
+ }
278
+ var canonical = KNOWN_HINTS_LC[n.toLowerCase()];
279
+ if (!canonical) {
280
+ throw new ClientHintsError("client-hints/unknown-hint",
281
+ "acceptList: '" + n + "' is not a known client-hint header " +
282
+ "(see b.clientHints.KNOWN_HINTS for the list)");
283
+ }
284
+ if (seen[canonical]) continue;
285
+ seen[canonical] = true;
286
+ out.push(canonical);
287
+ }
288
+ return out.join(", ");
289
+ }
290
+
291
+ /**
292
+ * @primitive b.clientHints.isKnownHint
293
+ * @signature b.clientHints.isKnownHint(headerName)
294
+ * @since 0.8.91
295
+ * @status stable
296
+ *
297
+ * Returns `true` when `headerName` matches one of the well-known
298
+ * Sec-CH-* hint headers (case-insensitive). Operators auditing
299
+ * inbound headers walk the request and call this to identify
300
+ * negotiation-related hints without keyword-matching.
301
+ *
302
+ * @example
303
+ * b.clientHints.isKnownHint("Sec-CH-UA-Mobile"); // → true
304
+ * b.clientHints.isKnownHint("sec-ch-ua-platform"); // → true
305
+ * b.clientHints.isKnownHint("X-Custom"); // → false
306
+ */
307
+ function isKnownHint(headerName) {
308
+ if (typeof headerName !== "string" || headerName.length === 0) return false;
309
+ return Object.prototype.hasOwnProperty.call(KNOWN_HINTS_LC, headerName.toLowerCase());
310
+ }
311
+
312
+ module.exports = {
313
+ parse: parse,
314
+ acceptList: acceptList,
315
+ isKnownHint: isKnownHint,
316
+ KNOWN_HINTS: KNOWN_HINTS,
317
+ ClientHintsError: ClientHintsError,
318
+ };
@@ -42,10 +42,11 @@
42
42
  * stale-while-revalidate / stale-if-error.
43
43
  */
44
44
 
45
- var C = require("./constants");
46
- var canonicalJson = require("./canonical-json");
47
- var safeUrl = require("./safe-url");
48
- var validateOpts = require("./validate-opts");
45
+ var C = require("./constants");
46
+ var canonicalJson = require("./canonical-json");
47
+ var safeUrl = require("./safe-url");
48
+ var structuredFields = require("./structured-fields");
49
+ var validateOpts = require("./validate-opts");
49
50
  var { HttpClientError } = require("./framework-error");
50
51
 
51
52
  // ---- Tunables ----------------------------------------------------------
@@ -90,7 +91,13 @@ function _hcErr(code, message) {
90
91
  function _parseCacheControl(value) {
91
92
  var out = Object.create(null);
92
93
  if (typeof value !== "string" || value.length === 0) return out;
93
- var parts = value.split(",");
94
+ // RFC 9111 §5.2 + RFC 9110 §5.6.4 — directive arguments may be
95
+ // quoted-string. A bare `value.split(",")` would slice through
96
+ // `no-cache="Authorization, Cookie"` and `private="set-cookie,
97
+ // x-foo"` and emit fake directives. Quote-aware splitter mirrors
98
+ // cdn-cache-control's _splitTopLevelCommas (RFC 8941 §3.3.3 with
99
+ // backslash-escape).
100
+ var parts = structuredFields.splitTopLevel(value, ",");
94
101
  for (var i = 0; i < parts.length; i++) {
95
102
  var p = parts[i].trim();
96
103
  if (!p) continue;
@@ -100,7 +107,7 @@ function _parseCacheControl(value) {
100
107
  else { k = p.slice(0, eq).trim(); v = p.slice(eq + 1).trim(); }
101
108
  // Strip surrounding quotes from value.
102
109
  if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
103
- v = v.slice(1, v.length - 1);
110
+ v = v.slice(1, v.length - 1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
104
111
  }
105
112
  out[k.toLowerCase()] = v;
106
113
  }
@@ -202,7 +209,7 @@ function _buildCacheKey(method, url, varyHeaderValues) {
202
209
  // uncacheable.
203
210
  function _extractVaryValues(varyHeader, requestHeaders) {
204
211
  if (typeof varyHeader !== "string" || varyHeader.length === 0) return [];
205
- var names = varyHeader.split(",").map(function (s) {
212
+ var names = varyHeader.split(",").map(function (s) { // allow:bare-split-on-quoted-header — RFC 9110 §12.5.5 Vary is a comma-list of field-names (token grammar); no quoted-string
206
213
  return s.trim().toLowerCase();
207
214
  }).filter(function (s) { return s.length > 0; });
208
215
  if (names.indexOf("*") !== -1) return null; // sentinel: "uncacheable"
@@ -252,7 +259,7 @@ function _evaluateStorage(method, statusCode, responseHeaders, sharedCache) {
252
259
 
253
260
  // Vary: * is uncacheable per RFC 9110 §12.5.5.
254
261
  if (typeof varyHeader === "string" && varyHeader.indexOf("*") !== -1) {
255
- var trimmed = varyHeader.split(",").map(function (s) { return s.trim(); });
262
+ var trimmed = varyHeader.split(",").map(function (s) { return s.trim(); }); // allow:bare-split-on-quoted-header — RFC 9110 §12.5.5 Vary field-names; token grammar only
256
263
  if (trimmed.indexOf("*") !== -1) {
257
264
  return { cacheable: false, reason: "vary-star", freshnessMs: -1, directives: directives, varyHeader: varyHeader };
258
265
  }
@@ -62,12 +62,13 @@
62
62
 
63
63
  var fs = require("node:fs");
64
64
  var path = require("node:path");
65
- var C = require("./constants");
66
- var numericBounds = require("./numeric-bounds");
67
- var safeAsync = require("./safe-async");
68
- var safeJson = require("./safe-json");
69
- var safeUrl = require("./safe-url");
70
- var validateOpts = require("./validate-opts");
65
+ var C = require("./constants");
66
+ var numericBounds = require("./numeric-bounds");
67
+ var safeAsync = require("./safe-async");
68
+ var safeJson = require("./safe-json");
69
+ var safeUrl = require("./safe-url");
70
+ var structuredFields = require("./structured-fields");
71
+ var validateOpts = require("./validate-opts");
71
72
  var { defineClass } = require("./framework-error");
72
73
 
73
74
  var CookieJarError = defineClass("CookieJarError", { alwaysPermanent: true });
@@ -102,7 +103,12 @@ function _parseSetCookie(line) {
102
103
  var attrs = {};
103
104
  if (semi !== -1) {
104
105
  var rest = line.slice(semi + 1);
105
- var parts = rest.split(";");
106
+ // RFC 6265 §4.1 attribute values are token-only by spec, but
107
+ // interop reality is that some servers emit quoted attr values
108
+ // (e.g. `; SameSite="Strict"` from older middleware). Quote-aware
109
+ // split preserves a quoted `;` inside an attr value if anyone
110
+ // ever sends one — defensive, not bug-fixing.
111
+ var parts = structuredFields.splitTopLevel(rest, ";");
106
112
  for (var i = 0; i < parts.length; i++) {
107
113
  var p = parts[i].trim();
108
114
  if (!p) continue;
@@ -110,6 +116,11 @@ function _parseSetCookie(line) {
110
116
  var k, v;
111
117
  if (pi === -1) { k = p; v = ""; }
112
118
  else { k = p.slice(0, pi).trim(); v = p.slice(pi + 1).trim(); }
119
+ // Strip surrounding quotes from attribute value when present
120
+ // (defensive against interop). RFC 6265 §4.1 does not require
121
+ // this, but doesn't forbid the operator's parser absorbing it.
122
+ var _unq = structuredFields.unquoteSfString(v);
123
+ if (_unq !== null) v = _unq;
113
124
  attrs[k.toLowerCase()] = v;
114
125
  }
115
126
  }
@@ -59,12 +59,13 @@
59
59
  * // → { valid, label, keyid, alg, covered, reason? }
60
60
  */
61
61
 
62
- var nodeCrypto = require("crypto");
63
- var safeUrl = require("./safe-url");
64
- var safeBuffer = require("./safe-buffer");
65
- var C = require("./constants");
66
- var lazyRequire = require("./lazy-require");
67
- var validateOpts = require("./validate-opts");
62
+ var nodeCrypto = require("crypto");
63
+ var safeUrl = require("./safe-url");
64
+ var safeBuffer = require("./safe-buffer");
65
+ var C = require("./constants");
66
+ var lazyRequire = require("./lazy-require");
67
+ var structuredFields = require("./structured-fields");
68
+ var validateOpts = require("./validate-opts");
68
69
  var { HttpSigError } = require("./framework-error");
69
70
 
70
71
  var _err = HttpSigError.factory;
@@ -317,7 +318,7 @@ function sign(msg, opts) {
317
318
  // header isn't already supplied. Operators wanting to use the
318
319
  // RFC 9530 "sha-512" identifier (SHA-512 instead of SHA3-512) supply
319
320
  // the header themselves; the framework emits SHA3-512.
320
- var coveredLower = opts.covered.map(function (c) { return c.split(";")[0].toLowerCase(); });
321
+ var coveredLower = opts.covered.map(function (c) { return c.split(";")[0].toLowerCase(); }); // allow:bare-split-on-quoted-header — opts.covered is operator-supplied component-id list (e.g. "content-digest;sf"); component identifiers are RFC 9421 §2.1 derived-field names with token-only grammar; no quoted-string
321
322
  if (coveredLower.indexOf("content-digest") !== -1 &&
322
323
  _resolveHeader(m.headers, "content-digest") === null) {
323
324
  if (m.body == null) {
@@ -416,7 +417,12 @@ function _parseSignatureInput(headerValue) {
416
417
 
417
418
  var params = {};
418
419
  if (paramsRaw.length > 0) {
419
- var paramParts = paramsRaw.split(";");
420
+ // RFC 9421 §2.3 + RFC 8941 §3.1.2 — parameter values may be
421
+ // sf-string. A bare `paramsRaw.split(";")` would slice through a
422
+ // legitimate `;tag="x;y"` parameter. Quote-aware splitter
423
+ // mirrors cdn-cache-control._splitTopLevelCommas (RFC 8941
424
+ // §3.3.3 quoted-string state with backslash-escape).
425
+ var paramParts = structuredFields.splitTopLevel(paramsRaw, ";");
420
426
  for (var j = 0; j < paramParts.length; j++) {
421
427
  var part = paramParts[j].trim();
422
428
  if (part.length === 0) continue;
@@ -425,7 +431,8 @@ function _parseSignatureInput(headerValue) {
425
431
  var k = part.slice(0, pEq).trim();
426
432
  var vv = part.slice(pEq + 1).trim();
427
433
  if (vv.charAt(0) === "\"" && vv.charAt(vv.length - 1) === "\"") {
428
- params[k] = vv.slice(1, -1);
434
+ var _unq = structuredFields.unquoteSfString(vv);
435
+ params[k] = _unq === null ? vv : _unq;
429
436
  } else {
430
437
  var num = Number(vv);
431
438
  params[k] = isFinite(num) ? num : vv;
@@ -441,7 +448,7 @@ function _parseSignature(headerValue, label) {
441
448
  if (headerValue.indexOf(prefix) !== 0) {
442
449
  // Multiple signature labels can appear; comma-separated. Find the
443
450
  // matching label.
444
- var parts = headerValue.split(",");
451
+ var parts = headerValue.split(","); // allow:bare-split-on-quoted-header — RFC 9421 §2.4 Signature header values are `label=:b64:` form; base64 alphabet excludes `,` and the label tokens are RFC 8941 §3.3.4 sf-token (no DQUOTE in practice)
445
452
  for (var i = 0; i < parts.length; i++) {
446
453
  var p = parts[i].trim();
447
454
  if (p.indexOf(prefix) === 0) {
@@ -509,7 +516,7 @@ function verify(msg, opts) {
509
516
  // If content-digest is covered, recompute and compare. RFC 9421 §B.2.5
510
517
  // mandates that verifiers re-run the digest over the body — a stale
511
518
  // header from a proxy would otherwise verify trivially.
512
- var coveredLower = parsedInput.covered.map(function (c) { return c.split(";")[0].toLowerCase(); });
519
+ var coveredLower = parsedInput.covered.map(function (c) { return c.split(";")[0].toLowerCase(); }); // allow:bare-split-on-quoted-header — same as sign() above: covered items are RFC 9421 §2.1 component-ids, token grammar
513
520
  if (coveredLower.indexOf("content-digest") !== -1) {
514
521
  if (m.body == null) {
515
522
  return { valid: false, reason: "content-digest-no-body" };
package/lib/log-stream.js CHANGED
@@ -133,15 +133,39 @@ function init(opts) {
133
133
  if (initialized) return;
134
134
  if (!opts || !opts.sinks) throw new Error("logStream.init({ sinks }) is required");
135
135
 
136
+ // Validate top-level minLevel at config time so a typo (`"infos"`)
137
+ // doesn't silently produce `LEVEL_PRIORITY["infos"] === undefined`
138
+ // and drop every record at runtime (an `X >= undefined` compare
139
+ // is always false). Throw rather than fall back to a default —
140
+ // operators want a loud failure at boot, not silent log loss.
141
+ if (opts.minLevel !== undefined && opts.minLevel !== null) {
142
+ var topLevel = String(opts.minLevel).toLowerCase();
143
+ if (LEVELS.indexOf(topLevel) === -1) {
144
+ throw _err("INVALID_LEVEL",
145
+ "logStream.init: opts.minLevel '" + opts.minLevel +
146
+ "' must be one of " + LEVELS.join(", "), true);
147
+ }
148
+ }
149
+
136
150
  sinks = {};
137
151
  for (var name in opts.sinks) {
138
152
  var cfg = opts.sinks[name];
139
153
  var proto = dispatcher.resolve(cfg.protocol);
154
+ // Same gate per-sink so a misconfigured filter doesn't silently
155
+ // drop records from a single sink while every other sink works.
156
+ if (cfg.minLevel !== undefined && cfg.minLevel !== null) {
157
+ var sinkLvl = String(cfg.minLevel).toLowerCase();
158
+ if (LEVELS.indexOf(sinkLvl) === -1) {
159
+ throw _err("INVALID_LEVEL",
160
+ "logStream.init: sink '" + name + "' minLevel '" + cfg.minLevel +
161
+ "' must be one of " + LEVELS.join(", "), true);
162
+ }
163
+ }
140
164
  sinks[name] = {
141
165
  name: name,
142
166
  protocol: cfg.protocol,
143
167
  raw: proto.create(cfg),
144
- levelFilter: cfg.minLevel || null,
168
+ levelFilter: cfg.minLevel ? String(cfg.minLevel).toLowerCase() : null,
145
169
  };
146
170
  }
147
171
 
package/lib/mail-auth.js CHANGED
@@ -379,7 +379,7 @@ var DMARCBIS_VALID_PSD = { y: 1, n: 1, u: 1 };
379
379
  function _parseDmarcRecord(text) {
380
380
  var policy = { v: null, p: null, sp: null, np: null, psd: null,
381
381
  pct: 100, adkim: "r", aspf: "r" }; // allow:raw-byte-literal — RFC 7489 default pct
382
- var pairs = text.split(";");
382
+ var pairs = text.split(";"); // allow:bare-split-on-quoted-header — RFC 7489 §6.4 DMARC tag-list grammar: `tag-spec *( ";" tag-spec )` with tag-value = 0*( tval *( WSP / FWS ) ); NO quoted-string allowed
383
383
  for (var i = 0; i < pairs.length; i += 1) {
384
384
  var kv = pairs[i].trim();
385
385
  if (kv.length === 0) continue;
@@ -950,7 +950,8 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
950
950
 
951
951
  function _parseArcTagList(value) {
952
952
  var tags = {};
953
- var parts = String(value).split(";");
953
+ var parts = String(value).split(";"); // allow:bare-split-on-quoted-header — allow:raw-byte-literal — RFC 8617 §4 ARC tag-list grammar (same as the DKIM RFC's): `tag-spec *( ";" tag-spec )`, tag-value contains no DQUOTE
954
+
954
955
  for (var i = 0; i < parts.length; i += 1) {
955
956
  var p = parts[i].trim();
956
957
  if (p.length === 0) continue;
@@ -52,7 +52,8 @@
52
52
  * RFC 8689 REQUIRETLS — per-message TLS-requirement signaling between MTAs (EHLO keyword + MAIL FROM extension + TLS-Required header parser).
53
53
  */
54
54
 
55
- var validateOpts = require("./validate-opts");
55
+ var structuredFields = require("./structured-fields");
56
+ var validateOpts = require("./validate-opts");
56
57
  var { defineClass } = require("./framework-error");
57
58
 
58
59
  var RequireTlsError = defineClass("RequireTlsError", { alwaysPermanent: true });
@@ -172,23 +173,11 @@ function mailFromExtension(opts) {
172
173
  */
173
174
  function parseTlsRequiredHeader(headerValue) {
174
175
  if (typeof headerValue !== "string") return null;
175
- // Refuse control characters defensively on the RAW value — scanning
176
- // after trim() would strip leading/trailing \r\n\t etc. before the
177
- // check ran, letting a header-injection-shape input like "\nno" or
178
- // "no\r" slip past as the literal "no" token. Validate the original
179
- // string so the contract ("control bytes are refused") holds for any
180
- // position in the value.
181
- for (var i = 0; i < headerValue.length; i += 1) {
182
- var code = headerValue.charCodeAt(i);
183
- // ASCII HT (0x09) is structural folding whitespace in HTTP/email
184
- // headers — strip-equivalent at the parser layer, so the trim()
185
- // below absorbs it. Everything else in C0 + DEL is rejected.
186
- if (code === 9) continue; // allow:raw-byte-literal — ASCII HT codepoint
187
- if (code < 32 || code === 127) { // allow:raw-byte-literal — C0 + DEL codepoint range
188
- throw new RequireTlsError("mail-require-tls/bad-header-value",
189
- "parseTlsRequiredHeader: value contains control characters");
190
- }
191
- }
176
+ structuredFields.refuseControlBytes(headerValue, {
177
+ ErrorClass: RequireTlsError,
178
+ code: "mail-require-tls/bad-header-value",
179
+ label: "parseTlsRequiredHeader",
180
+ });
192
181
  var trimmed = headerValue.trim();
193
182
  if (trimmed.length === 0) return null;
194
183
  if (trimmed.toLowerCase() === "no") return "no";