@blamejs/blamejs-shop 0.4.73 → 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 (50) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/customers.js +5 -2
  4. package/lib/storefront.js +41 -8
  5. package/lib/vendor/MANIFEST.json +48 -44
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +23 -2
  8. package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +5 -1
  9. package/lib/vendor/blamejs/examples/wiki/lib/harvest-vendored-deps.js +6 -1
  10. package/lib/vendor/blamejs/examples/wiki/test/codebase-patterns.test.js +8 -4
  11. package/lib/vendor/blamejs/index.js +2 -0
  12. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -8
  13. package/lib/vendor/blamejs/lib/auth/dpop.js +9 -0
  14. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +25 -12
  15. package/lib/vendor/blamejs/lib/auth/jwt.js +19 -3
  16. package/lib/vendor/blamejs/lib/auth/oauth.js +8 -2
  17. package/lib/vendor/blamejs/lib/auth/saml.js +19 -9
  18. package/lib/vendor/blamejs/lib/crypto-field.js +19 -1
  19. package/lib/vendor/blamejs/lib/csp.js +9 -0
  20. package/lib/vendor/blamejs/lib/db-query.js +33 -2
  21. package/lib/vendor/blamejs/lib/mail-auth.js +24 -1
  22. package/lib/vendor/blamejs/lib/mail-dkim.js +20 -7
  23. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +39 -5
  24. package/lib/vendor/blamejs/lib/pipl-cn.js +11 -8
  25. package/lib/vendor/blamejs/lib/request-helpers.js +146 -13
  26. package/lib/vendor/blamejs/lib/safe-json.js +26 -0
  27. package/lib/vendor/blamejs/lib/session.js +35 -117
  28. package/lib/vendor/blamejs/lib/sql.js +22 -0
  29. package/lib/vendor/blamejs/lib/ws-client.js +26 -0
  30. package/lib/vendor/blamejs/lib/x509-chain.js +71 -24
  31. package/lib/vendor/blamejs/package.json +1 -1
  32. package/lib/vendor/blamejs/release-notes/v0.15.15.json +73 -0
  33. package/lib/vendor/blamejs/test/00-primitives.js +54 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +22 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +130 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +37 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +21 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +22 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +40 -1
  40. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +29 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +46 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-compose-pipeline.test.js +74 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +12 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +46 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +77 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +74 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +19 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +32 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/x509-chain-ca-enforcement.test.js +11 -0
  50. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.15.14",
4
- "createdAt": "2026-06-21T06:07:03.428Z",
3
+ "frameworkVersion": "0.15.15",
4
+ "createdAt": "2026-06-22T00:16:09.227Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -49211,6 +49211,10 @@
49211
49211
  "type": "function",
49212
49212
  "arity": 1
49213
49213
  },
49214
+ "ipPrefix": {
49215
+ "type": "function",
49216
+ "arity": 1
49217
+ },
49214
49218
  "makeResourceAuditEmitter": {
49215
49219
  "type": "function",
49216
49220
  "arity": 3
@@ -50654,6 +50658,10 @@
50654
50658
  }
50655
50659
  }
50656
50660
  },
50661
+ "isJsonObject": {
50662
+ "type": "function",
50663
+ "arity": 1
50664
+ },
50657
50665
  "parse": {
50658
50666
  "type": "function",
50659
50667
  "arity": 2
@@ -54051,6 +54059,19 @@
54051
54059
  }
54052
54060
  }
54053
54061
  },
54062
+ "x509Chain": {
54063
+ "type": "object",
54064
+ "members": {
54065
+ "isCaCert": {
54066
+ "type": "function",
54067
+ "arity": 1
54068
+ },
54069
+ "issuerValidlyIssued": {
54070
+ "type": "function",
54071
+ "arity": 2
54072
+ }
54073
+ }
54074
+ },
54054
54075
  "xmlC14n": {
54055
54076
  "type": "object",
54056
54077
  "members": {
@@ -24,6 +24,10 @@ var pagesRoute = require("../routes/pages");
24
24
  var symbolIndex = require("./symbol-index");
25
25
  var pagesSeeder = require("../seeders/prod/pages/_index");
26
26
 
27
+ // Typed boot error — extends FrameworkError and carries a stable .code so a
28
+ // caller can dispatch on the failure instead of string-matching the message.
29
+ var BuildAppError = b.frameworkError.defineClass("BuildAppError");
30
+
27
31
  // Strict CSP — drops 'unsafe-inline' from style-src + script-src. All
28
32
  // assets are external; cspNonce middleware adds 'nonce-XYZ' when the
29
33
  // app actually needs an inline element.
@@ -57,7 +61,7 @@ var SCHEMA = [
57
61
 
58
62
  async function buildApp(opts) {
59
63
  opts = opts || {};
60
- if (!opts.dataDir) throw new Error("buildApp: opts.dataDir is required");
64
+ if (!opts.dataDir) throw new BuildAppError("wiki/missing-data-dir", "buildApp: opts.dataDir is required");
61
65
 
62
66
  var dataDir = opts.dataDir;
63
67
  var port = opts.port !== undefined ? opts.port : b.constants.BYTES.bytes(3008);
@@ -23,6 +23,11 @@
23
23
 
24
24
  var fs = require("node:fs");
25
25
  var path = require("node:path");
26
+ var b = require("@blamejs/core");
27
+
28
+ // Typed error — extends FrameworkError with a stable .code, so a build/CI
29
+ // caller can dispatch on the failure rather than string-match the message.
30
+ var HarvestError = b.frameworkError.defineClass("HarvestError");
26
31
 
27
32
  var REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
28
33
  var MANIFEST_PATH = path.join(REPO_ROOT, "lib", "vendor", "MANIFEST.json");
@@ -160,7 +165,7 @@ function harvest() {
160
165
  var raw = fs.readFileSync(MANIFEST_PATH, "utf8");
161
166
  var doc = JSON.parse(raw); // allow:bare-json-parse — reads framework's own vendor MANIFEST.json (not operator input)
162
167
  if (!doc || typeof doc !== "object" || !doc.packages || typeof doc.packages !== "object") {
163
- throw new Error("harvest-vendored-deps: MANIFEST.json missing 'packages' object");
168
+ throw new HarvestError("wiki/harvest-bad-manifest", "harvest-vendored-deps: MANIFEST.json missing 'packages' object");
164
169
  }
165
170
  var libFiles = _walkLibJs();
166
171
  var deps = [];
@@ -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
  }