@blamejs/blamejs-shop 0.4.87 → 0.4.88
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 +2 -0
- package/SECURITY.md +8 -0
- package/lib/asset-manifest.json +1 -1
- 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
|
@@ -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
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
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
|
|
package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-fd-read-errorfor-bypass.test.js
ADDED
|
@@ -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();
|