@blamejs/core 0.14.19 → 0.14.21

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 (41) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/lib/auth/oauth.js +736 -1
  4. package/lib/auth/oid4vci.js +124 -5
  5. package/lib/auth/oid4vp.js +14 -4
  6. package/lib/auth/sd-jwt-vc-holder.js +46 -1
  7. package/lib/break-glass.js +1 -2
  8. package/lib/config.js +28 -31
  9. package/lib/crypto-field.js +274 -17
  10. package/lib/dora.js +8 -5
  11. package/lib/dsr.js +2 -2
  12. package/lib/flag-evaluation-context.js +7 -0
  13. package/lib/guard-html-wcag-aria.js +4 -2
  14. package/lib/guard-html-wcag-forms.js +4 -2
  15. package/lib/guard-html-wcag-tables.js +4 -2
  16. package/lib/guard-html-wcag-tagwalk.js +20 -0
  17. package/lib/guard-html-wcag.js +1 -1
  18. package/lib/honeytoken.js +27 -20
  19. package/lib/mail-auth.js +333 -0
  20. package/lib/mail-deploy.js +1 -1
  21. package/lib/mail-send-deliver.js +13 -4
  22. package/lib/middleware/api-encrypt.js +140 -13
  23. package/lib/middleware/asyncapi-serve.js +3 -0
  24. package/lib/middleware/csp-report.js +13 -9
  25. package/lib/middleware/fetch-metadata.js +115 -14
  26. package/lib/middleware/openapi-serve.js +3 -0
  27. package/lib/middleware/scim-server.js +297 -19
  28. package/lib/middleware/security-headers.js +47 -0
  29. package/lib/middleware/security-txt.js +1 -2
  30. package/lib/middleware/trace-log-correlation.js +1 -2
  31. package/lib/network-smtp-policy.js +4 -4
  32. package/lib/object-store/sigv4-bucket-ops.js +11 -2
  33. package/lib/observability-tracer.js +1 -1
  34. package/lib/observability.js +39 -1
  35. package/lib/problem-details.js +56 -11
  36. package/lib/pubsub-cluster.js +16 -3
  37. package/lib/queue-sqs.js +20 -2
  38. package/lib/redis-client.js +32 -4
  39. package/lib/safe-redirect.js +16 -2
  40. package/package.json +1 -1
  41. package/sbom.cdx.json +6 -6
@@ -289,18 +289,28 @@ function create(opts) {
289
289
  ], "middleware.apiEncrypt");
290
290
  var keypairs = _resolveKeypairs(opts);
291
291
  var activeKeypair = keypairs[0];
292
+ // replayWindowMs gates the timestamp-staleness check (Math.abs(now - ts)
293
+ // > replayWindowMs). A non-numeric value would make that comparison
294
+ // always false and SILENTLY disable the staleness defense, so a typo
295
+ // throws at boot rather than shipping an open replay window.
296
+ validateOpts.optionalPositiveFinite(opts.replayWindowMs,
297
+ "apiEncrypt: replayWindowMs", ApiEncryptError, "BAD_OPT");
292
298
  var replayWindowMs = opts.replayWindowMs || DEFAULT_REPLAY_WINDOW_MS;
293
299
  // Cap on decrypted-payload size handed to safeJson.parse. Defaults
294
300
  // to 4 MiB (bodyParser's default 1 MiB plus headroom for crypto +
295
301
  // base64 round-trip). Operators with chunkier inbound payloads
296
302
  // raise this; the framework refuses to parse anything larger as a
297
303
  // parse-bomb defense.
304
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
305
+ "apiEncrypt: maxDecryptedBytes", ApiEncryptError, "BAD_OPT");
298
306
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
299
307
  ? opts.maxDecryptedBytes
300
308
  : C.BYTES.mib(4);
301
309
  // The spec calls for a sweep cadence of replayWindowMs/2 — short
302
310
  // enough that expired nonces don't pile up but not so frequent the
303
311
  // sweep query becomes a hot path. Operators can override.
312
+ validateOpts.optionalPositiveFinite(opts.pruneIntervalMs,
313
+ "apiEncrypt: pruneIntervalMs", ApiEncryptError, "BAD_OPT");
304
314
  var pruneIntervalMs = opts.pruneIntervalMs != null
305
315
  ? opts.pruneIntervalMs : Math.max(C.TIME.seconds(30), Math.floor(replayWindowMs / 2));
306
316
  var nonceStore = opts.nonceStore || nonceStoreLib.create({ backend: "memory" });
@@ -446,7 +456,11 @@ function create(opts) {
446
456
  // replayed responses with a monotonic counter check.
447
457
  function _encodeEnvelope(data, sessionKey, sessionCtx) {
448
458
  var ptBuf = Buffer.from(JSON.stringify(data), "utf8");
449
- var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
459
+ // Response AAD binds _sid/_ctr so a captured response cannot be
460
+ // replayed to the client under a rewritten counter.
461
+ var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey,
462
+ _responseAad(sessionCtx ? sessionCtx.sid : undefined,
463
+ sessionCtx ? sessionCtx.responseCtr : undefined));
450
464
  var encrypted = { _ct: ctBuf.toString("base64") };
451
465
  if (sessionCtx) {
452
466
  encrypted._sid = sessionCtx.sid;
@@ -527,6 +541,10 @@ function create(opts) {
527
541
 
528
542
  if (typeof ek === "string" && typeof nonce === "string") {
529
543
  // ---- Bootstrap path (per-request mode OR first request of session) ----
544
+ // The window-scoped claim TTL is sufficient HERE because _ts is
545
+ // AEAD-bound into _ct: a captured bootstrap envelope cannot have
546
+ // its timestamp rewritten, so past replayWindowMs the staleness
547
+ // gate above refuses it independently of this claim.
530
548
  var nonceHash = bCrypto.sha3Hash(nonce, "hex");
531
549
  var expireAt = now + replayWindowMs;
532
550
  var freshNonce;
@@ -553,11 +571,18 @@ function create(opts) {
553
571
  _emitFailure(req, "shape");
554
572
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
555
573
  }
556
- // Bootstrap a new session row keyed by sid.
574
+ // Bootstrap a new session row keyed by sid. responsesEmitted is
575
+ // set to 1 (this bootstrap emits one response) BEFORE the store
576
+ // write so a cluster store — which serialises a copy at set() time
577
+ // rather than holding a live reference — persists the same count
578
+ // the next subsequent request reads. Mutating the local object
579
+ // after set() would leave the stored row at 0, making the next
580
+ // response counter restart from 1 (a non-monotonic response _ctr
581
+ // that trips the client's strictly-increasing replay check).
557
582
  session = {
558
583
  sessionKey: sessionKey,
559
584
  lastReqCtr: ctr,
560
- responsesEmitted: 0,
585
+ responsesEmitted: 1,
561
586
  createdAt: now,
562
587
  lastUsedAt: now,
563
588
  expiresAt: now + sessionTtlMs,
@@ -574,7 +599,6 @@ function create(opts) {
574
599
  requestId: req.requestId || null,
575
600
  });
576
601
  sessionCtx = { sid: sid, responseCtr: 1 };
577
- session.responsesEmitted = 1;
578
602
  }
579
603
  } else if (keying === "per-session" &&
580
604
  typeof sid === "string" && typeof ctr === "number") {
@@ -633,6 +657,47 @@ function create(opts) {
633
657
  _emitFailure(req, "counter-replay");
634
658
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
635
659
  }
660
+ // Atomic replay gate (CWE-367). The monotonic counter check above is
661
+ // an ordering fast-path only: on a clustered session store, get(sid)
662
+ // returns a fresh deserialised copy per call, so two concurrent
663
+ // requests carrying the SAME valid ctr both observe the same
664
+ // lastReqCtr and both pass — double-execution replay. Claiming the
665
+ // (sid, ctr) tuple through the same atomic nonceStore the bootstrap
666
+ // path uses closes the window: exactly one concurrent request wins the
667
+ // insert; the loser is refused with the counter-replay shape. The
668
+ // "ctr:" prefix keeps this keyspace disjoint from the bootstrap
669
+ // nonceHash keyspace (a sha3 hex digest never starts with "ctr:").
670
+ // The claim must outlive the staleness window, not just span it:
671
+ // the post-handler sessionStore.set below is best-effort, so a
672
+ // failed write leaves lastReqCtr stale in the store, and _ts is
673
+ // plaintext envelope metadata (not bound into the AEAD) — were the
674
+ // claim to expire after replayWindowMs, the same captured
675
+ // (sid, ctr, _ct) could be replayed later with a fresh _ts, pass
676
+ // the stale monotonic check, re-claim the expired tuple, and
677
+ // execute twice. Claiming until session.expiresAt (the session is
678
+ // non-expired here, so that bound is in the future) keeps the
679
+ // tuple burned for as long as the session can accept requests;
680
+ // outstanding claims per session are bounded by
681
+ // sessionMaxResponses, and the memory nonce store fails closed at
682
+ // capacity.
683
+ var ctrKey = "ctr:" + sid + ":" + ctr;
684
+ var ctrFresh;
685
+ try { ctrFresh = await nonceStore.checkAndInsert(ctrKey, session.expiresAt); }
686
+ catch (_e) {
687
+ _emitFailure(req, "nonce-store-error");
688
+ return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
689
+ }
690
+ if (!ctrFresh) {
691
+ _emitObs("apiEncrypt.session.replay_rejected", 1, { lane: "atomic" });
692
+ _emitSessionAudit("apiEncrypt.session.replay_rejected", {
693
+ outcome: "denied",
694
+ actor: requestHelpers.extractActorContext(req),
695
+ metadata: { sid: sid, receivedCtr: ctr, lane: "atomic" },
696
+ requestId: req.requestId || null,
697
+ });
698
+ _emitFailure(req, "counter-replay");
699
+ return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
700
+ }
636
701
  sessionKey = session.sessionKey;
637
702
  if (Buffer.isBuffer(sessionKey) === false) {
638
703
  // Operator-supplied store may have JSON-serialised the buffer.
@@ -660,11 +725,15 @@ function create(opts) {
660
725
  return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
661
726
  }
662
727
 
663
- // Decrypt _ct → cleartext payload bytes → JSON object.
728
+ // Decrypt _ct → cleartext payload bytes → JSON object. The request
729
+ // AAD authenticates the plaintext envelope fields exactly as the
730
+ // client bound them — a rewritten _ts/_nonce/_sid/_ctr fails the
731
+ // AEAD tag here, so the staleness gate above operates on a
732
+ // timestamp the sender cannot forge after capture.
664
733
  var clearObj;
665
734
  try {
666
735
  var ctBuf = Buffer.from(ct, "base64");
667
- var ptBuf = bCrypto.decryptPacked(ctBuf, sessionKey);
736
+ var ptBuf = bCrypto.decryptPacked(ctBuf, sessionKey, _requestAad(ts, nonce, sid, ctr));
668
737
  clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
669
738
  } catch (_e) {
670
739
  _emitFailure(req, "tag");
@@ -740,6 +809,8 @@ function client(opts) {
740
809
  "apiEncrypt.client: pubkey.publicKey + ecPublicKey must be PEM strings", 500);
741
810
  }
742
811
  var pubkey = opts.pubkey;
812
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
813
+ "apiEncrypt.client: maxDecryptedBytes", ApiEncryptError, "CLIENT_BAD_OPT");
743
814
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
744
815
  ? opts.maxDecryptedBytes
745
816
  : C.BYTES.mib(4);
@@ -785,9 +856,23 @@ function client(opts) {
785
856
  "apiEncrypt.client: response counter is not strictly increasing " +
786
857
  "(got " + responseBody._ctr + ", lastSeen " + perSessionLastResCtr + ")");
787
858
  }
788
- perSessionLastResCtr = responseBody._ctr;
789
859
  var resCtBuf = Buffer.from(responseBody._ct, "base64");
790
- var resPtBuf = bCrypto.decryptPacked(resCtBuf, perSessionKey);
860
+ // Response AAD authenticates _sid/_ctr — the monotonic counter
861
+ // check above reads plaintext fields, so without this binding a
862
+ // captured response could be replayed under a bumped _ctr.
863
+ var resPtBuf;
864
+ try {
865
+ resPtBuf = bCrypto.decryptPacked(resCtBuf, perSessionKey,
866
+ _responseAad(responseBody._sid, responseBody._ctr));
867
+ } catch (_e) {
868
+ throw _err("CLIENT_RESPONSE_TAMPERED",
869
+ "apiEncrypt.client: response failed authenticated decryption (ciphertext or envelope metadata tampered)");
870
+ }
871
+ // Advance the counter only AFTER authenticated decryption — were it
872
+ // committed before, a forged high _ctr (which fails the AEAD above)
873
+ // would poison the monotonic check and refuse every subsequent
874
+ // genuine response for the rest of the session.
875
+ perSessionLastResCtr = responseBody._ctr;
791
876
  return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
792
877
  }
793
878
 
@@ -795,14 +880,18 @@ function client(opts) {
795
880
  if (payload === undefined) payload = null;
796
881
  if (!perSessionKey) _resetSession();
797
882
  var ts = Date.now();
798
- var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
799
- var ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey);
800
883
  perSessionReqCtr += 1;
884
+ var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
885
+ var ctBuf;
801
886
  var body;
802
887
  if (perSessionReqCtr === 1) {
803
888
  // Bootstrap envelope — full _ek + _nonce; server stores sid → sessionKey.
889
+ // The plaintext metadata is AEAD-bound so a captured envelope
890
+ // cannot be replayed under a rewritten _ts/_nonce/_sid/_ctr.
804
891
  var ek = bCrypto.encrypt(perSessionKey.toString("base64"), pubkey);
805
892
  var nonce = bCrypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
893
+ ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey,
894
+ _requestAad(ts, nonce, perSessionSid, perSessionReqCtr));
806
895
  body = {
807
896
  _ek: ek,
808
897
  _ct: ctBuf.toString("base64"),
@@ -813,6 +902,8 @@ function client(opts) {
813
902
  };
814
903
  } else {
815
904
  // Subsequent — sid + ctr only. KEM material amortized across the session.
905
+ ctBuf = bCrypto.encryptPacked(ptBuf, perSessionKey,
906
+ _requestAad(ts, undefined, perSessionSid, perSessionReqCtr));
816
907
  body = {
817
908
  _ct: ctBuf.toString("base64"),
818
909
  _ts: ts,
@@ -827,10 +918,13 @@ function client(opts) {
827
918
  if (payload === undefined) payload = null;
828
919
  var sessionKey = bCrypto.generateBytes(SESSION_KEY_BYTES);
829
920
  var ek = bCrypto.encrypt(sessionKey.toString("base64"), pubkey);
830
- var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
831
- var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey);
832
921
  var requestNonce = bCrypto.generateBytes(REQUEST_NONCE_BYTES).toString("hex");
833
922
  var ts = Date.now();
923
+ var ptBuf = Buffer.from(JSON.stringify(payload), "utf8");
924
+ // AEAD-bind _ts/_nonce so a captured per-request envelope cannot
925
+ // be replayed past the staleness window with a rewritten _ts.
926
+ var ctBuf = bCrypto.encryptPacked(ptBuf, sessionKey,
927
+ _requestAad(ts, requestNonce, undefined, undefined));
834
928
  return {
835
929
  body: {
836
930
  _ek: ek,
@@ -845,7 +939,14 @@ function client(opts) {
845
939
  "apiEncrypt.client: response missing _ct field");
846
940
  }
847
941
  var resCtBuf = Buffer.from(responseBody._ct, "base64");
848
- var resPtBuf = bCrypto.decryptPacked(resCtBuf, sessionKey);
942
+ var resPtBuf;
943
+ try {
944
+ resPtBuf = bCrypto.decryptPacked(resCtBuf, sessionKey,
945
+ _responseAad(undefined, undefined));
946
+ } catch (_e) {
947
+ throw _err("CLIENT_RESPONSE_TAMPERED",
948
+ "apiEncrypt.client: response failed authenticated decryption (ciphertext or envelope metadata tampered)");
949
+ }
849
950
  return safeJson.parse(resPtBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
850
951
  },
851
952
  };
@@ -865,6 +966,30 @@ function client(opts) {
865
966
  };
866
967
  }
867
968
 
969
+ // AEAD associated-data builders — bind the envelope's PLAINTEXT
970
+ // metadata into the ciphertext so a captured envelope cannot be
971
+ // replayed with rewritten fields. `_ts` drives the staleness gate,
972
+ // `_nonce` the bootstrap replay claim, `_sid`/`_ctr` the session
973
+ // replay gates on requests and the client's monotonic counter check
974
+ // on responses; none are confidential, but every one is
975
+ // integrity-critical — rode plaintext, an attacker who captured an
976
+ // envelope could refresh `_ts` past the staleness window or replay a
977
+ // response under a bumped `_ctr`. Both halves of the protocol
978
+ // (middleware + client) live in this module and MUST build
979
+ // byte-identical strings; absent fields encode as the empty string so
980
+ // the per-request and per-session shapes stay unambiguous.
981
+ function _requestAad(ts, nonce, sid, ctr) {
982
+ return "blamejs-apienc/req/1|ts=" + String(ts) +
983
+ "|nonce=" + (typeof nonce === "string" ? nonce : "") +
984
+ "|sid=" + (typeof sid === "string" ? sid : "") +
985
+ "|ctr=" + (typeof ctr === "number" ? String(ctr) : "");
986
+ }
987
+
988
+ function _responseAad(sid, ctr) {
989
+ return "blamejs-apienc/res/1|sid=" + (typeof sid === "string" ? sid : "") +
990
+ "|ctr=" + (typeof ctr === "number" ? String(ctr) : "");
991
+ }
992
+
868
993
  // _generateUuidV4 — UUID v4 from 16 random bytes, formatted dash-separated.
869
994
  // Used for client-side session-id generation in per-session keying.
870
995
  // Slice offsets are RFC 4122 UUID hex-byte boundaries (`xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx`)
@@ -914,6 +1039,8 @@ function httpClientEncrypted(opts) {
914
1039
  throw _err("CLIENT_INVALID_PUBKEY",
915
1040
  "httpClient.encrypted: opts.pubkey is required (the callee's bootstrap doc)", 500);
916
1041
  }
1042
+ validateOpts.optionalPositiveInt(opts.maxDecryptedBytes,
1043
+ "httpClient.encrypted: maxDecryptedBytes", ApiEncryptError, "CLIENT_BAD_OPT");
917
1044
  var maxDecryptedBytes = opts.maxDecryptedBytes != null
918
1045
  ? opts.maxDecryptedBytes
919
1046
  : C.BYTES.mib(4);
@@ -173,6 +173,9 @@ function create(opts) {
173
173
  if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
174
174
  }
175
175
  res.writeHead(200, headers); // HTTP 200
176
+ // HEAD carries the GET headers (incl. Content-Length) with no body
177
+ // (RFC 9110 §9.3.2).
178
+ if ((req.method || "GET").toUpperCase() === "HEAD") { res.end(); return; }
176
179
  res.end(body);
177
180
  }
178
181
 
@@ -135,8 +135,10 @@ function create(opts) {
135
135
  typeof opts.onReject !== "function") {
136
136
  throw new TypeError("middleware.cspReport: opts.onReject must be a function");
137
137
  }
138
- var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
139
- ? opts.maxBytes : DEFAULT_MAX_BYTES;
138
+ validateOpts.optionalPositiveInt(opts.maxBytes, "middleware.cspReport: maxBytes");
139
+ var maxBytes = (opts.maxBytes === undefined || opts.maxBytes === null)
140
+ ? DEFAULT_MAX_BYTES : opts.maxBytes;
141
+ var auditOn = opts.audit !== false;
140
142
  var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
141
143
  var onReject = (typeof opts.onReject === "function") ? opts.onReject : null;
142
144
 
@@ -176,13 +178,15 @@ function create(opts) {
176
178
  for (var i = 0; i < reports.length; i++) {
177
179
  var normalized = _normalizeOne(reports[i]);
178
180
  if (!normalized) continue;
179
- try {
180
- audit().safeEmit({
181
- action: "csp.violation",
182
- outcome: "failure",
183
- metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
184
- });
185
- } catch (_e) { /* audit best-effort */ }
181
+ if (auditOn) {
182
+ try {
183
+ audit().safeEmit({
184
+ action: "csp.violation",
185
+ outcome: "failure",
186
+ metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
187
+ });
188
+ } catch (_e) { /* audit best-effort */ }
189
+ }
186
190
  if (onReport) {
187
191
  try { onReport(normalized); } catch (_e) { /* hook best-effort */ }
188
192
  }
@@ -43,6 +43,52 @@ var observability = lazyRequire(function () { return require("../observability")
43
43
 
44
44
  var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
45
45
 
46
+ // Sec-Fetch-Dest request-destination vocabulary (Fetch Standard §3.2.6
47
+ // "destination", https://fetch.spec.whatwg.org/#concept-request-destination;
48
+ // surfaced as the Sec-Fetch-Dest header by the Fetch Metadata Request
49
+ // Headers spec, https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header).
50
+ // "webidentity" (FedCM credentialed-request destination,
51
+ // https://w3c.github.io/FedCM/) is included so an operator can recognize
52
+ // and gate FedCM traffic first-class — a webidentity Sec-Fetch-Dest on a
53
+ // route that is not a FedCM identity endpoint is a request worth refusing.
54
+ var KNOWN_DESTINATIONS = Object.freeze([
55
+ "audio", "audioworklet", "document", "embed", "empty", "fencedframe",
56
+ "font", "frame", "iframe", "image", "json", "manifest", "object",
57
+ "paintworklet", "report", "script", "serviceworker", "sharedworker",
58
+ "style", "track", "video", "webidentity", "worker", "xslt",
59
+ ]);
60
+ var KNOWN_DEST_SET = Object.create(null);
61
+ (function () {
62
+ for (var i = 0; i < KNOWN_DESTINATIONS.length; i += 1) {
63
+ KNOWN_DEST_SET[KNOWN_DESTINATIONS[i]] = true;
64
+ }
65
+ })();
66
+
67
+ // Sec-Fetch-Storage-Access status values (Storage Access API,
68
+ // https://privacycg.github.io/storage-access-headers/ — the header is
69
+ // distinct from Sec-Fetch-Dest). The browser sends this only on cross-site
70
+ // credentialed requests. "active" / "inactive" both indicate the embedded
71
+ // context can (active) or could (inactive, permission granted but not yet
72
+ // exercised) reach unpartitioned cross-site cookies; "none" carries no
73
+ // such capability. A route that does not participate in the Storage Access
74
+ // flow may refuse the active/inactive escalation.
75
+ var STORAGE_ACCESS_ESCALATED = Object.freeze({ active: true, inactive: true });
76
+
77
+ function _validateDestList(list, label) {
78
+ // Config-time tier — an unknown Sec-Fetch-Dest value in a strict
79
+ // allow/deny list is almost always an operator typo (e.g. "web-identity"
80
+ // for "webidentity"). Throw at boot per the config/entry-point tier so
81
+ // the typo surfaces before it silently fails to match at request time.
82
+ if (!Array.isArray(list)) return;
83
+ for (var i = 0; i < list.length; i += 1) {
84
+ if (!KNOWN_DEST_SET[list[i]]) {
85
+ throw new Error("middleware.fetchMetadata: " + label + "[" + i +
86
+ "] is not a known Sec-Fetch-Dest value (got '" + String(list[i]) +
87
+ "'). Known destinations: " + KNOWN_DESTINATIONS.join(", ") + ".");
88
+ }
89
+ }
90
+ }
91
+
46
92
  function _writeReject(req, res, message, reason, onDeny, problemMode) {
47
93
  denyResponse(req, res, {
48
94
  onDeny: onDeny,
@@ -75,17 +121,30 @@ function _writeReject(req, res, message, reason, onDeny, problemMode) {
75
121
  * the value-add; non-browser callers carry their own auth threat
76
122
  * model.
77
123
  *
124
+ * The Sec-Fetch-Dest vocabulary tracks the Fetch Standard request-
125
+ * destination list, including `webidentity` (FedCM credentialed
126
+ * requests). `deniedDest` refuses chosen destinations outright on the
127
+ * gated methods — a FedCM `webidentity` Sec-Fetch-Dest hitting a route
128
+ * that is not an identity endpoint is refused. `allowStorageAccess:
129
+ * false` refuses the Storage Access API escalation (a cross-site request
130
+ * carrying `Sec-Fetch-Storage-Access: active` / `inactive`) on routes
131
+ * that do not participate in the Storage Access flow. Both are opt-in;
132
+ * leaving them unset preserves the prior behavior exactly.
133
+ *
78
134
  * @opts
79
135
  * {
80
- * allowSameSite: boolean, // default true
81
- * allowCrossSite: boolean, // default false
82
- * allowMissing: boolean, // default true
83
- * allowedDest: string[],
84
- * allowedNavigate: boolean, // default true
85
- * methods: string[], // default POST/PUT/DELETE/PATCH
86
- * audit: boolean, // default true
87
- * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
88
- * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
136
+ * allowSameSite: boolean, // default true
137
+ * allowCrossSite: boolean, // default false
138
+ * allowMissing: boolean, // default true
139
+ * allowedDest: string[], // cross-site allowlist of Sec-Fetch-Dest values
140
+ * deniedDest: string[], // Sec-Fetch-Dest values refused on gated methods regardless of site (e.g. ["webidentity"])
141
+ * allowStorageAccess: boolean, // default true — false refuses Sec-Fetch-Storage-Access: active|inactive
142
+ * strictDest: boolean, // default false — true throws at config time on an allowedDest/deniedDest value outside the known Sec-Fetch-Dest vocabulary
143
+ * allowedNavigate: boolean, // default true
144
+ * methods: string[], // default POST/PUT/DELETE/PATCH
145
+ * audit: boolean, // default true
146
+ * onDeny: function(req, res, info): void, // own the 403; info = { status, reason }
147
+ * problemDetails: boolean, // default false — emit RFC 9457 application/problem+json instead of the default JSON envelope
89
148
  * }
90
149
  *
91
150
  * @example
@@ -102,15 +161,34 @@ function create(opts) {
102
161
  opts = opts || {};
103
162
  validateOpts(opts, [
104
163
  "allowSameSite", "allowCrossSite", "allowMissing",
105
- "allowedDest", "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
164
+ "allowedDest", "deniedDest", "allowStorageAccess", "strictDest",
165
+ "allowedNavigate", "methods", "audit", "onDeny", "problemDetails",
106
166
  ], "middleware.fetchMetadata");
167
+ validateOpts.optionalBoolean(opts.allowStorageAccess, "middleware.fetchMetadata: allowStorageAccess");
168
+ validateOpts.optionalBoolean(opts.strictDest, "middleware.fetchMetadata: strictDest");
169
+ validateOpts.optionalNonEmptyStringArray(opts.deniedDest, "middleware.fetchMetadata: deniedDest");
170
+ if (opts.strictDest === true) {
171
+ _validateDestList(opts.allowedDest, "allowedDest");
172
+ _validateDestList(opts.deniedDest, "deniedDest");
173
+ }
107
174
 
108
175
  var onDeny = typeof opts.onDeny === "function" ? opts.onDeny : null;
109
176
  var problemMode = opts.problemDetails === true;
110
- var allowSameSite = opts.allowSameSite !== false;
111
- var allowCrossSite = opts.allowCrossSite === true;
112
- var allowMissing = opts.allowMissing !== false;
113
- var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
177
+ var allowSameSite = opts.allowSameSite !== false;
178
+ var allowCrossSite = opts.allowCrossSite === true;
179
+ var allowMissing = opts.allowMissing !== false;
180
+ var allowedDest = Array.isArray(opts.allowedDest) ? opts.allowedDest.slice() : null;
181
+ var allowStorageAccess = opts.allowStorageAccess !== false;
182
+ // deniedDest → a null-prototype membership map; an operator-supplied
183
+ // destination string is never assigned onto a plain object, so no
184
+ // reserved name (__proto__ / constructor / prototype) can pollute it.
185
+ var deniedDest = null;
186
+ if (Array.isArray(opts.deniedDest) && opts.deniedDest.length > 0) {
187
+ deniedDest = Object.create(null);
188
+ for (var di = 0; di < opts.deniedDest.length; di += 1) {
189
+ deniedDest[opts.deniedDest[di]] = true;
190
+ }
191
+ }
114
192
  var allowedNavigate = opts.allowedNavigate !== false;
115
193
  var methods = (opts.methods || DEFAULT_METHODS).map(function (m) { return m.toUpperCase(); });
116
194
  var auditOn = opts.audit !== false;
@@ -139,6 +217,16 @@ function create(opts) {
139
217
  var mode = headers["sec-fetch-mode"];
140
218
  var dest = headers["sec-fetch-dest"];
141
219
 
220
+ // Destination refusal — independent of site. A FedCM `webidentity`
221
+ // (or any operator-denied) Sec-Fetch-Dest on a route that is not an
222
+ // identity endpoint is refused outright. The membership test is exact
223
+ // (null-prototype map keyed on the verbatim header value), never a
224
+ // substring scan.
225
+ if (deniedDest && typeof dest === "string" && deniedDest[dest] === true) {
226
+ _emitDenied(req, "dest-denied (dest=" + dest + ")");
227
+ return _writeReject(req, res, "Request destination not allowed for this route.", "dest-not-allowed", onDeny, problemMode);
228
+ }
229
+
142
230
  if (typeof site !== "string" || site.length === 0) {
143
231
  // No Sec-Fetch-Site header — legacy browser or non-browser client.
144
232
  // Defer to other auth/CSRF layers per allowMissing.
@@ -165,6 +253,19 @@ function create(opts) {
165
253
  }
166
254
 
167
255
  // cross-site
256
+ // Storage Access API escalation — the browser sends
257
+ // Sec-Fetch-Storage-Access only on cross-site credentialed requests.
258
+ // active|inactive both mean the embedded context can / could reach
259
+ // unpartitioned cross-site cookies; refuse it on routes that do not
260
+ // participate in the Storage Access flow. Exact membership, never a
261
+ // substring scan. Checked before the allowCrossSite shortcut so the
262
+ // escalation is gated even when cross-site is otherwise permitted.
263
+ var storageAccess = headers["sec-fetch-storage-access"];
264
+ if (!allowStorageAccess && typeof storageAccess === "string" &&
265
+ STORAGE_ACCESS_ESCALATED[storageAccess] === true) {
266
+ _emitDenied(req, "storage-access-refused (status=" + storageAccess + ")");
267
+ return _writeReject(req, res, "Storage Access escalation not allowed for this route.", "storage-access-refused", onDeny, problemMode);
268
+ }
168
269
  if (allowCrossSite) return next();
169
270
  if (allowedDest && typeof dest === "string" && allowedDest.indexOf(dest) !== -1) {
170
271
  return next();
@@ -179,6 +179,9 @@ function create(opts) {
179
179
  if (allowOriginHeader !== "*") headers["Vary"] = "Origin";
180
180
  }
181
181
  res.writeHead(200, headers); // HTTP 200
182
+ // HEAD carries the GET headers (incl. Content-Length) with no body
183
+ // (RFC 9110 §9.3.2).
184
+ if ((req.method || "GET").toUpperCase() === "HEAD") { res.end(); return; }
182
185
  res.end(body);
183
186
  }
184
187