@blamejs/blamejs-shop 0.4.87 → 0.4.89

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 (46) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/SECURITY.md +22 -9
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/gift-card-ledger.js +72 -15
  5. package/lib/store-credit.js +340 -89
  6. package/lib/vendor/MANIFEST.json +53 -31
  7. package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +7 -2
  8. package/lib/vendor/blamejs/.github/dependabot.yml +12 -0
  9. package/lib/vendor/blamejs/.github/workflows/ci.yml +16 -12
  10. package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +3 -1
  11. package/lib/vendor/blamejs/.github/workflows/release-container.yml +23 -0
  12. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  13. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  14. package/lib/vendor/blamejs/examples/wiki/Dockerfile +19 -2
  15. package/lib/vendor/blamejs/lib/atomic-file.js +32 -9
  16. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +105 -40
  17. package/lib/vendor/blamejs/lib/middleware/dpop.js +54 -31
  18. package/lib/vendor/blamejs/lib/middleware/span-http-server.js +7 -0
  19. package/lib/vendor/blamejs/lib/network-tls.js +12 -2
  20. package/lib/vendor/blamejs/lib/queue-local.js +123 -46
  21. package/lib/vendor/blamejs/lib/queue.js +13 -9
  22. package/lib/vendor/blamejs/lib/request-helpers.js +90 -0
  23. package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +69 -7
  24. package/lib/vendor/blamejs/lib/self-update.js +11 -2
  25. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
  26. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +6 -2
  27. package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +689 -688
  28. package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +5 -0
  29. package/lib/vendor/blamejs/package.json +1 -1
  30. package/lib/vendor/blamejs/release-notes/v0.15.17.json +52 -0
  31. package/lib/vendor/blamejs/release-notes/v0.15.18.json +49 -0
  32. package/lib/vendor/blamejs/release-notes/v0.15.19.json +18 -0
  33. package/lib/vendor/blamejs/scripts/check-vendor-currency.js +24 -0
  34. package/lib/vendor/blamejs/test/integration/queue-cluster-mysql.test.js +419 -0
  35. package/lib/vendor/blamejs/test/integration/queue-cluster-pg.test.js +471 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt-rejection-envelope.test.js +309 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -8
  38. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js +138 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +26 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/dpop-htu-peergating.test.js +227 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +83 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-poll-asset-digest.test.js +90 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/self-update-standalone-verifier-ecdsa-encoding.test.js +274 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/tls-ocsp-freshness.test.js +192 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/vendor-currency-classify.test.js +36 -0
  46. package/package.json +1 -1
@@ -22,7 +22,22 @@
22
22
  # The :latest runtime image runs as `nonroot` (different user, lower
23
23
  # privilege); --chown=nonroot:nonroot on the COPY --from=deps rewrites
24
24
  # ownership at copy time into the runtime image.
25
- FROM cgr.dev/chainguard/node:latest-dev AS deps
25
+ # Base images are NOT digest-pinned in source: Chainguard rebuilds :latest
26
+ # continuously to ship CVE fixes, so a committed digest goes stale within hours
27
+ # and the Trivy CRITICAL/HIGH release gate then rejects the build (e.g. a fixed
28
+ # npm/undici DoS the rolling tag already carries). For a deployed, Trivy-gated
29
+ # image, rolling + always-patched beats reproducible-but-frozen. (Scorecard
30
+ # PinnedDependencies flags this by design — accepted.)
31
+ #
32
+ # To make the Trivy-scanned image identical to the multi-arch image that gets
33
+ # published (release-container.yml builds those separately), the workflow
34
+ # resolves the rolling tag to a digest ONCE at build time and passes it via
35
+ # these build-args, so scan == publish without a stale committed pin. A local
36
+ # `docker build` with no build-args falls back to the rolling tag.
37
+ ARG BASE_DEPS=cgr.dev/chainguard/node:latest-dev
38
+ ARG BASE_RUNTIME=cgr.dev/chainguard/node:latest
39
+
40
+ FROM ${BASE_DEPS} AS deps
26
41
  WORKDIR /home/node
27
42
  COPY --chown=node:node package.json index.js NOTICE LICENSE README.md ./
28
43
  COPY --chown=node:node bin ./bin
@@ -41,7 +56,9 @@ RUN rm -rf node_modules data data-e2e public/dist \
41
56
  && node -e "var b = require('@blamejs/core'); var path = require('node:path'); (async function () { var bundler = b.bundler.create({ entries: { wiki: path.join(process.cwd(), 'src', 'wiki.js'), editor: path.join(process.cwd(), 'src', 'editor.js') }, outdir: path.join(process.cwd(), 'public', 'dist'), manifest: 'manifest.json', hashLen: 16 }); await bundler.build(); console.log('[Dockerfile] bundle pre-built into public/dist'); })().catch(function (e) { console.error('bundle build failed:', e.stack || e); process.exit(1); });" \
42
57
  && mkdir -p /home/node/data-skeleton
43
58
 
44
- FROM cgr.dev/chainguard/node:latest AS runtime
59
+ # See the deps stage above: rolling base, resolved to a digest at build time by
60
+ # release-container.yml (build-arg) so the scanned and published images match.
61
+ FROM ${BASE_RUNTIME} AS runtime
45
62
  # Explicit USER directive — Chainguard's :latest image already runs as
46
63
  # `nonroot` (UID 65532) by default, but Trivy's static Dockerfile
47
64
  # checker (DS-0002) flags any image without an explicit USER line
@@ -895,6 +895,19 @@ function fdSafeReadSync(filepath, opts) {
895
895
  var errorFor = opts.errorFor || function (kind, detail) {
896
896
  return new AtomicFileError((detail && detail.message) || ("atomic-file: " + kind), "atomic-file/" + kind);
897
897
  };
898
+ // Every failure KIND routes through this one mapper-then-fallback gate: throw
899
+ // the caller's typed error when errorFor returns truthy, else the fallback —
900
+ // the raw OS error for enoent (preserving the rethrow-raw posture network-tls
901
+ // / vault / backup rely on), or a synthetic AtomicFileError for the kinds with
902
+ // no underlying OS error to rethrow. An errorFor that returns undefined for a
903
+ // kind must never make us throw the literal `undefined` (the openSync/enoent
904
+ // branch already guarded this; symlink / too-large / toctou / short-read /
905
+ // integrity now do too — #358).
906
+ function _raise(kind, detail, fallbackErr) {
907
+ var typed = errorFor(kind, detail);
908
+ throw typed || fallbackErr ||
909
+ new AtomicFileError((detail && detail.message) || ("atomic-file: " + kind), "atomic-file/" + kind);
910
+ }
898
911
  if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
899
912
  var mode = opts.mode === undefined ? 0o600 : opts.mode;
900
913
  // refuseSymlink: lstat the path first and refuse a symlink source —
@@ -902,10 +915,21 @@ function fdSafeReadSync(filepath, opts) {
902
915
  // fd's inode is re-checked against this lstat's inode below.
903
916
  var lstat = null;
904
917
  if (opts.refuseSymlink) {
905
- lstat = nodeFs.lstatSync(filepath);
906
- if (lstat.isSymbolicLink()) throw errorFor("symlink", { path: filepath });
918
+ try {
919
+ lstat = nodeFs.lstatSync(filepath);
920
+ } catch (lstatErr) {
921
+ // A missing file makes lstat throw raw ENOENT BEFORE the openSync branch
922
+ // that consults errorFor("enoent"). Route it through the same mapping so
923
+ // the missing-file error class is identical with or without refuseSymlink
924
+ // (#358); any other lstat error rethrows raw.
925
+ if (lstatErr && lstatErr.code === "ENOENT") {
926
+ _raise("enoent", { path: filepath, cause: lstatErr }, lstatErr);
927
+ }
928
+ throw lstatErr;
929
+ }
930
+ if (lstat.isSymbolicLink()) _raise("symlink", { path: filepath });
907
931
  if (opts.maxBytes !== undefined && lstat.size > opts.maxBytes) {
908
- throw errorFor("too-large", { size: lstat.size, max: opts.maxBytes });
932
+ _raise("too-large", { size: lstat.size, max: opts.maxBytes });
909
933
  }
910
934
  }
911
935
  // The third argument pins an owner-only mode (0o600 default). The flag
@@ -918,8 +942,7 @@ function fdSafeReadSync(filepath, opts) {
918
942
  fd = nodeFs.openSync(filepath, "r", mode);
919
943
  } catch (openErr) {
920
944
  if (openErr && openErr.code === "ENOENT") {
921
- var typed = errorFor("enoent", { path: filepath, cause: openErr });
922
- if (typed) throw typed;
945
+ _raise("enoent", { path: filepath, cause: openErr }, openErr);
923
946
  }
924
947
  throw openErr;
925
948
  }
@@ -933,10 +956,10 @@ function fdSafeReadSync(filepath, opts) {
933
956
  // plain (no-inodeCheck) reader reports an over-cap as too-large.
934
957
  if (lstat && opts.inodeCheck) {
935
958
  if (fstat.ino !== lstat.ino || (opts.maxBytes !== undefined && fstat.size > opts.maxBytes)) {
936
- throw errorFor("toctou", { path: filepath });
959
+ _raise("toctou", { path: filepath });
937
960
  }
938
961
  } else if (opts.maxBytes !== undefined && fstat.size > opts.maxBytes) {
939
- throw errorFor("too-large", { size: fstat.size, max: opts.maxBytes });
962
+ _raise("too-large", { size: fstat.size, max: opts.maxBytes });
940
963
  }
941
964
  buf = Buffer.alloc(fstat.size);
942
965
  var read = 0;
@@ -947,7 +970,7 @@ function fdSafeReadSync(filepath, opts) {
947
970
  }
948
971
  if (read !== fstat.size) {
949
972
  if (opts.allowShortRead) { buf = buf.slice(0, read); }
950
- else { throw errorFor("short-read", { read: read, size: fstat.size }); }
973
+ else { _raise("short-read", { read: read, size: fstat.size }); }
951
974
  }
952
975
  } finally {
953
976
  try { nodeFs.closeSync(fd); } catch (_c) { /* close best-effort */ }
@@ -955,7 +978,7 @@ function fdSafeReadSync(filepath, opts) {
955
978
  if (opts.expectedHash) {
956
979
  var actual = sha3Hash(buf);
957
980
  if (actual !== opts.expectedHash) {
958
- throw errorFor("integrity", { expected: opts.expectedHash, actual: actual });
981
+ _raise("integrity", { expected: opts.expectedHash, actual: actual });
959
982
  }
960
983
  }
961
984
  var content = opts.encoding ? buf.toString(opts.encoding) : buf;
@@ -223,12 +223,39 @@ function _validSid(sid) {
223
223
  SID_RE.test(sid);
224
224
  }
225
225
 
226
- function _writeRejection(res, code, body) {
226
+ // _writeRejection emit a protocol-level rejection body.
227
+ //
228
+ // On an ESTABLISHED per-session encrypted channel the rejection MUST
229
+ // travel inside the session envelope, exactly like a successful
230
+ // response: a client on a keyed channel that sends a stale / replayed /
231
+ // malformed request would otherwise learn, in cleartext, which check
232
+ // failed over an otherwise-encrypted channel. The middleware stamps
233
+ // `req.apiEncryptRejectEncode` the moment a session is resolved with a
234
+ // valid session key; when present, the body is wrapped through the same
235
+ // response-encryption path successful responses use. Absent it (a
236
+ // pre-session handshake error, where no session context exists yet, or
237
+ // per-request mode) the body falls back to plaintext.
238
+ function _writeRejection(req, res, code, body, opts) {
227
239
  if (res.headersSent || res.writableEnded) return;
228
- if (typeof res.writeHead === "function") {
229
- res.writeHead(code, { "Content-Type": "application/json" });
230
- res.end(JSON.stringify(body));
240
+ if (typeof res.writeHead !== "function") return;
241
+ var out = body;
242
+ // opts.plaintext forces a cleartext body even on an established channel.
243
+ // Used for the generic "encrypted-payload-rejected" refusals that DO NOT
244
+ // delete the session (the monotonic-counter replay and the atomic-claim
245
+ // loss): riding those on the session envelope would emit a response _ctr
246
+ // the client tracks as consumed, but those paths return before the
247
+ // server persists responsesEmitted — so the next genuine response reuses
248
+ // that _ctr and the client refuses it as a replay, bricking the session.
249
+ // The body is already generic (no session-lifecycle reason leaks the way
250
+ // session-expired / rotation-required would), so plaintext here costs no
251
+ // meaningful confidentiality.
252
+ var encode = (!opts || !opts.plaintext) && req && req.apiEncryptRejectEncode;
253
+ if (typeof encode === "function") {
254
+ try { out = encode(body); }
255
+ catch (_e) { out = body; } // encryption failed → fall back to plaintext rather than hang
231
256
  }
257
+ res.writeHead(code, { "Content-Type": "application/json" });
258
+ res.end(JSON.stringify(out));
232
259
  }
233
260
 
234
261
  // ---- Server-side middleware ----
@@ -458,6 +485,26 @@ function create(opts) {
458
485
  return encrypted;
459
486
  }
460
487
 
488
+ // _installRejectEncoder — stamp req with a function that wraps a
489
+ // protocol-level rejection body in the SAME session envelope a
490
+ // successful response uses, so an error emitted on an established
491
+ // per-session encrypted channel does not leak (in cleartext) which
492
+ // check the request tripped. Called the moment a session is resolved
493
+ // with a valid session key, BEFORE the expiry / rotation / replay
494
+ // gates that fire on a keyed channel. The rejection rides a fresh
495
+ // response counter (sid bound, strictly above the session's last
496
+ // emitted response) so the client's monotonic _ctr check still holds.
497
+ // The session-deleting rejections (expired / rotation) and the
498
+ // post-claim tag-mismatch ride this encoder; the two generic surviving
499
+ // rejections (monotonic-counter replay, atomic-claim loss) opt out via
500
+ // _writeRejection({ plaintext: true }) because they return before the
501
+ // consumed counter is persisted — see _writeRejection.
502
+ function _installRejectEncoder(req, sessionKey, sid, responseCtr) {
503
+ req.apiEncryptRejectEncode = function (body) {
504
+ return _encodeEnvelope(body, sessionKey, { sid: sid, responseCtr: responseCtr });
505
+ };
506
+ }
507
+
461
508
  // _wrapResJson — install res.json that encrypts the response with the
462
509
  // session key. In per-request mode the response is `{ _ct }`; in
463
510
  // per-session mode it carries `{ _ct, _sid, _ctr }` so the client can
@@ -507,18 +554,18 @@ function create(opts) {
507
554
  var body = req.body;
508
555
  if (!body || typeof body !== "object") {
509
556
  _emitFailure(req, "shape");
510
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
557
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
511
558
  }
512
559
 
513
560
  var now = Date.now();
514
561
  var ct = body._ct, ts = body._ts;
515
562
  if (typeof ct !== "string" || typeof ts !== "number") {
516
563
  _emitFailure(req, "shape");
517
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
564
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
518
565
  }
519
566
  if (Math.abs(now - ts) > replayWindowMs) {
520
567
  _emitFailure(req, "stale");
521
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
568
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
522
569
  }
523
570
 
524
571
  // Per-request OR per-session bootstrap path: shape includes _ek + _nonce.
@@ -540,25 +587,25 @@ function create(opts) {
540
587
  try { freshNonce = await store.checkAndInsert(nonceHash, expireAt); }
541
588
  catch (_e) {
542
589
  _emitFailure(req, "nonce-store-error");
543
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
590
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
544
591
  }
545
592
  if (!freshNonce) {
546
593
  _emitFailure(req, "replay");
547
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
594
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
548
595
  }
549
596
  sessionKey = _decryptEkToSessionKey(ek);
550
597
  if (!sessionKey) {
551
598
  _emitFailure(req, "tag");
552
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
599
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
553
600
  }
554
601
  if (keying === "per-session") {
555
602
  if (!_validSid(sid)) {
556
603
  _emitFailure(req, "shape");
557
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
604
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
558
605
  }
559
606
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
560
607
  _emitFailure(req, "shape");
561
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
608
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
562
609
  }
563
610
  // Bootstrap a new session row keyed by sid. responsesEmitted is
564
611
  // set to 1 (this bootstrap emits one response) BEFORE the store
@@ -579,7 +626,7 @@ function create(opts) {
579
626
  try { await sessionStore.set(sid, session, { ttlMs: sessionTtlMs }); }
580
627
  catch (_e) {
581
628
  _emitFailure(req, "session-store-error");
582
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
629
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
583
630
  }
584
631
  _emitObs("apiEncrypt.session.created", 1, { mode: "per-session" });
585
632
  _emitSessionAudit("apiEncrypt.session.created", {
@@ -588,28 +635,56 @@ function create(opts) {
588
635
  requestId: req.requestId || null,
589
636
  });
590
637
  sessionCtx = { sid: sid, responseCtr: 1 };
638
+ // Session now established — a post-bootstrap rejection (e.g. the
639
+ // final decrypt below) rides the session envelope under the same
640
+ // counter the success response would have used.
641
+ _installRejectEncoder(req, sessionKey, sid, 1);
591
642
  }
592
643
  } else if (keying === "per-session" &&
593
644
  typeof sid === "string" && typeof ctr === "number") {
594
645
  // ---- Per-session subsequent-request path ----
595
646
  if (!_validSid(sid)) {
596
647
  _emitFailure(req, "shape");
597
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
648
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
598
649
  }
599
650
  if (!numericBounds.isNonNegativeFiniteInt(ctr)) {
600
651
  _emitFailure(req, "shape");
601
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
652
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
602
653
  }
603
654
  try { session = await sessionStore.get(sid); }
604
655
  catch (_e) {
605
656
  _emitFailure(req, "session-store-error");
606
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
657
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
607
658
  }
608
659
  if (!session) {
609
660
  _emitObs("apiEncrypt.session.unknown", 1, {});
610
661
  _emitFailure(req, "session-unknown");
611
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
662
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-unknown" });
663
+ }
664
+ // Recover + coerce the session key BEFORE the expiry / rotation /
665
+ // replay gates so a rejection on this established channel can ride
666
+ // the session envelope (the channel is keyed the moment the sid
667
+ // resolves to a stored session, even if THIS request is then
668
+ // refused). An operator store may have JSON-serialised the buffer.
669
+ sessionKey = session.sessionKey;
670
+ if (Buffer.isBuffer(sessionKey) === false) {
671
+ if (typeof sessionKey === "string") {
672
+ sessionKey = Buffer.from(sessionKey, "base64");
673
+ } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
674
+ sessionKey = Buffer.from(sessionKey.data);
675
+ } else if (sessionKey instanceof Uint8Array) {
676
+ sessionKey = Buffer.from(sessionKey);
677
+ }
678
+ }
679
+ if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
680
+ sessionKey = null;
681
+ _emitFailure(req, "session-store-error");
682
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
612
683
  }
684
+ // From here the channel is established: error bodies encrypt. The
685
+ // rejection counter sits one above the session's last emitted
686
+ // response so the client's strictly-increasing _ctr check holds.
687
+ _installRejectEncoder(req, sessionKey, sid, session.responsesEmitted + 1);
613
688
  if (now > session.expiresAt) {
614
689
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
615
690
  _emitObs("apiEncrypt.session.expired", 1, {});
@@ -620,7 +695,7 @@ function create(opts) {
620
695
  requestId: req.requestId || null,
621
696
  });
622
697
  _emitFailure(req, "session-expired");
623
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
698
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-expired" });
624
699
  }
625
700
  if (session.responsesEmitted >= sessionMaxResponses) {
626
701
  try { await sessionStore.delete(sid); } catch (_e) { /* best-effort */ }
@@ -632,7 +707,7 @@ function create(opts) {
632
707
  requestId: req.requestId || null,
633
708
  });
634
709
  _emitFailure(req, "session-rotation-required");
635
- return _writeRejection(res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
710
+ return _writeRejection(req, res, HTTP_STATUS.UNAUTHORIZED, { error: "session-rotation-required" });
636
711
  }
637
712
  // Replay defense: counter MUST strictly increase.
638
713
  if (ctr <= session.lastReqCtr) {
@@ -644,7 +719,10 @@ function create(opts) {
644
719
  requestId: req.requestId || null,
645
720
  });
646
721
  _emitFailure(req, "counter-replay");
647
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
722
+ // Plaintext: this path does not persist a consumed response counter
723
+ // (see _writeRejection). Keeping it cleartext avoids desyncing the
724
+ // session's response-counter sequence.
725
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
648
726
  }
649
727
  // Atomic replay gate (CWE-367). The monotonic counter check above is
650
728
  // an ordering fast-path only: on a clustered session store, get(sid)
@@ -674,7 +752,7 @@ function create(opts) {
674
752
  try { ctrFresh = await store.checkAndInsert(ctrKey, session.expiresAt); }
675
753
  catch (_e) {
676
754
  _emitFailure(req, "nonce-store-error");
677
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
755
+ return _writeRejection(req, res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "nonce-store-unavailable" });
678
756
  }
679
757
  if (!ctrFresh) {
680
758
  _emitObs("apiEncrypt.session.replay_rejected", 1, { lane: "atomic" });
@@ -685,23 +763,10 @@ function create(opts) {
685
763
  requestId: req.requestId || null,
686
764
  });
687
765
  _emitFailure(req, "counter-replay");
688
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
689
- }
690
- sessionKey = session.sessionKey;
691
- if (Buffer.isBuffer(sessionKey) === false) {
692
- // Operator-supplied store may have JSON-serialised the buffer.
693
- // Accept hex / base64 / Uint8Array and coerce.
694
- if (typeof sessionKey === "string") {
695
- sessionKey = Buffer.from(sessionKey, "base64");
696
- } else if (sessionKey && sessionKey.type === "Buffer" && Array.isArray(sessionKey.data)) {
697
- sessionKey = Buffer.from(sessionKey.data);
698
- } else if (sessionKey instanceof Uint8Array) {
699
- sessionKey = Buffer.from(sessionKey);
700
- }
701
- }
702
- if (!Buffer.isBuffer(sessionKey) || sessionKey.length !== SESSION_KEY_BYTES) {
703
- _emitFailure(req, "session-store-error");
704
- return _writeRejection(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "session-store-unavailable" });
766
+ // Plaintext for the same reason as the monotonic-replay path above:
767
+ // the atomic-claim loser returns before responsesEmitted is persisted,
768
+ // so encrypting it would consume a response _ctr the server never records.
769
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" }, { plaintext: true });
705
770
  }
706
771
  session.lastReqCtr = ctr;
707
772
  session.lastUsedAt = now;
@@ -711,7 +776,7 @@ function create(opts) {
711
776
  sessionCtx = { sid: sid, responseCtr: session.responsesEmitted };
712
777
  } else {
713
778
  _emitFailure(req, "shape");
714
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
779
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-required" });
715
780
  }
716
781
 
717
782
  // Decrypt _ct → cleartext payload bytes → JSON object. The request
@@ -726,7 +791,7 @@ function create(opts) {
726
791
  clearObj = safeJson.parse(ptBuf.toString("utf8"), { maxBytes: maxDecryptedBytes });
727
792
  } catch (_e) {
728
793
  _emitFailure(req, "tag");
729
- return _writeRejection(res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
794
+ return _writeRejection(req, res, HTTP_STATUS.BAD_REQUEST, { error: "encrypted-payload-rejected" });
730
795
  }
731
796
 
732
797
  // Replace req.body with cleartext, stash session key for any
@@ -144,33 +144,19 @@ function _nonceManager(rotateSec) {
144
144
  };
145
145
  }
146
146
 
147
- function _reconstructHtu(req, mopts) {
148
- // The proof's htu is the request URI WITHOUT query/fragment. Behind
149
- // a reverse proxy the operator may need to override via opts.htu /
150
- // opts.getHtu. X-Forwarded-* headers are ATTACKER-CONTROLLED when
151
- // the origin is reachable directly; an attacker who can hit the
152
- // origin while spoofing X-Forwarded-Proto: https can trick this
153
- // function into building an `https` htu that the DPoP proof was
154
- // signed for when the origin is actually serving HTTP. RFC 9449
155
- // §4.3 says htu MUST be the absolute URL the request was sent to.
156
- //
157
- // Default: ignore X-Forwarded-* and derive proto/host from the
158
- // socket. Operators with a confirmed-trusted front proxy opt in
159
- // via opts.trustForwardedHeaders: true.
160
- mopts = mopts || {};
161
- var trustForwarded = mopts.trustForwardedHeaders === true;
162
- var proto;
163
- if (trustForwarded && req.headers["x-forwarded-proto"]) {
164
- proto = String(req.headers["x-forwarded-proto"]).split(",")[0].trim();
165
- } else {
166
- proto = req.socket && req.socket.encrypted ? "https" : "http";
167
- }
168
- var host;
169
- if (trustForwarded && req.headers["x-forwarded-host"]) {
170
- host = String(req.headers["x-forwarded-host"]).split(",")[0].trim();
171
- } else {
172
- host = req.headers.host;
173
- }
147
+ function _reconstructHtu(req, protoResolver, hostResolver) {
148
+ // The proof's htu is the request URI WITHOUT query/fragment. Behind a
149
+ // reverse proxy the operator may override via opts.getHtu. RFC 9449 §4.3
150
+ // says htu MUST be the absolute URL the request was sent to — and it is
151
+ // cryptographically bound in the proof, so a forged scheme/authority lets a
152
+ // proof signed for one origin validate against another. proto + host are
153
+ // resolved through the peer-gated requestHelpers resolvers built in create():
154
+ // X-Forwarded-Proto / -Host are honored only from a declared trusted-proxy
155
+ // peer; otherwise the real TLS socket scheme + the request's own Host are
156
+ // used and forged forwarded headers are ignored.
157
+ if (!req || !req.headers) return null;
158
+ var proto = protoResolver.resolve(req);
159
+ var host = hostResolver.resolve(req);
174
160
  if (!host) return null;
175
161
  var path = req.url || "/";
176
162
  var qIdx = path.indexOf("?");
@@ -205,6 +191,9 @@ function _reconstructHtu(req, mopts) {
205
191
  * getAccessToken: function(req): string|null,
206
192
  * getNonce: async function(req): string|null,
207
193
  * getHtu: function(req): string,
194
+ * trustedProxies: string|string[], // CIDRs of your reverse proxies — peer-gates X-Forwarded-Proto + X-Forwarded-Host for htu reconstruction
195
+ * protocolResolver: function(req): "http"|"https", // own the scheme decision
196
+ * hostResolver: function(req): string|null, // own the authority decision
208
197
  * nonceStore: object,
209
198
  * nonceWindowSec: number,
210
199
  * nonceRotateSec: number,
@@ -228,9 +217,11 @@ function create(opts) {
228
217
  "replayStore", "algorithms", "iatWindowSec",
229
218
  "getAccessToken", "getNonce", "getHtu", "audit",
230
219
  "nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
231
- // v0.9.4 opt-in trust gate for X-Forwarded-Proto/Host when
232
- // reconstructing htu. Default off; operators
233
- // with a confirmed-trusted front proxy set this to `true`.
220
+ // htu reconstruction trust. trustedProxies (CIDRs) peer-gates
221
+ // X-Forwarded-Proto + X-Forwarded-Host; protocolResolver/hostResolver let
222
+ // the operator own each. trustForwardedHeaders (legacy boolean) is refused
223
+ // on its own — see the peer-gating block below.
224
+ "trustedProxies", "protocolResolver", "hostResolver",
234
225
  "trustForwardedHeaders", "onDeny", "problemDetails",
235
226
  ], "middleware.dpop");
236
227
 
@@ -282,6 +273,38 @@ function create(opts) {
282
273
  validateOpts.optionalFunction(opts.getHtu,
283
274
  "middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
284
275
 
276
+ // htu reconstruction (RFC 9449 §4.3) builds the absolute request URL —
277
+ // proto + host — that the proof's cryptographically-bound `htu` claim is
278
+ // verified against. Behind a proxy both come from forgeable X-Forwarded-*
279
+ // headers, so resolve them through the peer-gated requestHelpers primitives
280
+ // (the same fail-closed model csrf-protect / security-headers / cors use):
281
+ // X-Forwarded-Proto / -Host are honored ONLY when the immediate peer is a
282
+ // declared trusted proxy. The legacy trustForwardedHeaders:true trusted the
283
+ // headers from ANY caller — a direct attacker could forge XFP:https / a
284
+ // victim XFH to make a proof signed for one origin validate against another
285
+ // (htu confusion). It is refused on its own; migrate to trustedProxies.
286
+ var _proto = requestHelpers.trustedProtocol({
287
+ trustedProxies: opts.trustedProxies,
288
+ protocolResolver: opts.protocolResolver,
289
+ });
290
+ var _host = requestHelpers.trustedHost({
291
+ trustedProxies: opts.trustedProxies,
292
+ hostResolver: opts.hostResolver,
293
+ });
294
+ // Only refuse the spoofable legacy flag when the htu is actually
295
+ // reconstructed from the request. When the operator supplies getHtu they own
296
+ // the entire URI, _reconstructHtu (and the forwarded headers) is never
297
+ // consulted, so a leftover trustForwardedHeaders is moot — don't fail
298
+ // construction on it (the error text even offers getHtu as a migration path).
299
+ if (typeof opts.getHtu !== "function" && opts.trustForwardedHeaders === true && !_proto.peerGated) {
300
+ throw new AuthError("auth-dpop/bad-opt",
301
+ "middleware.dpop: trustForwardedHeaders is spoofable for the htu reconstruction " +
302
+ "(a direct caller can forge X-Forwarded-Proto / X-Forwarded-Host) and is no longer " +
303
+ "honored on its own. Declare your reverse proxies via trustedProxies: [\"10.0.0.0/8\", …] " +
304
+ "(peer-gates X-Forwarded-Proto + X-Forwarded-Host), or own the decision via " +
305
+ "protocolResolver(req) / hostResolver(req) / getHtu(req).");
306
+ }
307
+
285
308
  function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
286
309
 
287
310
  var middleware = async function dpopMiddleware(req, res, next) {
@@ -309,7 +332,7 @@ function create(opts) {
309
332
  "multiple DPoP proofs in one header value are not allowed", null, onDeny, problemMode);
310
333
  }
311
334
 
312
- var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
335
+ var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, _proto, _host));
313
336
  if (!htu) {
314
337
  return _writeUnauthorized(req, res, "invalid_dpop_proof", "could not reconstruct htu", null, onDeny, problemMode);
315
338
  }
@@ -68,6 +68,11 @@ function _splitUrl(url) {
68
68
  }
69
69
 
70
70
  function _scheme(req) {
71
+ // Display-only: the OTel url.scheme span attribute reflects the scheme the
72
+ // client used (forwarded), NOT a Secure/HSTS/origin trust decision. Routing
73
+ // through trustedProtocol would drop the forwarded scheme from spans behind a
74
+ // proxy (less accurate telemetry) for no security gain.
75
+ // allow:raw-xfp — telemetry label, not a trust sink (see above).
71
76
  var x = req.headers && (req.headers["x-forwarded-proto"] || "");
72
77
  if (typeof x === "string" && x.length > 0) {
73
78
  var first = x.split(",")[0].trim().toLowerCase();
@@ -77,6 +82,8 @@ function _scheme(req) {
77
82
  }
78
83
 
79
84
  function _serverAddress(req) {
85
+ // allow:raw-xfp — display-only: server.address span attribute (telemetry),
86
+ // not an authority trust decision. Same rationale as _scheme above.
80
87
  var hostHeader = req.headers && (req.headers["x-forwarded-host"] || req.headers.host);
81
88
  if (typeof hostHeader === "string" && hostHeader.length > 0) {
82
89
  return hostHeader.split(",")[0].trim();
@@ -1187,8 +1187,18 @@ function evaluateOcspResponse(ocspDer, opts) {
1187
1187
  var clockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min if absent or invalid
1188
1188
  ? opts.clockSkewMs : C.TIME.minutes(5);
1189
1189
  var now = typeof opts.now === "number" ? opts.now : Date.now();
1190
- var thisUpdateMs = match.thisUpdate ? Date.parse(match.thisUpdate) : NaN;
1191
- var nextUpdateMs = match.nextUpdate ? Date.parse(match.nextUpdate) : NaN;
1190
+ // thisUpdate / nextUpdate are already unix-ms NUMBERS (parseOcspResponse
1191
+ // _parseTime returns Date.UTC(...)). Do NOT Date.parse() them: Date.parse
1192
+ // coerces its argument to a string, and a bare-integer string is not a
1193
+ // recognized date format, so Date.parse(<number>) is NaN — which made the
1194
+ // !isFinite guard below reject every response (fresh or stale) with a
1195
+ // misleading "missing thisUpdate", leaving the real staleness window checks
1196
+ // (future thisUpdate, past nextUpdate) as unreachable dead code. Read the
1197
+ // numbers directly; the typeof guard keeps a legitimate epoch-0 thisUpdate
1198
+ // (Date.UTC(1970,0,1)===0, which is falsy) and maps a null nextUpdate to NaN
1199
+ // so the optional-field branch still no-ops.
1200
+ var thisUpdateMs = typeof match.thisUpdate === "number" ? match.thisUpdate : NaN;
1201
+ var nextUpdateMs = typeof match.nextUpdate === "number" ? match.nextUpdate : NaN;
1192
1202
  if (!isFinite(thisUpdateMs)) {
1193
1203
  return { ok: false, status: parsed.status, signatureValid: true,
1194
1204
  certStatus: match.certStatus,