@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.
- package/CHANGELOG.md +4 -0
- package/SECURITY.md +22 -9
- package/lib/asset-manifest.json +1 -1
- package/lib/gift-card-ledger.js +72 -15
- package/lib/store-credit.js +340 -89
- package/lib/vendor/MANIFEST.json +53 -31
- package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +7 -2
- package/lib/vendor/blamejs/.github/dependabot.yml +12 -0
- package/lib/vendor/blamejs/.github/workflows/ci.yml +16 -12
- package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +3 -1
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +23 -0
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/examples/wiki/Dockerfile +19 -2
- package/lib/vendor/blamejs/lib/atomic-file.js +32 -9
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +105 -40
- package/lib/vendor/blamejs/lib/middleware/dpop.js +54 -31
- package/lib/vendor/blamejs/lib/middleware/span-http-server.js +7 -0
- package/lib/vendor/blamejs/lib/network-tls.js +12 -2
- package/lib/vendor/blamejs/lib/queue-local.js +123 -46
- package/lib/vendor/blamejs/lib/queue.js +13 -9
- package/lib/vendor/blamejs/lib/request-helpers.js +90 -0
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +69 -7
- package/lib/vendor/blamejs/lib/self-update.js +11 -2
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +6 -2
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +689 -688
- package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +5 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.17.json +52 -0
- package/lib/vendor/blamejs/release-notes/v0.15.18.json +49 -0
- package/lib/vendor/blamejs/release-notes/v0.15.19.json +18 -0
- package/lib/vendor/blamejs/scripts/check-vendor-currency.js +24 -0
- package/lib/vendor/blamejs/test/integration/queue-cluster-mysql.test.js +419 -0
- package/lib/vendor/blamejs/test/integration/queue-cluster-pg.test.js +471 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt-rejection-envelope.test.js +309 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js +138 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dpop-htu-peergating.test.js +227 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +83 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/self-update-poll-asset-digest.test.js +90 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/self-update-standalone-verifier-ecdsa-encoding.test.js +274 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tls-ocsp-freshness.test.js +192 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vendor-currency-classify.test.js +36 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
+
_raise("toctou", { path: filepath });
|
|
937
960
|
}
|
|
938
961
|
} else if (opts.maxBytes !== undefined && fstat.size > opts.maxBytes) {
|
|
939
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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,
|
|
148
|
-
// The proof's htu is the request URI WITHOUT query/fragment. Behind
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// origin
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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,
|
|
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
|
-
|
|
1191
|
-
|
|
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,
|