@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
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ /**
3
+ * api-encrypt — protocol-level rejection bodies must ride the session
4
+ * envelope on an ESTABLISHED per-session encrypted channel (#361).
5
+ *
6
+ * A client on a keyed per-session channel that sends a stale / replayed
7
+ * / malformed request used to get an UNENCRYPTED { error: <code> } back,
8
+ * leaking — in cleartext, over an otherwise-encrypted channel — which
9
+ * validation tripped. The rejection must instead be wrapped exactly like
10
+ * a successful response ({ _ct, _sid, _ctr }), with plaintext reserved
11
+ * for pre-session handshake errors where no session context exists yet.
12
+ *
13
+ * Run standalone:
14
+ * node test/layer-0-primitives/api-encrypt-rejection-envelope.test.js
15
+ * Or via smoke: node test/smoke.js
16
+ */
17
+
18
+ var helpers = require("../helpers");
19
+ var b = helpers.b;
20
+ var check = helpers.check;
21
+ var _bodyReq = helpers._bodyReq;
22
+ var _bodyRes = helpers._bodyRes;
23
+
24
+ function _newFinish(res) {
25
+ return new Promise(function (resolve) { res.on("finish", resolve); });
26
+ }
27
+
28
+ function _mkRes() {
29
+ var res = _bodyRes();
30
+ // Mirror the router's res.json convention so the response-encryption
31
+ // wrap chains correctly (the router installs this in production).
32
+ res.json = function (data) {
33
+ res.writeHead(res.statusCode || 200, { "Content-Type": "application/json" });
34
+ res.end(JSON.stringify(data));
35
+ };
36
+ res.statusCode = 200;
37
+ return res;
38
+ }
39
+
40
+ function _serverKeypair() {
41
+ return b.crypto.generateEncryptionKeyPair();
42
+ }
43
+
44
+ // On an established per-session channel a rejection that consumes a
45
+ // persisted response counter (the post-claim tag-mismatch path) MUST be an
46
+ // encrypted envelope, not a plaintext { error } — leaking, in cleartext over
47
+ // an otherwise-encrypted channel, which check tripped. RED before #361
48
+ // (rejection emitted as plaintext JSON); GREEN once _writeRejection wraps it
49
+ // in the session envelope. The session must remain USABLE afterwards: the
50
+ // rejection rides a response counter the server actually persisted (the
51
+ // atomic claim ran before the decrypt failed), so the client's monotonic
52
+ // _ctr check still holds on the next genuine response.
53
+ async function testRejectionEncryptedOnEstablishedSession() {
54
+ var keypair = _serverKeypair();
55
+ var mw = b.middleware.apiEncrypt({
56
+ keypair: keypair,
57
+ audit: false,
58
+ keying: "per-session",
59
+ sessionTtlMs: 60_000,
60
+ });
61
+ var clientCtx = b.middleware.apiEncrypt.client({
62
+ pubkey: keypair, keying: "per-session",
63
+ });
64
+
65
+ // Bootstrap the session (ctr=1) — establishes the keyed channel.
66
+ var first = clientCtx.encryptRequest({ user: "alice" });
67
+ var req1 = _bodyReq("POST", { "content-type": "application/json" }, "");
68
+ req1.body = first.body;
69
+ var res1 = _mkRes();
70
+ var fin1 = _newFinish(res1);
71
+ await mw(req1, res1, function () { res1.json({ ok: true, n: 1 }); });
72
+ await fin1;
73
+ check("bootstrap returns 200", res1._endedStatus === 200);
74
+ var resp1 = JSON.parse(res1._captured);
75
+ check("bootstrap response is encrypted", typeof resp1._ct === "string");
76
+ first.decryptResponse(resp1); // advances the client's response counter
77
+
78
+ // Valid second request (ctr=2) — server now expects ctr>2.
79
+ var second = clientCtx.encryptRequest({ user: "alice", action: "ping" });
80
+ var req2 = _bodyReq("POST", { "content-type": "application/json" }, "");
81
+ req2.body = second.body;
82
+ var res2 = _mkRes();
83
+ var fin2 = _newFinish(res2);
84
+ await mw(req2, res2, function () { res2.json({ ok: true, n: 2 }); });
85
+ await fin2;
86
+ check("second request returns 200", res2._endedStatus === 200);
87
+ second.decryptResponse(JSON.parse(res2._captured));
88
+
89
+ // Third request with a FRESH counter (ctr=3) but a corrupted ciphertext:
90
+ // it passes the shape / expiry / rotation / replay gates AND wins the
91
+ // atomic (sid, ctr) claim — so the server persists the consumed response
92
+ // counter — then fails the AEAD decrypt. This rejection rides the session
93
+ // envelope under that persisted counter (the leak vector #361 closes).
94
+ var third = clientCtx.encryptRequest({ user: "alice", action: "tamper" });
95
+ var req3 = _bodyReq("POST", { "content-type": "application/json" }, "");
96
+ req3.body = JSON.parse(JSON.stringify(third.body));
97
+ req3.body._ct = Buffer.from("corrupted-ciphertext-bytes").toString("base64");
98
+ var res3 = _mkRes();
99
+ var fin3 = _newFinish(res3);
100
+ await mw(req3, res3, function () {
101
+ check("tampered ciphertext must NOT reach next()", false);
102
+ });
103
+ await fin3;
104
+
105
+ check("tampered request on established session returns 400", res3._endedStatus === 400);
106
+
107
+ var rejBody = JSON.parse(res3._captured);
108
+ // The core assertion: the rejection is an ENCRYPTED envelope, NOT a
109
+ // plaintext { error: ... }. On the buggy tree rejBody.error is the
110
+ // cleartext code and rejBody._ct is absent.
111
+ check("rejection body carries NO plaintext error field",
112
+ rejBody.error === undefined);
113
+ check("rejection body is an encrypted envelope (_ct present)",
114
+ typeof rejBody._ct === "string");
115
+ check("rejection envelope is bound to the session sid",
116
+ rejBody._sid === first.body._sid);
117
+ check("rejection envelope carries a response counter",
118
+ typeof rejBody._ctr === "number");
119
+
120
+ // The encrypted rejection must decrypt under the session key to reveal
121
+ // the error code only to the legitimate key-holder — and decrypting it
122
+ // advances the client's response counter.
123
+ var plain = third.decryptResponse(rejBody);
124
+ check("decrypted rejection reveals the error code to the key-holder",
125
+ plain && typeof plain.error === "string" && plain.error.length > 0);
126
+
127
+ // P2 regression guard: the session must still be usable. The encrypted
128
+ // rejection consumed a counter the server PERSISTED (it won the claim), so
129
+ // the next genuine request's response counter is strictly above the one
130
+ // the client just saw — decrypting it must NOT throw a replay error.
131
+ var fourth = clientCtx.encryptRequest({ user: "alice", action: "after-reject" });
132
+ var req4 = _bodyReq("POST", { "content-type": "application/json" }, "");
133
+ req4.body = fourth.body;
134
+ var res4 = _mkRes();
135
+ var fin4 = _newFinish(res4);
136
+ await mw(req4, res4, function () { res4.json({ ok: true, n: 4 }); });
137
+ await fin4;
138
+ check("a genuine request after an encrypted rejection still returns 200",
139
+ res4._endedStatus === 200);
140
+ var afterReject = fourth.decryptResponse(JSON.parse(res4._captured));
141
+ check("its response decrypts cleanly (counter stayed monotonic)",
142
+ afterReject && afterReject.ok === true && afterReject.n === 4);
143
+ }
144
+
145
+ // P2: the two GENERIC surviving-session rejections (monotonic-counter replay
146
+ // and atomic-claim loss) return BEFORE the consumed response counter is
147
+ // persisted. Encrypting them would emit a _ctr the client tracks as consumed
148
+ // while the server never records it — so the next genuine response reuses
149
+ // that _ctr and the client refuses it as a replay, bricking the session.
150
+ // They must stay PLAINTEXT (their body is generic — no session-lifecycle
151
+ // reason leaks), and the session must remain usable across the rejection.
152
+ // RED before the P2 fix (replay rejection encrypted → the follow-up valid
153
+ // response desyncs → decryptResponse throws); GREEN after.
154
+ async function testReplayRejectionStaysPlaintextAndSessionSurvives() {
155
+ var keypair = _serverKeypair();
156
+ var mw = b.middleware.apiEncrypt({
157
+ keypair: keypair, audit: false, keying: "per-session", sessionTtlMs: 60_000,
158
+ });
159
+ var clientCtx = b.middleware.apiEncrypt.client({
160
+ pubkey: keypair, keying: "per-session",
161
+ });
162
+
163
+ // Bootstrap + one valid request so the channel is established (lastReqCtr=2).
164
+ var first = clientCtx.encryptRequest({ user: "bob" });
165
+ var req1 = _bodyReq("POST", { "content-type": "application/json" }, "");
166
+ req1.body = first.body;
167
+ var res1 = _mkRes();
168
+ var fin1 = _newFinish(res1);
169
+ await mw(req1, res1, function () { res1.json({ ok: true, n: 1 }); });
170
+ await fin1;
171
+ first.decryptResponse(JSON.parse(res1._captured));
172
+
173
+ var second = clientCtx.encryptRequest({ user: "bob", action: "ping" });
174
+ var req2 = _bodyReq("POST", { "content-type": "application/json" }, "");
175
+ req2.body = second.body;
176
+ var res2 = _mkRes();
177
+ var fin2 = _newFinish(res2);
178
+ await mw(req2, res2, function () { res2.json({ ok: true, n: 2 }); });
179
+ await fin2;
180
+ second.decryptResponse(JSON.parse(res2._captured));
181
+
182
+ // REPLAY the second request verbatim (stale _ctr=2 <= lastReqCtr=2). This
183
+ // is the monotonic-replay path: it must refuse, and the refusal stays
184
+ // plaintext so no unpersisted response counter is consumed.
185
+ var reqR = _bodyReq("POST", { "content-type": "application/json" }, "");
186
+ reqR.body = JSON.parse(JSON.stringify(second.body));
187
+ var resR = _mkRes();
188
+ var finR = _newFinish(resR);
189
+ await mw(reqR, resR, function () { check("replay must NOT reach next()", false); });
190
+ await finR;
191
+ check("replay on established session returns 400", resR._endedStatus === 400);
192
+ var replBody = JSON.parse(resR._captured);
193
+ check("replay rejection is generic PLAINTEXT (no consumed counter)",
194
+ replBody.error === "encrypted-payload-rejected" && replBody._ct === undefined);
195
+
196
+ // The session must still work: a genuine next request (ctr=3) succeeds and
197
+ // its response decrypts cleanly. On the pre-P2 tree the replay rejection
198
+ // was an encrypted envelope whose _ctr the client consumed, so this
199
+ // decryptResponse would throw CLIENT_RESPONSE_REPLAY.
200
+ var third = clientCtx.encryptRequest({ user: "bob", action: "after-replay" });
201
+ var req3 = _bodyReq("POST", { "content-type": "application/json" }, "");
202
+ req3.body = third.body;
203
+ var res3 = _mkRes();
204
+ var fin3 = _newFinish(res3);
205
+ await mw(req3, res3, function () { res3.json({ ok: true, n: 3 }); });
206
+ await fin3;
207
+ check("a genuine request after a plaintext replay rejection returns 200",
208
+ res3._endedStatus === 200);
209
+ var afterReplay = third.decryptResponse(JSON.parse(res3._captured));
210
+ check("its response decrypts cleanly (replay rejection did not desync the counter)",
211
+ afterReplay && afterReplay.ok === true && afterReplay.n === 3);
212
+ }
213
+
214
+ // A pre-session handshake error — a bootstrap envelope whose _ek does not
215
+ // decrypt — has no session context yet, so the rejection MUST stay
216
+ // plaintext (the client cannot derive a session key to read an encrypted
217
+ // body it never established).
218
+ async function testHandshakeErrorStaysPlaintext() {
219
+ var keypair = _serverKeypair();
220
+ var mw = b.middleware.apiEncrypt({
221
+ keypair: keypair, audit: false, keying: "per-session", sessionTtlMs: 60_000,
222
+ });
223
+ var clientCtx = b.middleware.apiEncrypt.client({
224
+ pubkey: keypair, keying: "per-session",
225
+ });
226
+
227
+ // Bootstrap shape, but corrupt _ek so it fails to decrypt to a session
228
+ // key — no session is ever established.
229
+ var first = clientCtx.encryptRequest({ user: "mallory" });
230
+ var req = _bodyReq("POST", { "content-type": "application/json" }, "");
231
+ req.body = JSON.parse(JSON.stringify(first.body));
232
+ req.body._ek = Buffer.from("not-a-valid-envelope").toString("base64");
233
+ var res = _mkRes();
234
+ var fin = _newFinish(res);
235
+ await mw(req, res, function () {
236
+ check("handshake failure must NOT reach next()", false);
237
+ });
238
+ await fin;
239
+
240
+ check("handshake error returns 400", res._endedStatus === 400);
241
+ var body = JSON.parse(res._captured);
242
+ check("pre-session handshake error stays PLAINTEXT (has error field)",
243
+ typeof body.error === "string" && body.error.length > 0);
244
+ check("pre-session handshake error is NOT an encrypted envelope",
245
+ body._ct === undefined);
246
+ }
247
+
248
+ // A subsequent-shape request with a sid the server never saw is also a
249
+ // pre-session error (no key to encrypt under) — must stay plaintext.
250
+ async function testUnknownSidStaysPlaintext() {
251
+ var keypair = _serverKeypair();
252
+ var mw = b.middleware.apiEncrypt({
253
+ keypair: keypair, audit: false, keying: "per-session",
254
+ });
255
+ var req = _bodyReq("POST", { "content-type": "application/json" }, "");
256
+ req.body = {
257
+ _ct: Buffer.from("nope").toString("base64"),
258
+ _ts: Date.now(),
259
+ _sid: "12345678-1234-4234-8234-123456789012",
260
+ _ctr: 5,
261
+ };
262
+ var res = _mkRes();
263
+ var fin = _newFinish(res);
264
+ await mw(req, res, function () {
265
+ check("unknown sid must NOT reach next()", false);
266
+ });
267
+ await fin;
268
+ check("unknown-sid returns 401", res._endedStatus === 401);
269
+ var body = JSON.parse(res._captured);
270
+ check("unknown-sid rejection stays plaintext (no session to key under)",
271
+ typeof body.error === "string" && body._ct === undefined);
272
+ }
273
+
274
+ // Per-request mode never establishes a session, so its rejections stay
275
+ // plaintext (regression guard: the fix must not encrypt per-request
276
+ // errors, which have no session key on req).
277
+ async function testPerRequestModeRejectionStaysPlaintext() {
278
+ var keypair = _serverKeypair();
279
+ var mw = b.middleware.apiEncrypt({ keypair: keypair, audit: false });
280
+ var req = _bodyReq("POST", { "content-type": "application/json" }, "");
281
+ req.body = { _ct: "garbage", _ts: Date.now() }; // no _ek/_nonce → shape error
282
+ var res = _mkRes();
283
+ var fin = _newFinish(res);
284
+ await mw(req, res, function () {
285
+ check("per-request shape error must NOT reach next()", false);
286
+ });
287
+ await fin;
288
+ check("per-request rejection returns 400", res._endedStatus === 400);
289
+ var body = JSON.parse(res._captured);
290
+ check("per-request rejection stays plaintext",
291
+ typeof body.error === "string" && body._ct === undefined);
292
+ }
293
+
294
+ async function run() {
295
+ await testRejectionEncryptedOnEstablishedSession();
296
+ await testReplayRejectionStaysPlaintextAndSessionSurvives();
297
+ await testHandshakeErrorStaysPlaintext();
298
+ await testUnknownSidStaysPlaintext();
299
+ await testPerRequestModeRejectionStaysPlaintext();
300
+ }
301
+
302
+ module.exports = { run: run };
303
+
304
+ if (require.main === module) {
305
+ run().then(
306
+ function () { console.log("OK — " + helpers.getChecks() + " checks passed"); },
307
+ function (e) { console.error("FAIL:", e.message); console.error(e.stack); process.exit(1); }
308
+ );
309
+ }
@@ -45,6 +45,15 @@ function _serverKeypair() {
45
45
  return b.crypto.generateEncryptionKeyPair();
46
46
  }
47
47
 
48
+ // #361 — a rejection emitted on an ESTABLISHED per-session encrypted channel is
49
+ // wrapped in the session envelope (an { _ct, ... } object), so its reason never
50
+ // travels in cleartext. Pre-session / per-request rejections stay plaintext
51
+ // ({ error: <code> }). This helper distinguishes the two captured-body shapes.
52
+ function _isEncryptedEnvelope(captured) {
53
+ try { var p = JSON.parse(captured); return !!p && typeof p._ct === "string"; }
54
+ catch (_e) { return false; }
55
+ }
56
+
48
57
  // ---- Nonce-store ----
49
58
 
50
59
  async function testNonceStoreSurface() {
@@ -1006,8 +1015,14 @@ async function testApiEncryptPerSessionExpiry() {
1006
1015
  });
1007
1016
  await fin2;
1008
1017
  check("per-session expired: 401", res2._endedStatus === 401);
1009
- check("per-session expired: body says session-expired or session-unknown",
1010
- /session-expired|session-unknown/.test(res2._captured));
1018
+ // #361: when the store still holds the (past-TTL) row the channel is
1019
+ // established, so the expiry rejection is wrapped in the session envelope;
1020
+ // when the store has already evicted the TTL=1ms row the request is
1021
+ // session-unknown BEFORE any key is resolved, so it stays plaintext. Both
1022
+ // are valid 401 refusals — accept either (neither leaks "session-expired"
1023
+ // over an established channel in cleartext).
1024
+ check("per-session expired: encrypted envelope OR plaintext session-unknown",
1025
+ _isEncryptedEnvelope(res2._captured) || /session-unknown/.test(res2._captured));
1011
1026
  }
1012
1027
 
1013
1028
  async function testApiEncryptPerSessionMaxResponses() {
@@ -1042,8 +1057,9 @@ async function testApiEncryptPerSessionMaxResponses() {
1042
1057
  });
1043
1058
  await fin2;
1044
1059
  check("per-session maxResponses exceeded: 401", res2._endedStatus === 401);
1045
- check("per-session maxResponses exceeded: body says rotation-required",
1046
- /session-rotation-required/.test(res2._captured));
1060
+ // #361: established channel → the rotation-required rejection is encrypted.
1061
+ check("per-session maxResponses exceeded: rejection is an encrypted envelope",
1062
+ _isEncryptedEnvelope(res2._captured));
1047
1063
  }
1048
1064
 
1049
1065
  async function testApiEncryptPerSessionResponseCounterMonotonic() {
@@ -1286,8 +1302,15 @@ async function testApiEncryptPerSessionConcurrentCtrExecutesOnce() {
1286
1302
  check("concurrent-ctr: exactly one 200 and one 400",
1287
1303
  statuses[0] === 200 && statuses[1] === 400);
1288
1304
  var rejected = a.res._endedStatus === 400 ? a.res : c.res;
1289
- check("concurrent-ctr: the loser is refused with the replay shape",
1290
- /encrypted-payload-rejected/.test(rejected._captured));
1305
+ // The atomic-claim loser returns BEFORE the consumed response counter is
1306
+ // persisted, so its generic rejection stays PLAINTEXT: encrypting it would
1307
+ // emit a response _ctr the client tracks as consumed while the server never
1308
+ // records it, desyncing the session's monotonic counter. The body is generic
1309
+ // ("encrypted-payload-rejected") so plaintext leaks no session-lifecycle
1310
+ // reason. See _writeRejection({ plaintext: true }).
1311
+ check("concurrent-ctr: the loser is refused with the generic plaintext rejection body",
1312
+ /encrypted-payload-rejected/.test(rejected._captured) &&
1313
+ !_isEncryptedEnvelope(rejected._captured));
1291
1314
  }
1292
1315
 
1293
1316
  async function testApiEncryptPerSessionSequentialCounterStillWorks() {
@@ -1409,8 +1432,12 @@ async function testApiEncryptCtrClaimLifetimeAndSetFailure() {
1409
1432
  var fin3 = _newFinish(res3);
1410
1433
  await mw(req3, res3, function () { execCount += 1; res3.json({ ok: 3 }); });
1411
1434
  await fin3;
1412
- check("ctr-claim: replay of the captured body is refused",
1413
- res3._endedStatus === 400 && /encrypted-payload-rejected/.test(res3._captured));
1435
+ // The replay loses the atomic claim, which returns before persisting a
1436
+ // consumed counter so the refusal is generic PLAINTEXT (see
1437
+ // _writeRejection({ plaintext: true })), not an envelope.
1438
+ check("ctr-claim: replay of the captured body is refused (generic plaintext)",
1439
+ res3._endedStatus === 400 && /encrypted-payload-rejected/.test(res3._captured) &&
1440
+ !_isEncryptedEnvelope(res3._captured));
1414
1441
  check("ctr-claim: handler did not execute twice", execCount === 1);
1415
1442
  }
1416
1443
 
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ /**
3
+ * b.atomicFile.fdSafeReadSync — errorFor must own EVERY failure KIND (#358).
4
+ *
5
+ * Two regressions:
6
+ * (A) refuseSymlink lstats the path first; a MISSING file makes lstatSync
7
+ * throw raw ENOENT BEFORE the openSync branch that consults
8
+ * errorFor("enoent") — so a caller's enoent mapping silently never
9
+ * fires when refuseSymlink is on (the missing-file error class then
10
+ * differs by whether refuseSymlink is set).
11
+ * (B) The openSync ENOENT branch guards errorFor returning undefined
12
+ * (throw-if-truthy, else rethrow raw) — but the symlink / too-large /
13
+ * toctou / short-read / integrity branches threw errorFor(...) raw,
14
+ * so an errorFor that returns undefined for those throws `undefined`.
15
+ */
16
+
17
+ var fs = require("fs");
18
+ var path = require("path");
19
+
20
+ var helpers = require("../helpers");
21
+ var b = helpers.b;
22
+ var check = helpers.check;
23
+
24
+ function _dir() { return b.testing.tempDir("fdsaferead-errorfor"); }
25
+
26
+ function _throws(fn) {
27
+ try { fn(); return null; } catch (e) { return { e: e, threw: true }; }
28
+ }
29
+
30
+ // (A) refuseSymlink + missing file → the caller's errorFor("enoent") fires.
31
+ function testRefuseSymlinkMissingRoutesEnoent() {
32
+ var dir = _dir();
33
+ try {
34
+ var missing = path.join(dir.path, "nope.pem");
35
+ function Tagged(msg) { this.message = msg; this.tag = "custom-enoent"; }
36
+ var r = _throws(function () {
37
+ b.atomicFile.fdSafeReadSync(missing, {
38
+ refuseSymlink: true,
39
+ errorFor: function (kind) {
40
+ return kind === "enoent" ? new Tagged("not there") : undefined;
41
+ },
42
+ });
43
+ });
44
+ check("fdSafeReadSync refuseSymlink + missing: caller errorFor(enoent) fires (not raw ENOENT)",
45
+ r !== null && r.e && r.e.tag === "custom-enoent");
46
+ } finally { dir.cleanup(); }
47
+ }
48
+
49
+ // (A) refuseSymlink + missing + errorFor returning undefined → rethrows raw ENOENT
50
+ // (same posture the openSync branch already gives), never `undefined`.
51
+ function testRefuseSymlinkMissingErrorForUndefinedRethrowsRaw() {
52
+ var dir = _dir();
53
+ try {
54
+ var missing = path.join(dir.path, "nope2.pem");
55
+ var r = _throws(function () {
56
+ b.atomicFile.fdSafeReadSync(missing, {
57
+ refuseSymlink: true,
58
+ errorFor: function () { return undefined; },
59
+ });
60
+ });
61
+ check("fdSafeReadSync refuseSymlink + missing + undefined errorFor rethrows raw ENOENT",
62
+ r !== null && r.e && r.e.code === "ENOENT");
63
+ } finally { dir.cleanup(); }
64
+ }
65
+
66
+ // (B) errorFor returning undefined for "too-large" must throw a REAL error,
67
+ // never the literal `undefined`.
68
+ function testTooLargeErrorForUndefinedThrowsRealError() {
69
+ var dir = _dir();
70
+ try {
71
+ var p = path.join(dir.path, "big.bin");
72
+ fs.writeFileSync(p, Buffer.alloc(4096), { mode: 0o600 });
73
+ var r = _throws(function () {
74
+ b.atomicFile.fdSafeReadSync(p, {
75
+ maxBytes: 16,
76
+ errorFor: function () { return undefined; },
77
+ });
78
+ });
79
+ check("fdSafeReadSync too-large + undefined errorFor throws a real Error (not undefined)",
80
+ r !== null && r.e instanceof Error);
81
+ } finally { dir.cleanup(); }
82
+ }
83
+
84
+ // (B) errorFor returning undefined for "symlink" must throw a REAL error.
85
+ function testSymlinkErrorForUndefinedThrowsRealError() {
86
+ var dir = _dir();
87
+ try {
88
+ var victim = path.join(dir.path, "victim.pem");
89
+ fs.writeFileSync(victim, "SECRET", { mode: 0o600 });
90
+ var link = path.join(dir.path, "link.pem");
91
+ var symlinkOk = true;
92
+ try { fs.symlinkSync(victim, link); } catch (_e) { symlinkOk = false; }
93
+ if (symlinkOk) {
94
+ var r = _throws(function () {
95
+ b.atomicFile.fdSafeReadSync(link, {
96
+ refuseSymlink: true,
97
+ errorFor: function () { return undefined; },
98
+ });
99
+ });
100
+ check("fdSafeReadSync symlink + undefined errorFor throws a real Error (not undefined)",
101
+ r !== null && r.e instanceof Error);
102
+ } else {
103
+ check("fdSafeReadSync symlink case skipped (platform lacks symlink privilege)", true);
104
+ }
105
+ } finally { dir.cleanup(); }
106
+ }
107
+
108
+ // (B) errorFor returning undefined for "integrity" must throw a REAL error.
109
+ function testIntegrityErrorForUndefinedThrowsRealError() {
110
+ var dir = _dir();
111
+ try {
112
+ var p = path.join(dir.path, "hashed.bin");
113
+ fs.writeFileSync(p, Buffer.from("integrity"), { mode: 0o600 });
114
+ var r = _throws(function () {
115
+ b.atomicFile.fdSafeReadSync(p, {
116
+ expectedHash: "0".repeat(128),
117
+ errorFor: function () { return undefined; },
118
+ });
119
+ });
120
+ check("fdSafeReadSync integrity + undefined errorFor throws a real Error (not undefined)",
121
+ r !== null && r.e instanceof Error);
122
+ } finally { dir.cleanup(); }
123
+ }
124
+
125
+ async function run() {
126
+ testRefuseSymlinkMissingRoutesEnoent();
127
+ testRefuseSymlinkMissingErrorForUndefinedRethrowsRaw();
128
+ testTooLargeErrorForUndefinedThrowsRealError();
129
+ testSymlinkErrorForUndefinedThrowsRealError();
130
+ testIntegrityErrorForUndefinedThrowsRealError();
131
+ }
132
+
133
+ module.exports = { run: run };
134
+
135
+ if (require.main === module) {
136
+ run().then(function () { console.log("OK"); })
137
+ .catch(function (e) { console.error(e.stack || e); process.exit(1); });
138
+ }
@@ -332,6 +332,7 @@ var VALID_ALLOW_CLASSES = {
332
332
  "raw-randombytes-token": 1,
333
333
  "raw-time-literal": 1,
334
334
  "raw-timing-safe-equal": 1,
335
+ "raw-xfp": 1,
335
336
  "regex-no-length-cap": 1,
336
337
  "seal-without-aad": 1,
337
338
  "silent-catch": 1,
@@ -1802,6 +1803,30 @@ function testNoRawXffRead() {
1802
1803
  matches);
1803
1804
  }
1804
1805
 
1806
+ // ---- Pattern 20b: peer-gating bypass — raw X-Forwarded-Proto/-Host read ----
1807
+
1808
+ function testNoRawForwardedProtoHostRead() {
1809
+ // class: raw-xfp
1810
+ // The XFP sibling of Pattern 20. X-Forwarded-Proto / X-Forwarded-Host are
1811
+ // forgeable; reading them directly for a scheme/authority decision (Secure
1812
+ // cookie, HSTS, same-origin, the cryptographically-bound DPoP htu) bypasses
1813
+ // the peer-gating boundary — a direct caller can spoof the header. Route
1814
+ // through requestHelpers.trustedProtocol / trustedHost (or requestProtocol /
1815
+ // requestHost with a peer predicate) so the header is honored only when the
1816
+ // immediate peer is a declared trusted proxy. csrf-protect / security-headers
1817
+ // / cors / bot-guard / dpop all do; dpop was the consumer this rule was added
1818
+ // for (it read XFP/XFH via a bare trustForwardedHeaders boolean → htu
1819
+ // confusion). span-http-server reads both for the url.scheme/server.address
1820
+ // telemetry span attributes (display-only, not a trust sink) and carries an
1821
+ // allow:raw-xfp marker.
1822
+ var matches = _scan(/req\.headers\s*\[\s*["']x-forwarded-(?:proto|host)["']\s*\]/i);
1823
+ // request-helpers.js IS the canonical reader (the primitive home).
1824
+ matches = matches.filter(function (m) { return m.file !== "lib/request-helpers.js"; });
1825
+ matches = _filterMarkers(matches, "raw-xfp");
1826
+ _report("req.headers['x-forwarded-proto'|'x-forwarded-host'] routes through requestHelpers.trustedProtocol/trustedHost",
1827
+ matches);
1828
+ }
1829
+
1805
1830
  // ---- Pattern 21: req.socket.remoteAddress raw read for actor IP ----
1806
1831
 
1807
1832
  function testNoRawRemoteAddress() {
@@ -13064,6 +13089,7 @@ async function run() {
13064
13089
  testNoSilentCatchSwallow();
13065
13090
  testNoDynamicRegexFromOperatorInput();
13066
13091
  testNoRawXffRead();
13092
+ testNoRawForwardedProtoHostRead();
13067
13093
  testNoRawRemoteAddress();
13068
13094
  testNoRawProcessEnv();
13069
13095
  testNoRawTimingSafeEqual();