@blamejs/blamejs-shop 0.4.74 → 0.4.75

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/vendor/MANIFEST.json +48 -44
  4. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  5. package/lib/vendor/blamejs/api-snapshot.json +23 -2
  6. package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +5 -1
  7. package/lib/vendor/blamejs/examples/wiki/lib/harvest-vendored-deps.js +6 -1
  8. package/lib/vendor/blamejs/examples/wiki/test/codebase-patterns.test.js +8 -4
  9. package/lib/vendor/blamejs/index.js +2 -0
  10. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -8
  11. package/lib/vendor/blamejs/lib/auth/dpop.js +9 -0
  12. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +25 -12
  13. package/lib/vendor/blamejs/lib/auth/jwt.js +19 -3
  14. package/lib/vendor/blamejs/lib/auth/oauth.js +8 -2
  15. package/lib/vendor/blamejs/lib/auth/saml.js +19 -9
  16. package/lib/vendor/blamejs/lib/crypto-field.js +19 -1
  17. package/lib/vendor/blamejs/lib/csp.js +9 -0
  18. package/lib/vendor/blamejs/lib/db-query.js +33 -2
  19. package/lib/vendor/blamejs/lib/mail-auth.js +24 -1
  20. package/lib/vendor/blamejs/lib/mail-dkim.js +20 -7
  21. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +39 -5
  22. package/lib/vendor/blamejs/lib/pipl-cn.js +11 -8
  23. package/lib/vendor/blamejs/lib/request-helpers.js +146 -13
  24. package/lib/vendor/blamejs/lib/safe-json.js +26 -0
  25. package/lib/vendor/blamejs/lib/session.js +35 -117
  26. package/lib/vendor/blamejs/lib/sql.js +22 -0
  27. package/lib/vendor/blamejs/lib/ws-client.js +26 -0
  28. package/lib/vendor/blamejs/lib/x509-chain.js +71 -24
  29. package/lib/vendor/blamejs/package.json +1 -1
  30. package/lib/vendor/blamejs/release-notes/v0.15.15.json +73 -0
  31. package/lib/vendor/blamejs/test/00-primitives.js +54 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +22 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +130 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +37 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +21 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +22 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +40 -1
  38. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +29 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +46 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-compose-pipeline.test.js +74 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +12 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +46 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +77 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +74 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +19 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +32 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/x509-chain-ca-enforcement.test.js +11 -0
  48. package/package.json +1 -1
@@ -469,12 +469,16 @@ function testNoHandrolledDeepClone() {
469
469
 
470
470
  function testNoBareErrorThrows() {
471
471
  // class: bare-error-throw
472
- // Wiki routes that throw plain Error skip the framework's structured
473
- // error contract. Rare in operator code but worth catching.
472
+ // Wiki code that throws a plain Error skips the framework's structured
473
+ // error contract (a typed FrameworkError carries a stable .code a caller
474
+ // can dispatch on). This is a hard gate: use b.frameworkError.defineClass
475
+ // (see lib/build-app.js BuildAppError / lib/harvest-vendored-deps.js
476
+ // HarvestError), or add an `// allow:bare-error-throw` marker with a reason
477
+ // for a genuinely throwaway script throw.
474
478
  var matches = _scan(/\bthrow\s+new\s+Error\s*\(/);
475
479
  matches = _filterMarkers(matches, "bare-error-throw");
476
- _reportAdvisory("throw new Error(string) → consider a typed error " +
477
- "(b.framework-error.defineClass) when callers dispatch on .code",
480
+ _report("throw new Error(string) → use a typed error " +
481
+ "(b.frameworkError.defineClass) so callers can dispatch on .code",
478
482
  matches);
479
483
  }
480
484
 
@@ -400,6 +400,7 @@ var authBotChallenge = require("./lib/auth-bot-challenge");
400
400
  var sessionDeviceBinding = require("./lib/session-device-binding");
401
401
  var acme = require("./lib/acme");
402
402
  var cert = require("./lib/cert");
403
+ var x509Chain = require("./lib/x509-chain");
403
404
  var watcher = require("./lib/watcher");
404
405
  var localDbThin = require("./lib/local-db-thin");
405
406
  var daemon = require("./lib/daemon");
@@ -750,6 +751,7 @@ module.exports = {
750
751
  sessionDeviceBinding: sessionDeviceBinding,
751
752
  acme: acme,
752
753
  cert: cert,
754
+ x509Chain: x509Chain,
753
755
  ntpCheck: ntpCheck,
754
756
  tlsExporter: tlsExporter,
755
757
  watcher: watcher,
@@ -162,6 +162,10 @@ function create(opts) {
162
162
  tokenEndpoint: opts.tokenEndpoint,
163
163
  httpClientOpts: opts.httpClientOpts,
164
164
  allowHttp: opts.allowHttp === true,
165
+ // Thread the SSRF opt-in through to the inner client's discovery / JWKS /
166
+ // token fetches — an operator with an internal-network IdP needs it, and
167
+ // dropping it silently made allowInternal an accepted-but-ignored option.
168
+ allowInternal: opts.allowInternal,
165
169
  isOidc: true,
166
170
  });
167
171
 
@@ -456,10 +460,11 @@ function create(opts) {
456
460
  // returning its verified claims, or null when no id_token was supplied. A
457
461
  // present-but-invalid id_token throws (fail-closed) so a forged token's
458
462
  // sub/acr/amr can never be returned as trusted.
459
- async function _verifyIdTokenIfPresent(idToken, auditKey, op) {
463
+ async function _verifyIdTokenIfPresent(idToken, auditKey, op, expectedAuthReqId) {
460
464
  if (!idToken) return null;
465
+ var verified;
461
466
  try {
462
- return await inner.verifyIdToken(idToken);
467
+ verified = await inner.verifyIdToken(idToken);
463
468
  } catch (e) {
464
469
  _emitAudit("id_token_verify_fail", "failure",
465
470
  auditKey ? { authReqIdHash: sha3Hash(auditKey) } : {});
@@ -467,6 +472,27 @@ function create(opts) {
467
472
  "ciba." + op + ": id_token failed verification: " +
468
473
  ((e && e.code) || (e && e.message) || String(e)));
469
474
  }
475
+ // OpenID CIBA Core §7.3 / §10.1: the ID Token MUST carry the
476
+ // urn:openid:params:jwt:claim:auth_req_id claim and the RP MUST verify it
477
+ // equals the auth_req_id this flow used. Without it, an id_token minted for
478
+ // a DIFFERENT auth_req_id (another user's CIBA flow at the same RP) could be
479
+ // substituted — cross-user token substitution. verifyIdToken returns
480
+ // { header, claims }, so the claim lives on `.claims`. Constant-time compare.
481
+ // idToken is present (we returned early if not), so the binding is
482
+ // MANDATORY and ALWAYS checked — never gated behind a truthiness test that
483
+ // an empty string could slip. Both call sites pass a non-empty auth_req_id
484
+ // (validated at the parseNotification / pollToken entry), so a falsy value
485
+ // can't reach here to skip the comparison.
486
+ var payload = verified && verified.claims;
487
+ var boundId = payload && payload["urn:openid:params:jwt:claim:auth_req_id"];
488
+ if (typeof boundId !== "string" || !timingSafeEqual(boundId, expectedAuthReqId)) {
489
+ _emitAudit("id_token_authreqid_mismatch", "failure",
490
+ auditKey ? { authReqIdHash: sha3Hash(auditKey) } : {});
491
+ throw new AuthError("auth-ciba/id-token-authreqid-mismatch",
492
+ "ciba." + op + ": id_token urn:openid:params:jwt:claim:auth_req_id does not " +
493
+ "match the expected auth_req_id (cross-user token-substitution defense)");
494
+ }
495
+ return verified;
470
496
  }
471
497
 
472
498
  async function pollToken(popts) {
@@ -536,7 +562,7 @@ function create(opts) {
536
562
  // accepted-algorithms and enforces signature + iss/aud/exp. Returning an
537
563
  // unverified id_token let a forged token's sub/acr/amr be trusted.
538
564
  var pollClaims = await _verifyIdTokenIfPresent(rv.id_token,
539
- "auth-ciba:" + popts.authReqId, "pollToken");
565
+ "auth-ciba:" + popts.authReqId, "pollToken", popts.authReqId);
540
566
  _emitAudit("token_received", "success", {
541
567
  authReqIdHash: sha3Hash("auth-ciba:" + popts.authReqId),
542
568
  });
@@ -631,10 +657,8 @@ function create(opts) {
631
657
  throw new AuthError("auth-ciba/no-notification-body",
632
658
  "ciba.parseNotification: body required (Buffer/string parsed by middleware)");
633
659
  }
634
- if (typeof body.auth_req_id !== "string") {
635
- throw new AuthError("auth-ciba/no-auth-req-id-in-body",
636
- "ciba.parseNotification: body missing auth_req_id");
637
- }
660
+ validateOpts.requireNonEmptyString(body.auth_req_id,
661
+ "ciba.parseNotification: auth_req_id", AuthError, "auth-ciba/no-auth-req-id-in-body");
638
662
  // Verify the pushed ID token (CIBA §10.2 / OIDC Core MUST) via the
639
663
  // composed inner OAuth client before returning it as trusted. The
640
664
  // notification-token bearer authenticates the CALLER, not the token; a
@@ -642,7 +666,7 @@ function create(opts) {
642
666
  // arbitrary id_token claims the RP would trust on the documented
643
667
  // "no follow-up call" push path.
644
668
  var pushClaims = await _verifyIdTokenIfPresent(body.id_token,
645
- "auth-ciba:" + body.auth_req_id, "parseNotification");
669
+ "auth-ciba:" + body.auth_req_id, "parseNotification", body.auth_req_id);
646
670
  _emitAudit("notification_received", "success", {
647
671
  authReqIdHash: sha3Hash("auth-ciba:" + body.auth_req_id),
648
672
  mode: deliveryMode,
@@ -317,6 +317,15 @@ async function verify(proof, opts) {
317
317
  catch (_e) { throw new AuthError("auth-dpop/malformed", "header is not valid base64url-JSON"); }
318
318
  try { payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8")); }
319
319
  catch (_e) { throw new AuthError("auth-dpop/malformed", "payload is not valid base64url-JSON"); }
320
+ // safeJson.parse accepts the literal `null` / scalars, so a header segment of
321
+ // base64url("null") would otherwise reach `header.typ` and throw a raw
322
+ // TypeError rather than the typed malformed error. Require JSON objects.
323
+ if (!safeJson.isJsonObject(header)) {
324
+ throw new AuthError("auth-dpop/malformed", "header is not a JSON object");
325
+ }
326
+ if (!safeJson.isJsonObject(payload)) {
327
+ throw new AuthError("auth-dpop/malformed", "payload is not a JSON object");
328
+ }
320
329
 
321
330
  // Header checks
322
331
  if (header.typ !== "dpop+jwt") {
@@ -550,24 +550,37 @@ function lookupAaguid(blob, aaguid) {
550
550
  return null;
551
551
  }
552
552
 
553
- // Pull the certified-level token out of a list of status reports. The
554
- // most recent FIDO_CERTIFIED_L{N}[_PLUS] report wins; if none exist,
555
- // the authenticator is uncertified (level 0).
553
+ // Resolve the CURRENT certified level from the status-report history. FIDO MDS3
554
+ // status reports are chronological; the level is whatever the MOST RECENT
555
+ // certification-status report says a FIDO_CERTIFIED_L{N}[_PLUS], or 0 when the
556
+ // latest such report is NOT_FIDO_CERTIFIED (a decertification). Taking the
557
+ // highest-ever level instead let a step-up / risk policy that requires L{N}
558
+ // accept an authenticator that was later decertified or downgraded (the
559
+ // certifiedLevel is the policy input, so a stale max is an authenticator-
560
+ // assurance bypass). Ordering is by effectiveDate (ISO YYYY-MM-DD, so lexical ==
561
+ // chronological); ties and missing dates fall back to array order (append
562
+ // order, which the spec defines as chronological).
556
563
  function _certifiedLevel(statusReports) {
557
564
  if (!Array.isArray(statusReports)) return { level: 0, plus: false };
558
- var best = { level: 0, plus: false };
565
+ var latest = null;
566
+ var latestDate = null;
559
567
  for (var i = 0; i < statusReports.length; i++) {
560
568
  var sr = statusReports[i];
561
- if (!sr || typeof sr.status !== "string") continue;
562
- var m = CERT_LEVEL_RE.exec(sr.status);
563
- if (!m) continue;
564
- var level = parseInt(m[1], 10);
565
- var plus = !!m[2];
566
- if (level > best.level || (level === best.level && plus && !best.plus)) {
567
- best = { level: level, plus: plus };
569
+ // Only certification-status reports move the level: a level grant, or its
570
+ // explicit revocation (NOT_FIDO_CERTIFIED). Other statuses (UPDATE_AVAILABLE,
571
+ // REVOKED, …) are handled elsewhere and must not be read as a level. The
572
+ // status length is bounded before the regex test below — FIDO status tokens
573
+ // are short enums; bounding input before any .test() is the convention.
574
+ if (!sr || typeof sr.status !== "string" || sr.status.length > 64) continue;
575
+ if (!CERT_LEVEL_RE.test(sr.status) && sr.status !== "NOT_FIDO_CERTIFIED") continue;
576
+ var d = typeof sr.effectiveDate === "string" ? sr.effectiveDate : "";
577
+ if (latest === null || d >= latestDate) {
578
+ latest = sr; latestDate = d;
568
579
  }
569
580
  }
570
- return best;
581
+ if (!latest || latest.status === "NOT_FIDO_CERTIFIED") return { level: 0, plus: false };
582
+ var m = CERT_LEVEL_RE.exec(latest.status);
583
+ return { level: parseInt(m[1], 10), plus: !!m[2] };
571
584
  }
572
585
 
573
586
  /**
@@ -193,6 +193,16 @@ function decode(token) {
193
193
  catch (_e) { throw new AuthError("auth-jwt/malformed", "header is not valid base64url-JSON"); }
194
194
  try { payload = safeJson.parse(_b64urlDecode(parts[1])); }
195
195
  catch (_e) { throw new AuthError("auth-jwt/malformed", "payload is not valid base64url-JSON"); }
196
+ // The JOSE header and the claims set MUST each be a JSON object. safeJson.parse
197
+ // accepts the literal `null` (a valid JSON document) and scalars/arrays, so a
198
+ // header segment of base64url("null") would otherwise survive to a `.crit` /
199
+ // `.kid` dereference and throw a raw TypeError instead of a typed AuthError.
200
+ if (!safeJson.isJsonObject(header)) {
201
+ throw new AuthError("auth-jwt/malformed", "header is not a JSON object");
202
+ }
203
+ if (!safeJson.isJsonObject(payload)) {
204
+ throw new AuthError("auth-jwt/malformed", "payload is not a JSON object");
205
+ }
196
206
  var signature;
197
207
  try { signature = _b64urlDecode(parts[2]); }
198
208
  catch (_e) { throw new AuthError("auth-jwt/malformed", "signature is not valid base64url"); }
@@ -351,10 +361,16 @@ async function verify(token, opts) {
351
361
  "token not yet valid: nbf=" + p.nbf + " (now=" + nowSec + ", tolerance=" + tol + "s)");
352
362
  }
353
363
 
354
- // String-claim assertions
355
- if (opts.issuer !== undefined && !_matchClaim(p.iss, opts.issuer, "iss")) {
364
+ // String-claim assertions. `iss` is StringOrURI (RFC 7519 §4.1.1) — a single
365
+ // value, not a list: reject a non-string iss before matching. Routing it
366
+ // through the aud-style any-of _matchClaim let an attacker-influenced
367
+ // multi-issuer array (iss:["evil","trusted"]) satisfy a single-issuer
368
+ // expectation (CVE-2025-30144 / fast-jwt iss-array class) — the same defense
369
+ // jwtExternal.verifyExternal and oauth.verifyIdToken already apply.
370
+ if (opts.issuer !== undefined &&
371
+ (typeof p.iss !== "string" || !_matchClaim(p.iss, opts.issuer, "iss"))) {
356
372
  throw new AuthError("auth-jwt/iss-mismatch",
357
- "iss='" + p.iss + "' does not match expected " + JSON.stringify(opts.issuer));
373
+ "iss=" + JSON.stringify(p.iss) + " does not match expected " + JSON.stringify(opts.issuer));
358
374
  }
359
375
  if (opts.audience !== undefined && !_matchClaim(p.aud, opts.audience, "aud")) {
360
376
  throw new AuthError("auth-jwt/aud-mismatch",
@@ -1276,9 +1276,15 @@ function create(opts) {
1276
1276
  // browser session could be replayed without detection. Throw
1277
1277
  // loudly so the operator sees the bug at config time, not at
1278
1278
  // first-replay-attempt time.
1279
- if (isOidc && eopts.nonce === undefined && eopts.skipNonceCheck !== true) {
1279
+ // Require a NON-EMPTY STRING nonce, not merely a defined one: a falsy
1280
+ // nonce (null / "" — e.g. a session field that was never set) would slip a
1281
+ // strict `=== undefined` guard, and the downstream verifier only checks the
1282
+ // nonce when vopts.nonce is truthy, so the ID-token nonce check would be
1283
+ // silently skipped and a token captured from another session replayed.
1284
+ if (isOidc && eopts.skipNonceCheck !== true &&
1285
+ (typeof eopts.nonce !== "string" || eopts.nonce.length === 0)) {
1280
1286
  throw new OAuthError("auth-oauth/no-nonce",
1281
- "exchangeCode: nonce is required on OIDC flows. Pass the " +
1287
+ "exchangeCode: a non-empty nonce is required on OIDC flows. Pass the " +
1282
1288
  "value returned from authorizationUrl() through to exchangeCode " +
1283
1289
  "({ code, state, verifier, nonce }). Operators with a deliberate " +
1284
1290
  "no-nonce flow must pass `skipNonceCheck: true` (audited reason).");
@@ -695,7 +695,7 @@ function create(opts) {
695
695
  if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // ms→s
696
696
  Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // ms→s
697
697
  var recipHok = _attr(scdHok, "Recipient");
698
- if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
698
+ if (!recipHok || recipHok !== opts.assertionConsumerServiceUrl) continue; // §3.1→§4.1.4.2 — Recipient is mandatory; absent fails the endpoint binding
699
699
  hokOk = true;
700
700
  break;
701
701
  }
@@ -711,8 +711,11 @@ function create(opts) {
711
711
  var nb = Date.parse(notBefore) / 1000; // ms→s
712
712
  if (isFinite(nb) && nb > nowSec + clockSkewSec) continue;
713
713
  }
714
+ // §4.1.4.2 — a Bearer SubjectConfirmationData delivered to an ACS MUST
715
+ // carry a Recipient equal to this SP's ACS URL. Absent Recipient fails
716
+ // the endpoint binding (treated as a mismatch), not silently skipped.
714
717
  var recipient = _attr(scd, "Recipient");
715
- if (recipient && recipient !== opts.assertionConsumerServiceUrl) {
718
+ if (!recipient || recipient !== opts.assertionConsumerServiceUrl) {
716
719
  continue;
717
720
  }
718
721
  var inResponseTo = _attr(scd, "InResponseTo");
@@ -774,17 +777,24 @@ function create(opts) {
774
777
  // SP). Fail closed when an audience is configured; opt out only via
775
778
  // vopts.requireAudienceRestriction === false.
776
779
  if (audience && vopts.requireAudienceRestriction !== false) {
777
- var ar = conditions && _findChild(conditions, "AudienceRestriction", SAML_NS.assertion);
778
- if (!ar) {
780
+ var ars = conditions
781
+ ? _findAllChildren(conditions, "AudienceRestriction", SAML_NS.assertion) : [];
782
+ if (ars.length === 0) {
779
783
  throw new AuthError("auth-saml/no-audience-restriction",
780
784
  "verifyResponse: assertion has no AudienceRestriction binding it to \"" +
781
785
  audience + "\" (audience-confusion defense; set requireAudienceRestriction:false to opt out)");
782
786
  }
783
- var audiences = _findAllChildren(ar, "Audience", SAML_NS.assertion).map(_textContent);
784
- if (audiences.indexOf(audience) === -1) {
785
- throw new AuthError("auth-saml/wrong-audience",
786
- "Audience \"" + audience + "\" not in assertion's AudienceRestriction (got " +
787
- JSON.stringify(audiences) + ")");
787
+ // SAML core §2.5.1.4: multiple <AudienceRestriction> elements are
788
+ // AND-combined the SP must be a member of the Audience set of EVERY
789
+ // one. Checking only the first let an IdP that narrowed the assertion to
790
+ // a DIFFERENT audience in a later restriction be accepted here.
791
+ for (var ari = 0; ari < ars.length; ari += 1) {
792
+ var audiences = _findAllChildren(ars[ari], "Audience", SAML_NS.assertion).map(_textContent);
793
+ if (audiences.indexOf(audience) === -1) {
794
+ throw new AuthError("auth-saml/wrong-audience",
795
+ "Audience \"" + audience + "\" not in AudienceRestriction #" + (ari + 1) +
796
+ " of " + ars.length + " (got " + JSON.stringify(audiences) + ")");
797
+ }
788
798
  }
789
799
  }
790
800
 
@@ -1747,9 +1747,27 @@ function assertColumnResidency(table, row, args) {
1747
1747
  var entry = columnResidency[table];
1748
1748
  if (!entry || !row || !args) return null;
1749
1749
  var backendTag = args.backendTag || "unrestricted";
1750
+ // SQL unquoted identifiers are case-insensitive; a raw-SQL-parsed row keeps
1751
+ // the column token's case, so resolve each mapped column case-insensitively
1752
+ // (a case-sensitive `row[col]` let a differently-cased column skip the gate —
1753
+ // CWE-178). Lazily index the row's keys by lowercase; exact match wins first.
1754
+ var rowLcIndex = null;
1755
+ function _rowVal(c) {
1756
+ if (Object.prototype.hasOwnProperty.call(row, c)) return row[c];
1757
+ if (rowLcIndex === null) {
1758
+ rowLcIndex = {};
1759
+ var ks = Object.keys(row);
1760
+ for (var i = 0; i < ks.length; i++) {
1761
+ var lk = ks[i].toLowerCase();
1762
+ if (!Object.prototype.hasOwnProperty.call(rowLcIndex, lk)) rowLcIndex[lk] = row[ks[i]];
1763
+ }
1764
+ }
1765
+ return rowLcIndex[String(c).toLowerCase()];
1766
+ }
1750
1767
  for (var col in entry) {
1751
1768
  var want = entry[col];
1752
- if (row[col] === undefined || row[col] === null) continue;
1769
+ var cellVal = _rowVal(col);
1770
+ if (cellVal === undefined || cellVal === null) continue;
1753
1771
  if (want === "global" || want === "unrestricted") continue;
1754
1772
  if (backendTag === "unrestricted") continue;
1755
1773
  if (backendTag !== want) {
@@ -210,6 +210,15 @@ function build(directives, opts) {
210
210
  throw new CspError("csp/header-injection",
211
211
  "csp.build: source '" + src + "' contains CR/LF/NUL");
212
212
  }
213
+ // A CSP source-expression is a single non-whitespace token. The emitter
214
+ // space-joins sources within a directive and ';'-joins directives, so a
215
+ // source containing ';' or ASCII whitespace would inject a brand-new live
216
+ // directive (e.g. "https://x; script-src https://evil") — refuse both.
217
+ if (/[\s;]/.test(src)) {
218
+ throw new CspError("csp/bad-source",
219
+ "csp.build: source '" + src + "' contains whitespace or ';' — a CSP source " +
220
+ "must be a single token (directive-injection defense)");
221
+ }
213
222
  if (!acknowledgeUnsafe && SCRIPT_DIRECTIVES.indexOf(name) !== -1 &&
214
223
  UNSAFE_KEYWORDS.indexOf(src) !== -1) {
215
224
  throw new CspError("csp/unsafe-keyword",
@@ -89,6 +89,19 @@ function _postureState() {
89
89
  //
90
90
  // Unregulated postures audit (drop-silent) and pass; tables with no
91
91
  // declaration are untouched.
92
+ // Resolve a column in a raw-SQL-parsed row case-insensitively (SQL unquoted
93
+ // identifiers fold case; the parser preserves the token). Exact match wins
94
+ // first so a structured-builder row (keys already canonical) is unaffected.
95
+ function _ciColumn(row, col) {
96
+ if (Object.prototype.hasOwnProperty.call(row, col)) return { present: true, value: row[col] };
97
+ var lc = String(col).toLowerCase();
98
+ var keys = Object.keys(row);
99
+ for (var i = 0; i < keys.length; i++) {
100
+ if (keys[i].toLowerCase() === lc) return { present: true, value: row[keys[i]] };
101
+ }
102
+ return { present: false, value: undefined };
103
+ }
104
+
92
105
  function _assertLocalResidency(table, plaintextRow, op) {
93
106
  var spec = cryptoField.getPerRowResidency(table);
94
107
  var colMap = cryptoField.getColumnResidency(table);
@@ -106,9 +119,16 @@ function _assertLocalResidency(table, plaintextRow, op) {
106
119
  var regulated = state.regulated;
107
120
 
108
121
  if (spec) {
109
- var tag = plaintextRow[spec.residencyColumn];
122
+ // SQL unquoted identifiers are case-insensitive, and the raw-SQL parser
123
+ // preserves the column token's case — so resolve the residency column
124
+ // case-insensitively. A case-sensitive lookup let `UPDATE t SET REGION=...`
125
+ // miss a `residencyColumn: "region"` declaration, skipping the gate and
126
+ // admitting a cross-border write (CWE-178 / CWE-863). Fail-safe: any
127
+ // spelling that could be the residency column engages the gate.
128
+ var resolved = _ciColumn(plaintextRow, spec.residencyColumn);
129
+ var tag = resolved.value;
110
130
  var tagPresent = tag !== undefined && tag !== null;
111
- var colInChangeSet = Object.prototype.hasOwnProperty.call(plaintextRow, spec.residencyColumn);
131
+ var colInChangeSet = resolved.present;
112
132
  if (op === "insert" && !tagPresent) {
113
133
  throw new DbQueryError("db-query/row-residency-tag-missing",
114
134
  op + ": table '" + table + "' declares per-row residency on column '" +
@@ -368,6 +388,17 @@ class Query {
368
388
  return this.where(field, "IN", values);
369
389
  }
370
390
 
391
+ // whereNull / whereNotNull — explicit NULL predicates (IS NULL / IS NOT
392
+ // NULL). `where({ field: null })` / `where(field, "=", null)` is refused
393
+ // because `col = NULL` is UNKNOWN in SQL (never true); these are the
394
+ // intended way to test a column for NULL.
395
+ whereNull(field) {
396
+ return this.where(field, "IS", null);
397
+ }
398
+ whereNotNull(field) {
399
+ return this.where(field, "IS NOT", null);
400
+ }
401
+
371
402
  // Resolve a (field, op, value) predicate through the framework gates
372
403
  // (JSONB value guard, sealed-field → derived-hash rewrite, column
373
404
  // membership) and return the post-rewrite { field, op, value } that
@@ -2173,15 +2173,38 @@ function _extractFromHeaders(headerBlock) {
2173
2173
  // one of several authors (the §6.6.1 forbidden move), so the
2174
2174
  // field is treated as unparsable instead.
2175
2175
  address = value.trim();
2176
- if (/[\s,]/.test(address)) address = null;
2176
+ // An RFC 5322 addr-spec cannot contain whitespace, commas, or the
2177
+ // group / route markers ; : < > — their presence means a list, a
2178
+ // display-name, or RFC 5322 group syntax (`display-name: mailbox ;`),
2179
+ // not a single bare addr-spec.
2180
+ if (/[\s,;:<>]/.test(address)) address = null;
2177
2181
  }
2178
2182
  var at = address ? address.lastIndexOf("@") : -1;
2179
2183
  var domain = (at > 0 && address && at < address.length - 1)
2180
2184
  ? address.slice(at + 1).toLowerCase()
2181
2185
  : null;
2186
+ // The domain drives the DMARC policy lookup + alignment check. A value that
2187
+ // is not a syntactically valid DNS hostname (a trailing ';', group-syntax
2188
+ // soup, or other bytes that slipped the filters) would silently miss the
2189
+ // policy record and defeat DMARC (CWE-290). Require a real hostname;
2190
+ // otherwise treat From as unparsable — the caller fails closed (permerror →
2191
+ // reject).
2192
+ if (domain && !_isValidFromHostname(domain)) domain = null;
2182
2193
  return { count: count, address: address || null, domain: domain };
2183
2194
  }
2184
2195
 
2196
+ // Strict, bounded DNS-hostname check (ASCII / A-label form, as DMARC lookups
2197
+ // use). Anchored with a length lookahead and tempered labels — linear time, no
2198
+ // catastrophic backtracking. Requires at least two labels (a dot).
2199
+ var _FROM_HOSTNAME_RE =
2200
+ /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
2201
+ function _isValidFromHostname(host) {
2202
+ // Bound the length before the regex test (max DNS name is 253 octets) — the
2203
+ // pattern is already anchored + tempered, but bounding untrusted input before
2204
+ // any .test() is the framework convention.
2205
+ return typeof host === "string" && host.length <= 253 && _FROM_HOSTNAME_RE.test(host);
2206
+ }
2207
+
2185
2208
  async function inboundVerify(opts) {
2186
2209
  validateOpts.requireObject(opts, "inbound.verify", MailAuthError, "mail-auth/inbound-bad-input");
2187
2210
  validateOpts(opts, ["ip", "helo", "mailFrom", "message", "dnsLookup", "domainExists",
@@ -167,13 +167,24 @@ function _parseHeaders(rawHeaders) {
167
167
 
168
168
  // ---- Hashing + signing ----
169
169
 
170
- function _bodyHashB64(body, algorithm, canonBody) {
170
+ function _bodyHashB64(body, algorithm, canonBody, lcap) {
171
171
  var canonicalized = canonBody === "simple"
172
172
  ? _canonBodySimple(body)
173
173
  : _canonBodyRelaxed(body);
174
174
  var hashName = "sha256"; // both rsa-sha256 and ed25519-sha256 hash with sha256
175
- return nodeCrypto.createHash(hashName)
176
- .update(canonicalized).digest("base64");
175
+ var hash = nodeCrypto.createHash(hashName);
176
+ // RFC 6376 §3.4.5 — the l= body-length count is octets of the body AFTER
177
+ // canonicalization. Canonicalize first, then truncate the canonicalized
178
+ // octet stream to lcap (slicing the raw body before canonicalizing diverges
179
+ // whenever relaxed canon changes the byte count within the first lcap
180
+ // octets — WSP-run collapse, trailing-WSP strip, CRLF normalization).
181
+ if (typeof lcap === "number" && isFinite(lcap) && lcap >= 0) {
182
+ var buf = Buffer.from(canonicalized, "utf8");
183
+ hash.update(lcap < buf.length ? buf.subarray(0, lcap) : buf);
184
+ } else {
185
+ hash.update(canonicalized);
186
+ }
187
+ return hash.digest("base64");
177
188
  }
178
189
 
179
190
  function _signString(strToSign, privateKey, algorithm) {
@@ -674,15 +685,17 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
674
685
 
675
686
  var split = _splitHeadersBody(rfc822);
676
687
  var body = split.body;
688
+ var lcap;
677
689
  if (sigTags.l !== undefined) {
678
690
  // The framework refuses l= at SIGN-time per the M3AAWG / Gmail /
679
691
  // Microsoft 365 guidance (v0.7.18). On VERIFY, an `l=` tag on an
680
692
  // inbound signature signals append-after-signature exposure —
681
693
  // operators decide acceptance. Honor the cap for the body hash so
682
694
  // the signature still validates against legitimate senders that
683
- // use l=, but flag in the result.
684
- var lcap = parseInt(sigTags.l, 10);
685
- if (isFinite(lcap) && lcap >= 0) body = body.slice(0, lcap);
695
+ // use l=, but flag in the result. The cap is octets of the
696
+ // CANONICALIZED body (RFC 6376 §3.4.5), applied inside _bodyHashB64.
697
+ var parsedL = parseInt(sigTags.l, 10);
698
+ if (isFinite(parsedL) && parsedL >= 0) lcap = parsedL;
686
699
  }
687
700
 
688
701
  // 1. Body-hash check.
@@ -690,7 +703,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
690
703
  if (typeof expectedBh !== "string") {
691
704
  return { result: "permerror", errors: ["DKIM-Signature missing bh="] };
692
705
  }
693
- var actualBh = _bodyHashB64(body, algorithm, canonBody);
706
+ var actualBh = _bodyHashB64(body, algorithm, canonBody, lcap);
694
707
  if (actualBh !== expectedBh) {
695
708
  return { result: "fail", errors: ["body hash mismatch"] };
696
709
  }
@@ -287,6 +287,28 @@ function composePipeline(entries, opts) {
287
287
  catch (finalErr) { return reject(finalErr); }
288
288
  resolve();
289
289
  }
290
+ // A middleware (or error handler) that ENDS THE RESPONSE without calling
291
+ // next has halted the chain: it handled the request itself. Settle the
292
+ // outer promise so the awaiting router is released — a never-settled
293
+ // promise pins its req/res closure forever — but do NOT call finalNext:
294
+ // the router's next-flag stays false, so it won't run the route handler
295
+ // on top of an already-sent response.
296
+ function _resolveOnce() {
297
+ if (finished) return;
298
+ finished = true;
299
+ resolve();
300
+ }
301
+ // Settle when the response actually finishes. This is response-driven,
302
+ // not return-driven: a callback-style middleware that calls next() LATER
303
+ // (from a timer, stream, or legacy callback) returns before next() runs,
304
+ // so we must NOT treat a bare return as a halt — only an ended response.
305
+ // The synchronous _responseEnded() check below covers a middleware that
306
+ // ended the response inline (and a mock res without an event emitter);
307
+ // this listener covers one that ends it from a deferred callback.
308
+ if (res && typeof res.once === "function") {
309
+ res.once("finish", _resolveOnce);
310
+ res.once("close", _resolveOnce);
311
+ }
290
312
  async function dispatch(err) {
291
313
  if (finished) return;
292
314
  if (idx >= resolved.length) return _finishOnce(err);
@@ -313,14 +335,19 @@ function composePipeline(entries, opts) {
313
335
  }
314
336
  try {
315
337
  if (err) {
316
- // Error handler: (err, req, res, next)
338
+ // Error handler: (err, req, res, next). Express convention — a
339
+ // 4-arg handler that returns without calling next has HANDLED the
340
+ // error, so the chain ends cleanly; settle (without finalNext).
317
341
  await entry.mw(err, req, res, _next);
318
- if (!advanced) _finishOnce();
342
+ if (!advanced) _resolveOnce();
319
343
  } else {
320
- // Regular middleware: (req, res, next)
344
+ // Regular middleware: (req, res, next). Settle only if it ENDED the
345
+ // response (a halt). A bare return without next is NOT treated as a
346
+ // halt — it may be a callback-style middleware that calls next()
347
+ // later (timer/stream); the finish/close listener covers a deferred
348
+ // response end, and a deferred next() continues the chain.
321
349
  await entry.mw(req, res, _next);
322
- // 3-arg middleware that doesn't call next — chain halts.
323
- // The middleware presumably wrote the response itself.
350
+ if (!advanced && _responseEnded(res)) _resolveOnce();
324
351
  }
325
352
  } catch (syncErr) {
326
353
  // Synchronous throw OR rejected promise — route through
@@ -334,6 +361,13 @@ function composePipeline(entries, opts) {
334
361
  };
335
362
  }
336
363
 
364
+ // True once the response has been committed/ended — the reliable "this
365
+ // middleware handled the request" signal (vs. the function merely returning,
366
+ // which a callback-style middleware does before its deferred next()).
367
+ function _responseEnded(res) {
368
+ return !!(res && (res.writableEnded || res.finished || res.headersSent));
369
+ }
370
+
337
371
  composePipeline.CANONICAL_POSITIONS = CANONICAL_POSITIONS;
338
372
  composePipeline.ComposePipelineError = ComposePipelineError;
339
373
 
@@ -108,14 +108,17 @@ function _requireRecordedAt(value, label) {
108
108
  * and determine the lawful mechanism the transfer requires. PIPL Art. 38(1)
109
109
  * permits three bases for moving personal information out of the PRC — the
110
110
  * CAC standard contract (SCC), a CAC security assessment (Art. 40), or
111
- * certification by a CAC-accredited body but the Measures for Security
112
- * Assessment of Outbound Data Transfers make the security assessment
113
- * MANDATORY (the operator may NOT self-select the standard contract or
114
- * certification) when the exporter is a critical-information-infrastructure
115
- * operator (CIIO), exports "important data", handles personal information
116
- * of more than 1,000,000 individuals, or has cumulatively exported PI of
117
- * more than 100,000 individuals or sensitive PI of more than 10,000
118
- * individuals since 1 January of the preceding year.
111
+ * certification by a CAC-accredited body. Under the CAC Provisions on
112
+ * Promoting and Regulating Cross-Border Data Flows (effective 2024, which
113
+ * relaxed the 2022 thresholds) the security assessment is MANDATORY (the
114
+ * operator may NOT self-select the standard contract or certification) when
115
+ * the exporter is a critical-information-infrastructure operator (CIIO),
116
+ * exports "important data", or counting cumulatively since 1 January of
117
+ * the current year transfers the personal information of more than
118
+ * 1,000,000 individuals (non-sensitive) or the sensitive personal
119
+ * information of more than 10,000 individuals. The 100,000–1,000,000
120
+ * non-sensitive band is the standard-contract / certification tier, NOT a
121
+ * security-assessment trigger.
119
122
  *
120
123
  * The builder validates the operator-supplied facts, computes
121
124
  * `securityAssessmentRequired` against those thresholds, resolves the