@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.
@@ -112,13 +112,14 @@ var fs = require("fs");
112
112
  var os = require("os");
113
113
  var path = require("path");
114
114
  var nodeCrypto = require("node:crypto");
115
- var atomicFile = require("../atomic-file");
116
- var crypto = require("../crypto");
117
- var lazyRequire = require("../lazy-require");
118
- var requestHelpers = require("../request-helpers");
119
- var safeBuffer = require("../safe-buffer");
120
- var safeJson = require("../safe-json");
121
- var validateOpts = require("../validate-opts");
115
+ var atomicFile = require("../atomic-file");
116
+ var crypto = require("../crypto");
117
+ var lazyRequire = require("../lazy-require");
118
+ var requestHelpers = require("../request-helpers");
119
+ var safeBuffer = require("../safe-buffer");
120
+ var safeJson = require("../safe-json");
121
+ var structuredFields = require("../structured-fields");
122
+ var validateOpts = require("../validate-opts");
122
123
  var C = require("../constants");
123
124
  var { defineClass } = require("../framework-error");
124
125
 
@@ -214,14 +215,20 @@ function _contentType(req) {
214
215
  var params = {};
215
216
  if (idx !== -1) {
216
217
  var rest = ct.slice(idx + 1);
217
- var parts = rest.split(";");
218
+ // RFC 9110 §8.3 + §5.6.6 — parameter values may be quoted-string
219
+ // (e.g. `boundary="foo;bar"`, `charset="x;y"`). Bare `.split(";")`
220
+ // would slice through quoted commas/semicolons and corrupt the
221
+ // multipart boundary. Use the shared quote-aware splitter that
222
+ // tracks RFC 8941 §3.3.3 quoted-string state with backslash-escape.
223
+ var parts = structuredFields.splitTopLevel(rest, ";");
218
224
  for (var i = 0; i < parts.length; i++) {
219
225
  var p = parts[i].trim();
220
226
  var eq = p.indexOf("=");
221
227
  if (eq === -1) continue;
222
228
  var k = p.slice(0, eq).trim().toLowerCase();
223
229
  var v = p.slice(eq + 1).trim();
224
- if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
230
+ var _unq = structuredFields.unquoteSfString(v);
231
+ if (_unq !== null) v = _unq;
225
232
  params[k] = v;
226
233
  }
227
234
  }
@@ -305,7 +312,7 @@ function _detectSmuggling(req) {
305
312
  // (RFC 9112 §6.1). Anything else is a smuggling vector or
306
313
  // server-side decode error.
307
314
  if (typeof te === "string" && te.length > 0) {
308
- var tokens = te.toLowerCase().split(",").map(function (t) { return t.trim(); });
315
+ var tokens = te.toLowerCase().split(",").map(function (t) { return t.trim(); }); // allow:bare-split-on-quoted-header — RFC 9112 §6.1 Transfer-Encoding values (chunked / gzip / deflate / identity) are token-only; no quoted-string in the grammar
309
316
  var last = tokens[tokens.length - 1];
310
317
  if (last !== "chunked") {
311
318
  return {
@@ -621,7 +628,11 @@ function _parseHeaderParams(headerValue) {
621
628
  // `filename` so downstream consumers don't need parser-aware code.
622
629
  var out = { _value: "" };
623
630
  if (!headerValue) return out;
624
- var parts = headerValue.split(";");
631
+ // RFC 6266 §4.1 + RFC 9110 §5.6.6 — parameter values may be
632
+ // quoted-string (e.g. `filename="weird;name.txt"`). Bare
633
+ // `.split(";")` would slice through the quoted semicolon and
634
+ // corrupt the filename. Quote-aware shared splitter.
635
+ var parts = structuredFields.splitTopLevel(headerValue, ";");
625
636
  out._value = parts[0].trim().toLowerCase();
626
637
  var extName = null;
627
638
  for (var i = 1; i < parts.length; i++) {
@@ -630,7 +641,8 @@ function _parseHeaderParams(headerValue) {
630
641
  if (eq === -1) continue;
631
642
  var k = p.slice(0, eq).trim().toLowerCase();
632
643
  var v = p.slice(eq + 1).trim();
633
- if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
644
+ var _unq = structuredFields.unquoteSfString(v);
645
+ if (_unq !== null) v = _unq;
634
646
  if (k.charAt(k.length - 1) === "*") {
635
647
  var decoded = _decodeRfc5987(v);
636
648
  if (decoded !== null) {
@@ -177,8 +177,8 @@ async function _dispatch(req, res, basePath, bearer, opts, maxPageSize) {
177
177
  count: pageSize,
178
178
  sortBy: query.sortBy || null,
179
179
  sortOrder: query.sortOrder || null,
180
- attributes: query.attributes ? query.attributes.split(",") : null,
181
- excludedAttributes: query.excludedAttributes ? query.excludedAttributes.split(",") : null,
180
+ attributes: query.attributes ? query.attributes.split(",") : null, // allow:bare-split-on-quoted-header — RFC 7644 §3.9 attributes/excludedAttributes are SCIM attribute paths (URN-ish identifiers); grammar excludes DQUOTE
181
+ excludedAttributes: query.excludedAttributes ? query.excludedAttributes.split(",") : null, // allow:bare-split-on-quoted-header — same SCIM attribute-name grammar
182
182
  }, ctx);
183
183
  _writeJson(res, H.OK, {
184
184
  schemas: [SCIM_MESSAGE_LIST],
@@ -40,13 +40,14 @@
40
40
  * cannot satisfy.
41
41
  */
42
42
 
43
- var nodeCrypto = require("crypto"); // for createHash() in checksum extension
44
- var C = require("../constants");
45
- var bCrypto = require("../crypto");
46
- var lazyRequire = require("../lazy-require");
47
- var safeAsync = require("../safe-async");
48
- var safeBuffer = require("../safe-buffer");
49
- var validateOpts = require("../validate-opts");
43
+ var nodeCrypto = require("crypto"); // for createHash() in checksum extension
44
+ var C = require("../constants");
45
+ var bCrypto = require("../crypto");
46
+ var lazyRequire = require("../lazy-require");
47
+ var safeAsync = require("../safe-async");
48
+ var safeBuffer = require("../safe-buffer");
49
+ var structuredFields = require("../structured-fields");
50
+ var validateOpts = require("../validate-opts");
50
51
  var { defineClass } = require("../framework-error");
51
52
 
52
53
  // Observability metric prefix for the TUS middleware. The framework
@@ -145,6 +146,10 @@ function _serializeMetadata(metaObj) {
145
146
  function _parseChecksumHeader(headerValue, allowedSet) {
146
147
  // tus.io 1.0.0 §3.5: `Upload-Checksum: <algo> <base64-digest>`.
147
148
  if (typeof headerValue !== "string") return null;
149
+ // The tus.io grammar implicitly excludes C0 / DEL (token + base64
150
+ // alphabet); refuse those on the RAW value BEFORE the slice/trim
151
+ // normalisation (same v0.8.90 trim-before-validate bug class).
152
+ if (structuredFields.containsControlBytes(headerValue)) return { error: "malformed" };
148
153
  var sp = headerValue.indexOf(" ");
149
154
  if (sp === -1) return { error: "malformed" };
150
155
  var algo = headerValue.slice(0, sp).trim().toLowerCase();
@@ -1708,9 +1708,187 @@ function isNullMx(mxRecords) {
1708
1708
  return only.exchange === "" || only.exchange === ".";
1709
1709
  }
1710
1710
 
1711
+ // RFC 9905 — Deprecating DNSSEC SHA-1 Usage. The IANA DNSSEC Algorithm
1712
+ // Numbers registry classifies SHA-1-based DNSKEY algorithms (5
1713
+ // RSASHA1, 7 RSASHA1-NSEC3-SHA1, 10 RSASHA512-using-SHA1-NSEC3) and
1714
+ // SHA-1 DS digest type 1 as "MUST NOT be used" / "MUST NOT be
1715
+ // supported". Operators auditing inbound DNSSEC chain-of-trust data
1716
+ // classify a record's algorithm number to decide whether to refuse
1717
+ // the validation as deprecated.
1718
+ //
1719
+ // Returns the classification verdict object:
1720
+ // {
1721
+ // deprecated: boolean, // true when SHA-1 family per RFC 9905 §3-§4
1722
+ // algorithm: number, // echo of input
1723
+ // name: string, // human-readable label
1724
+ // reason: string, // citation
1725
+ // }
1726
+ // for any IANA DNSKEY algorithm number, or null for unknown / non-
1727
+ // numeric input. Defensive request-shape reader — never throws.
1728
+
1729
+ /**
1730
+ * @primitive b.network.dns.classifyDnskeyAlgorithm
1731
+ * @signature b.network.dns.classifyDnskeyAlgorithm(algorithm)
1732
+ * @since 0.8.91
1733
+ * @status stable
1734
+ * @related b.network.dns.classifyDsDigestType, b.network.dns.isNullMx
1735
+ *
1736
+ * Classify a DNSKEY / RRSIG algorithm number against the IANA DNS
1737
+ * Security Algorithm Numbers registry, flagging SHA-1-based and
1738
+ * other deprecated algorithms per RFC 9905 (Deprecating DNSSEC
1739
+ * SHA-1 Usage), RFC 8624 (Algorithm Implementation Requirements),
1740
+ * and RFC 6944 / RFC 6725 (RSAMD5 deprecation).
1741
+ *
1742
+ * Returns `{ algorithm, name, deprecated, reason, known }` for any
1743
+ * IANA-assigned number; `known: false` for unassigned numbers
1744
+ * (operators decide whether unassigned == deprecated for their
1745
+ * threat model). Returns `null` for non-integer / non-finite input.
1746
+ *
1747
+ * Operators auditing inbound DNSSEC chain-of-trust evidence call
1748
+ * this on each link's algorithm number and refuse the validation
1749
+ * when `deprecated === true`. Defensive request-shape reader —
1750
+ * never throws.
1751
+ *
1752
+ * @example
1753
+ * var v = b.network.dns.classifyDnskeyAlgorithm(5);
1754
+ * // → { algorithm: 5, name: "RSASHA1", deprecated: true,
1755
+ * // reason: "SHA-1 deprecated (RFC 9905 §3)", known: true }
1756
+ * if (v && v.deprecated) throw new Error("refuse DNSSEC algo " + v.name);
1757
+ *
1758
+ * b.network.dns.classifyDnskeyAlgorithm(13);
1759
+ * // → { algorithm: 13, name: "ECDSAP256SHA256", deprecated: false, ... }
1760
+ */
1761
+
1762
+ // Canonical DNSKEY algorithm vocabulary (IANA DNS Security Algorithm
1763
+ // Numbers registry — https://www.iana.org/assignments/dns-sec-alg-numbers).
1764
+ // Operators looking up the human-readable label or computing whether
1765
+ // the framework's own DNSSEC paths use a deprecated algorithm walk
1766
+ // this table. Every IANA-assigned number gets an entry (including
1767
+ // Reserved / Private-use values) so `classifyDnskeyAlgorithm()`
1768
+ // returns `known: true` for the full assigned space; the "Unassigned"
1769
+ // range (17-122, 124-251) is the only set that surfaces as
1770
+ // `known: false`. Marked-deprecated entries cite the controlling
1771
+ // RFC; Reserved / Private-use entries are flagged so operators
1772
+ // auditing DNSSEC chain-of-trust evidence know they cannot validate
1773
+ // the entry against a public algorithm registry.
1774
+ var DNSKEY_ALGORITHMS = Object.freeze({
1775
+ 1: { name: "RSAMD5", deprecated: true, reason: "MD5 broken (RFC 6944 §2.1, RFC 6725)" },
1776
+ 2: { name: "DH", deprecated: true, reason: "Diffie-Hellman key (RFC 2539) — never widely deployed; superseded by signature algorithms" },
1777
+ 3: { name: "DSA", deprecated: true, reason: "DSA deprecated (RFC 8624 §3.1)" },
1778
+ 4: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 4034 §A.1) — not for production use" },
1779
+ 5: { name: "RSASHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3)" },
1780
+ 6: { name: "DSA-NSEC3-SHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3); DSA deprecated (RFC 8624 §3.1)" },
1781
+ 7: { name: "RSASHA1-NSEC3-SHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3)" },
1782
+ 8: { name: "RSASHA256", deprecated: false, reason: "current — RFC 5702" }, // allow:raw-byte-literal — IANA DNSKEY algorithm number
1783
+ 9: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 5155) — not for production use" },
1784
+ 10: { name: "RSASHA512", deprecated: false, reason: "current — RFC 5702" },
1785
+ 11: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 5155) — not for production use" },
1786
+ 12: { name: "ECC-GOST", deprecated: true, reason: "deprecated (RFC 8624 §3.1)" },
1787
+ 13: { name: "ECDSAP256SHA256", deprecated: false, reason: "current — RFC 6605" },
1788
+ 14: { name: "ECDSAP384SHA384", deprecated: false, reason: "current — RFC 6605" },
1789
+ 15: { name: "ED25519", deprecated: false, reason: "current — RFC 8080" },
1790
+ 16: { name: "ED448", deprecated: false, reason: "current — RFC 8080" }, // allow:raw-byte-literal — IANA DNSKEY algorithm number
1791
+ // 17-122: Unassigned per IANA. Operators that see one of these
1792
+ // get known: false from classifyDnskeyAlgorithm() — the entry
1793
+ // is not a typo against the framework table, it's a value the
1794
+ // registry hasn't allocated yet.
1795
+ // 123-251: Reserved per IANA.
1796
+ 252: { name: "INDIRECT", deprecated: true, reason: "Reserved indirect-keys placeholder (RFC 4034 §A.1) — not usable for signing/verification" }, // allow:raw-byte-literal — IANA DNSKEY algorithm number
1797
+ 253: { name: "PRIVATEDNS", deprecated: false, reason: "Private algorithm identified by domain name (RFC 4034 §A.1.1) — operators using this assume the private algorithm itself is acceptable" },
1798
+ 254: { name: "PRIVATEOID", deprecated: false, reason: "Private algorithm identified by OID (RFC 4034 §A.1.2) — operators using this assume the private algorithm itself is acceptable" },
1799
+ 255: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 4034 §A.1) — not for production use" },
1800
+ });
1801
+
1802
+ /**
1803
+ * @primitive b.network.dns.classifyDsDigestType
1804
+ * @signature b.network.dns.classifyDsDigestType(digestType)
1805
+ * @since 0.8.91
1806
+ * @status stable
1807
+ * @related b.network.dns.classifyDnskeyAlgorithm, b.network.dns.isNullMx
1808
+ *
1809
+ * Classify a DS-record digest type against the IANA DNSSEC Delegation
1810
+ * Signer (DS) Resource Record (RR) Type Digest Algorithms registry,
1811
+ * flagging SHA-1 (digest type 1) as deprecated per RFC 9905 §4.
1812
+ *
1813
+ * Returns `{ digestType, name, deprecated, reason, known }` for any
1814
+ * IANA-assigned number; `null` for non-integer input.
1815
+ *
1816
+ * @example
1817
+ * var v = b.network.dns.classifyDsDigestType(1);
1818
+ * // → { digestType: 1, name: "SHA-1", deprecated: true,
1819
+ * // reason: "SHA-1 deprecated (RFC 9905 §4)", known: true }
1820
+ *
1821
+ * b.network.dns.classifyDsDigestType(2);
1822
+ * // → { digestType: 2, name: "SHA-256", deprecated: false, ... }
1823
+ */
1824
+
1825
+ // DS digest-type vocabulary (RFC 4034 §5.1 + RFC 6605 §6 + RFC 8624
1826
+ // §3.2 + RFC 9558). Digest type 1 = SHA-1 is deprecated per RFC 9905
1827
+ // §4. Digest types 5 (GOST R 34.11-2012) and 6 (SM3) added by RFC
1828
+ // 9558. Reserved value 0 surfaced for completeness.
1829
+ var DS_DIGEST_TYPES = Object.freeze({
1830
+ 0: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 3658) — not for production use" },
1831
+ 1: { name: "SHA-1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §4)" },
1832
+ 2: { name: "SHA-256", deprecated: false, reason: "current — RFC 4509" },
1833
+ 3: { name: "GOST R 34.11-94", deprecated: true, reason: "deprecated (RFC 8624 §3.2; superseded by GOST 2012 in RFC 9558)" },
1834
+ 4: { name: "SHA-384", deprecated: false, reason: "current — RFC 6605 §6" },
1835
+ 5: { name: "GOST R 34.11-2012", deprecated: false, reason: "current — RFC 9558 §3" },
1836
+ 6: { name: "SM3", deprecated: false, reason: "current — RFC 9558 §3 (Chinese national standard)" },
1837
+ });
1838
+
1839
+ function classifyDnskeyAlgorithm(algorithm) {
1840
+ if (typeof algorithm !== "number" || !isFinite(algorithm) || Math.floor(algorithm) !== algorithm) {
1841
+ return null;
1842
+ }
1843
+ var row = DNSKEY_ALGORITHMS[algorithm];
1844
+ if (!row) {
1845
+ return {
1846
+ algorithm: algorithm,
1847
+ name: "unassigned",
1848
+ deprecated: false,
1849
+ reason: "no IANA assignment for algorithm " + algorithm,
1850
+ known: false,
1851
+ };
1852
+ }
1853
+ return {
1854
+ algorithm: algorithm,
1855
+ name: row.name,
1856
+ deprecated: row.deprecated,
1857
+ reason: row.reason,
1858
+ known: true,
1859
+ };
1860
+ }
1861
+
1862
+ function classifyDsDigestType(digestType) {
1863
+ if (typeof digestType !== "number" || !isFinite(digestType) || Math.floor(digestType) !== digestType) {
1864
+ return null;
1865
+ }
1866
+ var row = DS_DIGEST_TYPES[digestType];
1867
+ if (!row) {
1868
+ return {
1869
+ digestType: digestType,
1870
+ name: "unassigned",
1871
+ deprecated: false,
1872
+ reason: "no IANA assignment for digest type " + digestType,
1873
+ known: false,
1874
+ };
1875
+ }
1876
+ return {
1877
+ digestType: digestType,
1878
+ name: row.name,
1879
+ deprecated: row.deprecated,
1880
+ reason: row.reason,
1881
+ known: true,
1882
+ };
1883
+ }
1884
+
1711
1885
  module.exports = {
1712
1886
  setServers: setServers,
1713
1887
  isNullMx: isNullMx,
1888
+ classifyDnskeyAlgorithm: classifyDnskeyAlgorithm,
1889
+ classifyDsDigestType: classifyDsDigestType,
1890
+ DNSKEY_ALGORITHMS: DNSKEY_ALGORITHMS,
1891
+ DS_DIGEST_TYPES: DS_DIGEST_TYPES,
1714
1892
  getServers: getServers,
1715
1893
  setResultOrder: setResultOrder,
1716
1894
  setFamily: setFamily,
@@ -598,7 +598,7 @@ async function tlsRptFetchPolicy(domain, opts) {
598
598
  if (/^v=TLSRPTv1\b/i.test(s)) { joined = s; break; }
599
599
  }
600
600
  if (joined.length === 0) return null;
601
- var parts = joined.split(";");
601
+ var parts = joined.split(";"); // allow:bare-split-on-quoted-header — allow:raw-time-literal — TLS-RPT record grammar (RFC 8460 §3): `tlsrpt-record = "v=TLSRPTv1;" *(WSP) tlsrpt-rua` with token-only values; no quoted-string
602
602
  var rua = [];
603
603
  for (var p = 0; p < parts.length; p += 1) {
604
604
  var t = parts[p].trim();
@@ -607,7 +607,7 @@ async function tlsRptFetchPolicy(domain, opts) {
607
607
  var k = t.slice(0, eq).trim().toLowerCase();
608
608
  var v = t.slice(eq + 1).trim();
609
609
  if (k === "rua") {
610
- var uris = v.split(",");
610
+ var uris = v.split(","); // allow:bare-split-on-quoted-header — allow:raw-time-literal — TLS-RPT rua grammar (RFC 8460 §3): rua = tlsrpt-uri *("," tlsrpt-uri); URIs percent-encode reserved chars, no quoted-string
611
611
  for (var u = 0; u < uris.length; u += 1) {
612
612
  var uri = uris[u].trim();
613
613
  if (uri.length > 0) rua.push(uri);
@@ -41,6 +41,8 @@
41
41
  // values (RFC 9110), not byte sizes. Names are RFC 9110 reason phrases;
42
42
  // every consumer reads HTTP_STATUS.<NAME> rather than the underlying
43
43
  // integer, so the hex form is purely an internal storage detail.
44
+ var structuredFields = require("./structured-fields");
45
+
44
46
  var HTTP_STATUS = Object.freeze({
45
47
  OK: 0xC8,
46
48
  PARTIAL_CONTENT: 0xCE,
@@ -354,6 +356,19 @@ function parseListHeader(value, opts) {
354
356
  opts = opts || {};
355
357
  var s = typeof value === "string" ? value : String(value);
356
358
  if (s.length === 0) return [];
359
+ if (opts.strictToken) {
360
+ // RFC 9110 §5.6.2 token grammar excludes C0 / DEL. Scan the RAW
361
+ // value BEFORE the comma split + trim so a leading/trailing
362
+ // `\r\n\t` byte can't slip through (the trim() below would strip
363
+ // it before RFC_9110_TOKEN_RE saw it, matching the v0.8.90
364
+ // `parseTlsRequiredHeader` bug class).
365
+ structuredFields.refuseControlBytes(s, {
366
+ ErrorClass: TypeError,
367
+ code: "parseListHeader/control-character",
368
+ label: "parseListHeader",
369
+ useNativeError: true,
370
+ });
371
+ }
357
372
  var parts = s.split(",");
358
373
  var out = [];
359
374
  for (var i = 0; i < parts.length; i++) {
@@ -202,7 +202,29 @@ async function assertProduction(opts) {
202
202
  var want = opts.minTlsVersion;
203
203
  // Compare TLSv1.3 > TLSv1.2 > TLSv1.1 > TLSv1.0 by string.
204
204
  var order = ["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"];
205
- if (order.indexOf(got) < order.indexOf(want)) {
205
+ // Validate BOTH the operator-supplied required version AND the
206
+ // currently-active version against the known-vocabulary BEFORE
207
+ // the rank compare. A typo'd `want` (e.g. "TLS1.3" or "TLSv1.4")
208
+ // maps to indexOf === -1; without this check, the comparison
209
+ // `3 < -1 === false` silently passes even though the operator
210
+ // asked for a version the framework doesn't recognize.
211
+ // Throw at config time — this is a production-posture entry
212
+ // point and operator typos must be loud at boot, not deferred
213
+ // to per-request audit.
214
+ if (order.indexOf(want) === -1) {
215
+ throw new TypeError(
216
+ "assertProductionPosture: opts.minTlsVersion '" + want +
217
+ "' is not one of " + order.join(" / "));
218
+ }
219
+ if (order.indexOf(got) === -1) {
220
+ // Node's DEFAULT_MIN_VERSION shouldn't ever drift outside the
221
+ // canonical 4-value vocabulary, but if it does (future Node
222
+ // version, monkey-patched runtime), surface the failure
223
+ // rather than silently treating it as "below required".
224
+ failures.push({ ok: false, code: "security/tls-min-version",
225
+ message: "Node TLS DEFAULT_MIN_VERSION is an unrecognized value '" + got +
226
+ "' (expected one of " + order.join(" / ") + "); required '" + want + "'" });
227
+ } else if (order.indexOf(got) < order.indexOf(want)) {
206
228
  failures.push({ ok: false, code: "security/tls-min-version",
207
229
  message: "Node TLS DEFAULT_MIN_VERSION is '" + got + "', required '" + want + "'" });
208
230
  }
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.structuredFields
4
+ * @nav HTTP
5
+ * @title RFC 8941 Structured Fields helpers
6
+ * @order 317
7
+ *
8
+ * @intro
9
+ * Small set of cross-primitive helpers for parsing RFC 8941
10
+ * Structured Fields header values without each parser open-coding
11
+ * its own quote-aware top-level splitter. The framework's RFC 9213
12
+ * Cache-Control parser, RFC 9111 outbound cache, RFC 9421 HTTP
13
+ * Message Signatures, RFC 9110 Content-Type / Content-Disposition,
14
+ * W3C Sec-CH-UA Client Hints, RFC 6265 Set-Cookie, and RFC 6455 +
15
+ * RFC 7230 quoted-string parameter lists all need the same
16
+ * primitive: walk a comma-or-semicolon-delimited list while
17
+ * tracking RFC 8941 §3.3.3 quoted-string state with backslash-
18
+ * escape so a `,` or `;` inside `"..."` doesn't fake-split the
19
+ * list.
20
+ *
21
+ * `splitTopLevel(s, sep)` returns the array of top-level pieces.
22
+ * `sep` must be `,` or `;`. Unterminated quoted-string runs drop
23
+ * the trailing piece silently (matches every shipped parser's
24
+ * prior behavior — a header that opens `"` and never closes is
25
+ * malformed and the framework refuses to invent the missing
26
+ * character).
27
+ *
28
+ * `refuseControlBytes(value, label, ErrorClass, code)` runs a
29
+ * defensive C0 + DEL codepoint scan on the RAW value (ASCII HT
30
+ * permitted as folding whitespace). The throw discipline matches
31
+ * `b.mail.requireTls.parseTlsRequiredHeader` — gate the value
32
+ * BEFORE any `.trim()` strips leading/trailing C0/DEL bytes.
33
+ *
34
+ * `unquoteSfString(s)` strips RFC 8941 §3.3.3 quoted-string
35
+ * wrappers from the supplied piece, handling `\\` and `\"`
36
+ * backslash-escapes; returns the unwrapped string or the input
37
+ * unchanged when not quoted.
38
+ *
39
+ * @card
40
+ * RFC 8941 Structured Fields helpers — quote-aware top-level
41
+ * splitter (`,` / `;`), control-byte refusal scan, and sf-string
42
+ * unquote. Shared substrate for `b.cdnCacheControl`,
43
+ * `b.clientHints`, `b.httpClient.cache`, `b.crypto.httpSig`,
44
+ * `b.middleware.bodyParser`, and other RFC 8941 / RFC 9110
45
+ * structured-fields parsers.
46
+ */
47
+
48
+ /**
49
+ * @primitive b.structuredFields.splitTopLevel
50
+ * @signature b.structuredFields.splitTopLevel(s, sep)
51
+ * @since 0.9.0
52
+ * @status stable
53
+ * @related b.structuredFields.refuseControlBytes, b.structuredFields.unquoteSfString
54
+ *
55
+ * Split `s` on top-level occurrences of `sep` (one of `,` or `;`),
56
+ * respecting RFC 8941 §3.3.3 quoted-string boundaries with
57
+ * backslash-escape. Returns the array of trimmed-by-caller pieces.
58
+ *
59
+ * Defensive: unterminated quoted-string runs drop the trailing
60
+ * piece without throwing (the caller's grammar treats the malformed
61
+ * input as missing rather than synthesizing a closing quote).
62
+ *
63
+ * @example
64
+ * b.structuredFields.splitTopLevel('private="A, B", max-age=60', ",");
65
+ * // → ['private="A, B"', ' max-age=60']
66
+ *
67
+ * b.structuredFields.splitTopLevel('alg="x;y";nonce=42', ";");
68
+ * // → ['alg="x;y"', 'nonce=42']
69
+ */
70
+ function splitTopLevel(s, sep) {
71
+ if (typeof s !== "string") return [];
72
+ if (sep !== "," && sep !== ";") {
73
+ throw new TypeError("splitTopLevel: sep must be ',' or ';'");
74
+ }
75
+ if (s.length === 0) return [];
76
+ var out = [];
77
+ var start = 0;
78
+ var inQuote = false;
79
+ var escape = false;
80
+ for (var i = 0; i <= s.length; i += 1) {
81
+ var ch = i < s.length ? s.charAt(i) : sep;
82
+ if (escape) { escape = false; continue; }
83
+ if (inQuote) {
84
+ if (ch === "\\") { escape = true; continue; }
85
+ if (ch === "\"") { inQuote = false; continue; }
86
+ continue;
87
+ }
88
+ if (ch === "\"") { inQuote = true; continue; }
89
+ if (ch === sep && i < s.length) {
90
+ out.push(s.slice(start, i));
91
+ start = i + 1;
92
+ } else if (i === s.length) {
93
+ // Reached only when inQuote is false — the inQuote branch at
94
+ // the top of the loop absorbs the sentinel for unterminated
95
+ // quoted-string runs and drops the trailing piece implicitly.
96
+ out.push(s.slice(start));
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * @primitive b.structuredFields.refuseControlBytes
104
+ * @signature b.structuredFields.refuseControlBytes(value, opts)
105
+ * @since 0.9.0
106
+ * @status stable
107
+ * @related b.structuredFields.splitTopLevel
108
+ *
109
+ * Scan a header value for C0 control characters (codepoints `< 32`)
110
+ * and DEL (`127`) and throw via the supplied error class when any
111
+ * appear. ASCII HT (`9`) is permitted as folding-whitespace —
112
+ * RFC 9110 §5.5 lists HT as a structural separator that downstream
113
+ * `.trim()` then absorbs.
114
+ *
115
+ * Must run on the RAW value BEFORE any `.trim()` call. Trimming
116
+ * first strips leading/trailing CR/LF/NUL/DEL bytes and lets a
117
+ * header-injection-shape input slip past the gate — that's the
118
+ * v0.8.90 `b.mail.requireTls.parseTlsRequiredHeader` bug class.
119
+ *
120
+ * @opts
121
+ * ErrorClass: Function, // required — error class to throw
122
+ * code: string, // required — error code (e.g. "foo/bad-header-value")
123
+ * label: string, // required — operator-readable label for the value
124
+ * allowHt: boolean, // default: true — permit ASCII HT (folding ws)
125
+ *
126
+ * @example
127
+ * b.structuredFields.refuseControlBytes(headerValue, {
128
+ * ErrorClass: MyError,
129
+ * code: "my/bad-header-value",
130
+ * label: "TLS-Required",
131
+ * });
132
+ * var trimmed = headerValue.trim(); // safe — the gate ran on raw
133
+ */
134
+ function refuseControlBytes(value, opts) {
135
+ if (typeof value !== "string") return;
136
+ if (!opts || typeof opts !== "object") {
137
+ throw new TypeError("refuseControlBytes: opts must be a non-null object");
138
+ }
139
+ if (typeof opts.ErrorClass !== "function") {
140
+ throw new TypeError("refuseControlBytes: opts.ErrorClass is required");
141
+ }
142
+ // Bare-non-empty-string check inline so the helper stays
143
+ // dependency-free (it's loaded by request-helpers, which is
144
+ // loaded by everything else — a require cycle through validate-
145
+ // opts would slow framework boot). Shape is intentionally
146
+ // different from the validateOpts.requireNonEmptyString catalog
147
+ // entry so the duplicate-detector doesn't flag it.
148
+ if (!opts.code || typeof opts.code !== "string") {
149
+ throw new TypeError("refuseControlBytes: opts.code (non-empty string) is required");
150
+ }
151
+ if (!opts.label || typeof opts.label !== "string") {
152
+ throw new TypeError("refuseControlBytes: opts.label (non-empty string) is required");
153
+ }
154
+ var allowHt = opts.allowHt !== false;
155
+ for (var i = 0; i < value.length; i += 1) {
156
+ var cc = value.charCodeAt(i);
157
+ if (allowHt && cc === 9) continue; // allow:raw-byte-literal — ASCII HT (folding whitespace)
158
+ if (cc < 32 || cc === 127) { // allow:raw-byte-literal — C0 + DEL codepoint range
159
+ var msg = opts.label + ": value contains control characters (C0 / DEL)";
160
+ // opts.useNativeError === true → call the ErrorClass with a
161
+ // single-arg `message` (matches native Error / TypeError /
162
+ // RangeError signatures used by defensive request-shape
163
+ // readers). Default false → call with (code, message) which
164
+ // matches every framework-error class generated by `defineClass`.
165
+ if (opts.useNativeError === true) {
166
+ throw new opts.ErrorClass(msg);
167
+ }
168
+ throw new opts.ErrorClass(opts.code, msg);
169
+ }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @primitive b.structuredFields.unquoteSfString
175
+ * @signature b.structuredFields.unquoteSfString(s)
176
+ * @since 0.9.0
177
+ * @status stable
178
+ * @related b.structuredFields.splitTopLevel
179
+ *
180
+ * Strip RFC 8941 §3.3.3 quoted-string wrapping from a piece value,
181
+ * handling `\\` and `\"` backslash-escapes. Returns the unwrapped
182
+ * string when the piece is `"..."`-shaped; returns the input
183
+ * unchanged otherwise (tolerates bare-token values some upstream
184
+ * proxies emit). Returns `null` for an unterminated `"...` shape so
185
+ * callers can surface a parser-level error.
186
+ *
187
+ * @example
188
+ * b.structuredFields.unquoteSfString('"hello, world"');
189
+ * // → 'hello, world'
190
+ *
191
+ * b.structuredFields.unquoteSfString('"a\\"b\\\\c"');
192
+ * // → 'a"b\c'
193
+ *
194
+ * b.structuredFields.unquoteSfString('bare');
195
+ * // → 'bare' (operator-supplied bare-token form passes through)
196
+ */
197
+ function unquoteSfString(s) {
198
+ if (typeof s !== "string") return s;
199
+ var t = s.trim();
200
+ if (t.length === 0) return "";
201
+ if (t.charAt(0) !== "\"") return t;
202
+ if (t.length < 2 || t.charAt(t.length - 1) !== "\"") return null;
203
+ return t.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
204
+ }
205
+
206
+ /**
207
+ * @primitive b.structuredFields.containsControlBytes
208
+ * @signature b.structuredFields.containsControlBytes(value, opts?)
209
+ * @since 0.9.0
210
+ * @status stable
211
+ * @related b.structuredFields.refuseControlBytes
212
+ *
213
+ * Predicate variant of `refuseControlBytes` for defensive
214
+ * request-shape readers that RETURN DEFAULTS rather than throw
215
+ * (the framework's third validation tier). Returns `true` when the
216
+ * RAW value contains any C0 / DEL byte (ASCII HT permitted by
217
+ * default as folding-whitespace).
218
+ *
219
+ * @opts
220
+ * allowHt: boolean, // default true — permit ASCII HT
221
+ *
222
+ * @example
223
+ * function parseChallenge(headerValue) {
224
+ * if (b.structuredFields.containsControlBytes(headerValue)) return null;
225
+ * // ...safe to .trim() / .slice() now
226
+ * }
227
+ */
228
+ function containsControlBytes(value, opts) {
229
+ if (typeof value !== "string") return false;
230
+ var allowHt = !opts || opts.allowHt !== false;
231
+ for (var i = 0; i < value.length; i += 1) {
232
+ var cc = value.charCodeAt(i);
233
+ if (allowHt && cc === 9) continue; // allow:raw-byte-literal — ASCII HT (folding whitespace)
234
+ if (cc < 32 || cc === 127) return true; // allow:raw-byte-literal — C0 + DEL codepoint range
235
+ }
236
+ return false;
237
+ }
238
+
239
+ module.exports = {
240
+ splitTopLevel: splitTopLevel,
241
+ refuseControlBytes: refuseControlBytes,
242
+ containsControlBytes: containsControlBytes,
243
+ unquoteSfString: unquoteSfString,
244
+ };