@blamejs/blamejs-shop 0.4.74 → 0.4.75

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 (48) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/vendor/MANIFEST.json +48 -44
  4. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  5. package/lib/vendor/blamejs/api-snapshot.json +23 -2
  6. package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +5 -1
  7. package/lib/vendor/blamejs/examples/wiki/lib/harvest-vendored-deps.js +6 -1
  8. package/lib/vendor/blamejs/examples/wiki/test/codebase-patterns.test.js +8 -4
  9. package/lib/vendor/blamejs/index.js +2 -0
  10. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -8
  11. package/lib/vendor/blamejs/lib/auth/dpop.js +9 -0
  12. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +25 -12
  13. package/lib/vendor/blamejs/lib/auth/jwt.js +19 -3
  14. package/lib/vendor/blamejs/lib/auth/oauth.js +8 -2
  15. package/lib/vendor/blamejs/lib/auth/saml.js +19 -9
  16. package/lib/vendor/blamejs/lib/crypto-field.js +19 -1
  17. package/lib/vendor/blamejs/lib/csp.js +9 -0
  18. package/lib/vendor/blamejs/lib/db-query.js +33 -2
  19. package/lib/vendor/blamejs/lib/mail-auth.js +24 -1
  20. package/lib/vendor/blamejs/lib/mail-dkim.js +20 -7
  21. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +39 -5
  22. package/lib/vendor/blamejs/lib/pipl-cn.js +11 -8
  23. package/lib/vendor/blamejs/lib/request-helpers.js +146 -13
  24. package/lib/vendor/blamejs/lib/safe-json.js +26 -0
  25. package/lib/vendor/blamejs/lib/session.js +35 -117
  26. package/lib/vendor/blamejs/lib/sql.js +22 -0
  27. package/lib/vendor/blamejs/lib/ws-client.js +26 -0
  28. package/lib/vendor/blamejs/lib/x509-chain.js +71 -24
  29. package/lib/vendor/blamejs/package.json +1 -1
  30. package/lib/vendor/blamejs/release-notes/v0.15.15.json +73 -0
  31. package/lib/vendor/blamejs/test/00-primitives.js +54 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +22 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +130 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +37 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +21 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +22 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +40 -1
  38. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +29 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +46 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-compose-pipeline.test.js +74 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +12 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +46 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +77 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +74 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +19 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +32 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/x509-chain-ca-enforcement.test.js +11 -0
  48. package/package.json +1 -1
@@ -253,6 +253,77 @@ async function testFinalNextFallbackOnUnhandledError() {
253
253
  capturedErr === sentinel);
254
254
  }
255
255
 
256
+ async function testHaltingMiddlewareSettlesPromise() {
257
+ // A regular (3-arg) middleware that writes the response and returns
258
+ // WITHOUT calling next() halts the chain — the documented "this
259
+ // middleware handled the request" pattern (auth/rate-limit/bot block).
260
+ // The composed promise MUST still settle; a permanently-pending
261
+ // promise retains its req/res closure forever (memory leak under
262
+ // sustained blocked traffic). finalNext must NOT fire — the chain was
263
+ // halted, so the caller's next-flag stays false and the router does
264
+ // not proceed to the route handler.
265
+ var finalCalled = false;
266
+ var pipe = b.middleware.composePipeline([
267
+ { name: "pass", mw: _passMw("a") },
268
+ { name: "halt", mw: function (req, res, next) { res.writableEnded = true; /* ended response, no next() */ } },
269
+ { name: "after", mw: _passMw("z") },
270
+ ]);
271
+ var req = {}; var res = {};
272
+ var settled = false;
273
+ pipe(req, res, function () { finalCalled = true; }).then(function () { settled = true; });
274
+ try {
275
+ await helpers.waitUntil(function () { return settled; },
276
+ { timeoutMs: 2000, label: "compose-pipeline: halting middleware settles the composed promise" });
277
+ } catch (_e) { /* stays false on the buggy tree → check fails RED */ }
278
+ check("halting middleware settles the composed promise", settled === true);
279
+ check("downstream middleware not run after a halt",
280
+ !req._tags || req._tags.indexOf("z") === -1);
281
+ check("finalNext NOT called when a middleware halts the chain", finalCalled === false);
282
+ }
283
+
284
+ async function testHandledErrorSettlesWithoutFinalNext() {
285
+ // An error handler that consumes the error WITHOUT calling next has
286
+ // handled the request (same halt contract as a 3-arg middleware): the
287
+ // promise settles but finalNext must not fire, so the caller does not
288
+ // proceed to the route handler on top of an already-sent error page.
289
+ var finalCalled = false;
290
+ var sentinel = new Error("boom");
291
+ var pipe = b.middleware.composePipeline([
292
+ { name: "failing", mw: _bailMw(sentinel), position: 10 },
293
+ { name: "errorHandler", mw: function (err, req, res, _next) { res._handled = err; res.writableEnded = true; }, position: 20 },
294
+ ]);
295
+ var req = {}; var res = {};
296
+ var settled = false;
297
+ pipe(req, res, function () { finalCalled = true; }).then(function () { settled = true; });
298
+ try {
299
+ await helpers.waitUntil(function () { return settled; },
300
+ { timeoutMs: 2000, label: "compose-pipeline: handled error settles the composed promise" });
301
+ } catch (_e) { /* RED if it hangs */ }
302
+ check("handled error settles the composed promise", settled === true);
303
+ check("error handler consumed the error", res._handled === sentinel);
304
+ check("finalNext NOT called when an error handler halts the chain", finalCalled === false);
305
+ }
306
+
307
+ async function testDeferredNextContinuesChain() {
308
+ // A callback-style middleware that calls next() LATER (from a timer, stream,
309
+ // or legacy callback) returns before next() runs. The pipeline must NOT treat
310
+ // that bare return as a halt: the chain continues when the deferred next()
311
+ // fires, downstream middleware run, and finalNext is called.
312
+ var finalCalled = false;
313
+ var pipe = b.middleware.composePipeline([
314
+ { name: "a", mw: _passMw("a") },
315
+ { name: "deferred", mw: function (req, res, next) {
316
+ setTimeout(function () { req._tags.push("deferred"); next(); }, 5);
317
+ } },
318
+ { name: "c", mw: _passMw("c") },
319
+ ]);
320
+ var req = {}; var res = {};
321
+ await pipe(req, res, function (err) { finalCalled = !err; });
322
+ check("deferred next() continues the chain (callback-style middleware)",
323
+ req._tags && req._tags.join("") === "adeferredc");
324
+ check("finalNext called after a deferred next()", finalCalled === true);
325
+ }
326
+
256
327
  async function run() {
257
328
  testSurface();
258
329
  testSequentialDispatch();
@@ -269,6 +340,9 @@ async function run() {
269
340
  await testAsyncMiddlewareAwaited();
270
341
  await testErrorMiddlewareReceivesError();
271
342
  await testFinalNextFallbackOnUnhandledError();
343
+ await testHaltingMiddlewareSettlesPromise();
344
+ await testHandledErrorSettlesWithoutFinalNext();
345
+ await testDeferredNextContinuesChain();
272
346
  }
273
347
 
274
348
  module.exports = { run: run };
@@ -95,6 +95,18 @@ function run() {
95
95
  midBand.mechanismRequired === "standard-contract" &&
96
96
  midBand.securityAssessmentRequired === false);
97
97
 
98
+ // Same band reached via the cumulativePI field: 100,001 prior + 50,000 this
99
+ // transfer = 150,001 effective (in the 100k–1M band) is still SCC — the
100
+ // documented boundary is 1,000,000, not 100,000.
101
+ var cumMid = b.pipl.sccFilingAssessment({
102
+ assessmentId: "xfer-3d", transferType: "processor", recipientJurisdiction: "US",
103
+ dataCategories: ["contact"], legalBasis: "standard-contract",
104
+ volume: 50000, sensitivePI: false, cumulativePI: 100001, recordedAt: recordedAt,
105
+ });
106
+ check("scc: 150,001 cumulative non-sensitive stays standard-contract (boundary is 1M)",
107
+ cumMid.mechanismRequired === "standard-contract" &&
108
+ cumMid.securityAssessmentRequired === false);
109
+
98
110
  // ---- THIS transfer's volume counts toward the cumulative sensitive
99
111
  // threshold: a first transfer of 10,001 sensitive subjects forces it
100
112
  // even with cumulativeSensitivePI omitted (defaults 0) ----
@@ -133,6 +133,27 @@ function testParseListHeader() {
133
133
  JSON.stringify(rh.parseListHeader("\ta\t,\tb\n")) === '["a","b"]');
134
134
  }
135
135
 
136
+ function testParseQualityListQuoteAware() {
137
+ var rh = b.requestHelpers;
138
+ // The q-value must come from the parameter literally named `q`, parsed
139
+ // quote-aware — never a `q=`-shaped substring inside a quoted parameter value,
140
+ // and a quoted value's ',' / ';' must not split the list.
141
+ var quoted = rh.parseQualityList('text/html;title="x;q=0.1";q=0.9');
142
+ check("parseQualityList: q= inside a quoted value is not the q-value",
143
+ quoted.length === 1 && quoted[0].value === "text/html" && quoted[0].q === 0.9);
144
+ var commaInQuote = rh.parseQualityList('a/b;p="x,y";q=0.3');
145
+ check("parseQualityList: comma inside a quoted value does not split the list",
146
+ commaInQuote.length === 1 && commaInQuote[0].value === "a/b" && commaInQuote[0].q === 0.3);
147
+ var leveled = rh.parseQualityList("text/html;level=1;q=0.5");
148
+ check("parseQualityList: a media-type param before q is ignored",
149
+ leveled.length === 1 && leveled[0].q === 0.5);
150
+ var ranked = rh.parseQualityList("br;q=1.0, gzip;q=0.5, *;q=0");
151
+ check("parseQualityList: ranks by descending q",
152
+ ranked[0].value === "br" && ranked[1].value === "gzip" && ranked[2].q === 0);
153
+ check("parseQualityList: missing q defaults to 1",
154
+ rh.parseQualityList("en")[0].q === 1);
155
+ }
156
+
136
157
  function testSafeHeadersDistinct() {
137
158
  check("safeHeadersDistinct is fn", typeof b.requestHelpers.safeHeadersDistinct === "function");
138
159
 
@@ -416,9 +437,33 @@ function testTrustedClientIpValidates() {
416
437
  check("trustedClientIp rejects malformed CIDR", threwCidr === true);
417
438
  }
418
439
 
440
+ function testIpPrefixMasking() {
441
+ var ip = b.requestHelpers.ipPrefix;
442
+ check("ipPrefix is a function", typeof ip === "function");
443
+ // IPv4 → /24 (network address, low octet zeroed).
444
+ check("ipPrefix v4 masks to /24", ip("203.0.113.47") === "203.0.113.0/24");
445
+ check("ipPrefix v4 same /24 → same bucket", ip("203.0.113.47") === ip("203.0.113.250"));
446
+ check("ipPrefix v4 cross-/24 → different bucket", ip("203.0.113.1") !== ip("198.51.100.1"));
447
+ // IPv6 → /64 (low 64 bits zeroed), deterministic uncompressed emit.
448
+ check("ipPrefix v6 masks to /64", ip("2001:db8:1234:5678::1") === "2001:db8:1234:5678:0:0:0:0/64");
449
+ check("ipPrefix v6 same /64 → same bucket",
450
+ ip("2001:db8:1234:5678::1") === ip("2001:db8:1234:5678:abcd:ef01:2345:6789"));
451
+ check("ipPrefix v6 cross-/64 → different bucket",
452
+ ip("2001:db8:1234:5678::1") !== ip("2001:db8:1234:9999::1"));
453
+ // IPv4-mapped IPv6 folds to the v4 /24 bucket.
454
+ check("ipPrefix folds ::ffff: mapped v4 to the v4 bucket",
455
+ ip("::ffff:203.0.113.5") === ip("203.0.113.99"));
456
+ // Garbage / non-string → "" (never throws).
457
+ check("ipPrefix returns '' for a non-string", ip(null) === "" && ip(12345) === "");
458
+ check("ipPrefix returns '' for an empty string", ip("") === "");
459
+ check("ipPrefix returns '' for an unparseable address", ip("not-an-ip") === "");
460
+ check("ipPrefix rejects an out-of-range v4 octet", ip("999.0.0.1") === "");
461
+ }
462
+
419
463
  async function run() {
420
464
  testSurface();
421
465
  testSafeHeadersDistinct();
466
+ testIpPrefixMasking();
422
467
  testClientIpDefaultIgnoresXff();
423
468
  testClientIpPeerGatedTrustedPeer();
424
469
  testClientIpPeerGatedUntrustedPeerIgnoresXff();
@@ -439,6 +484,7 @@ async function run() {
439
484
  await testCaptureStatusOnEndThrowDoesntBreakResponse();
440
485
  testCaptureStatusValidatesArgs();
441
486
  testParseListHeader();
487
+ testParseQualityListQuoteAware();
442
488
  testExtractBearerSurface();
443
489
  testExtractBearerHappyPath();
444
490
  testExtractBearerCaseInsensitiveScheme();
@@ -164,6 +164,21 @@ function _hokScd(notOnOrAfterAttr, holderCertPem) {
164
164
  "</saml:SubjectConfirmationData>";
165
165
  }
166
166
 
167
+ // Same shape, Recipient attribute OMITTED — the Web SSO profile (§4.1.4.2)
168
+ // makes Recipient mandatory for a Bearer/HoK confirmation delivered to an ACS.
169
+ function _bearerScdNoRecipient(notOnOrAfterAttr, inResponseTo) {
170
+ return "<saml:SubjectConfirmationData" + notOnOrAfterAttr +
171
+ " InResponseTo=\"" + inResponseTo + "\"/>";
172
+ }
173
+
174
+ function _hokScdNoRecipient(notOnOrAfterAttr, holderCertPem) {
175
+ return "<saml:SubjectConfirmationData" + notOnOrAfterAttr + ">" +
176
+ "<ds:KeyInfo xmlns:ds=\"" + DS + "\"><ds:X509Data><ds:X509Certificate>" +
177
+ _certBodyB64(holderCertPem) +
178
+ "</ds:X509Certificate></ds:X509Data></ds:KeyInfo>" +
179
+ "</saml:SubjectConfirmationData>";
180
+ }
181
+
167
182
  function _newSp(idp) {
168
183
  return b.auth.saml.sp.create({
169
184
  entityId: SP_ENTITY_ID,
@@ -274,6 +289,41 @@ async function testHolderOfKeyNotOnOrAfterRefused() {
274
289
  _verifyThrows(sp, b64bad, vopts) === "auth-saml/no-valid-confirmation");
275
290
  }
276
291
 
292
+ // SAML 2.0 Profiles §4.1.4.2 — a Bearer SubjectConfirmationData delivered to
293
+ // an ACS MUST carry a Recipient equal to the SP's ACS URL. An assertion whose
294
+ // Bearer confirmation omits Recipient must be refused; accepting it lets an
295
+ // assertion relayed to an unintended endpoint pass the recipient-binding axis.
296
+ async function testBearerMissingRecipientRefused() {
297
+ var idp = await _mintRsaCert("idp.example");
298
+ var sp = _newSp(idp);
299
+ var inResponseTo = "_req-no-recip";
300
+ var b64 = _buildSignedResponse(idp, {
301
+ tag: "bearer-no-recip",
302
+ method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
303
+ nameId: "alice@example.com",
304
+ scd: _bearerScdNoRecipient(" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\"", inResponseTo), // allow:raw-time-literal — 5m future
305
+ });
306
+ check("Bearer with no Recipient is refused (§4.1.4.2 mandatory)",
307
+ _verifyThrows(sp, b64, { expectedInResponseTo: inResponseTo }) === "auth-saml/no-valid-confirmation");
308
+ }
309
+
310
+ // The Holder-of-Key sibling incorporates the same Web SSO Recipient requirement
311
+ // (Profile §3.1 by reference). An HoK confirmation delivered to an ACS with no
312
+ // Recipient must be refused too.
313
+ async function testHolderOfKeyMissingRecipientRefused() {
314
+ var idp = await _mintRsaCert("idp.example");
315
+ var holder = await _mintRsaCert("holder.example");
316
+ var sp = _newSp(idp);
317
+ var b64 = _buildSignedResponse(idp, {
318
+ tag: "hok-no-recip",
319
+ method: "urn:oasis:names:tc:SAML:2.0:cm:holder-of-key",
320
+ nameId: "bob@example.com",
321
+ scd: _hokScdNoRecipient(" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\"", holder.certPem), // allow:raw-time-literal — 5m future
322
+ });
323
+ check("HoK with no Recipient is refused (§3.1 incorporates §4.1.4.2)",
324
+ _verifyThrows(sp, b64, { holderOfKey: { presentedCertPem: holder.certPem } }) === "auth-saml/no-valid-confirmation");
325
+ }
326
+
277
327
  // #B0 — a signed assertion with NO AudienceRestriction is not bound to THIS
278
328
  // SP. Accepting it is audience-confusion: an IdP-signed assertion minted for
279
329
  // another SP is replayed here. Must fail closed (default on).
@@ -314,12 +364,39 @@ async function testUnparseableConditionsTimestampRefused() {
314
364
  _verifyThrows(sp, b64, { expectedInResponseTo: inResponseTo }) === "auth-saml/conditions-bad-timestamp");
315
365
  }
316
366
 
367
+ // SAML core §2.5.1.4 — multiple <AudienceRestriction> elements are AND-combined:
368
+ // the SP must be a member of EVERY one. An assertion whose first restriction
369
+ // lists this SP but whose second narrows to a DIFFERENT audience must be refused
370
+ // (checking only the first let it through — audience-confusion).
371
+ async function testSecondAudienceRestrictionEnforced() {
372
+ var idp = await _mintRsaCert("idp.example");
373
+ var sp = _newSp(idp);
374
+ var inResponseTo = "_req-2aud";
375
+ var b64 = _buildSignedResponse(idp, {
376
+ tag: "two-aud",
377
+ method: "urn:oasis:names:tc:SAML:2.0:cm:bearer",
378
+ nameId: "u@example.com",
379
+ scd: _bearerScd(" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\"", inResponseTo), // allow:raw-time-literal — 5m future
380
+ conditions: "<saml:Conditions NotBefore=\"" + _isoFromNow(-5 * 60 * 1000) + // allow:raw-time-literal — 5m skew
381
+ "\" NotOnOrAfter=\"" + _isoFromNow(5 * 60 * 1000) + "\">" + // allow:raw-time-literal — 5m future
382
+ "<saml:AudienceRestriction><saml:Audience>" + SP_ENTITY_ID +
383
+ "</saml:Audience></saml:AudienceRestriction>" +
384
+ "<saml:AudienceRestriction><saml:Audience>https://other-sp.example/different" +
385
+ "</saml:Audience></saml:AudienceRestriction></saml:Conditions>",
386
+ });
387
+ check("second AudienceRestriction (different audience) refused (AND-combined)",
388
+ _verifyThrows(sp, b64, { expectedInResponseTo: inResponseTo }) === "auth-saml/wrong-audience");
389
+ }
390
+
317
391
  async function run() {
318
392
  await testBearerValidNotOnOrAfterAccepted();
319
393
  await testBearerMissingNotOnOrAfterRefused();
320
394
  await testBearerUnparseableNotOnOrAfterRefused();
321
395
  await testHolderOfKeyNotOnOrAfterRefused();
396
+ await testBearerMissingRecipientRefused();
397
+ await testHolderOfKeyMissingRecipientRefused();
322
398
  await testMissingAudienceRestrictionRefused();
399
+ await testSecondAudienceRestrictionEnforced();
323
400
  await testUnparseableConditionsTimestampRefused();
324
401
  }
325
402
 
@@ -152,6 +152,79 @@ async function testClientIpPrefixV4MappedV6() {
152
152
  }
153
153
  }
154
154
 
155
+ // A request whose immediate peer is a reverse proxy: the socket peer is the
156
+ // proxy, the real client arrives in X-Forwarded-For.
157
+ function _makeProxiedReq(proxyAddr, clientIp) {
158
+ return {
159
+ headers: { "x-forwarded-for": clientIp, "user-agent": "ua1" },
160
+ socket: { remoteAddress: proxyAddr },
161
+ };
162
+ }
163
+
164
+ async function testFingerprintPeerGatedClientIp() {
165
+ // Behind a trusted proxy the client IP arrives in X-Forwarded-For while the
166
+ // socket peer is the proxy. The bare-socket default binds the fingerprint to
167
+ // the PROXY, so two different real clients behind the same proxy share a
168
+ // fingerprint — the IP component is silently defeated. The { trustedProxies }
169
+ // option peer-gates the resolve (consistent with trustedClientIp) so the real
170
+ // client is bound. Both halves are proven here: the default still binds the
171
+ // proxy (a different real client does NOT drift), and the opt makes a
172
+ // different real client DRIFT.
173
+ var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ses-peergate-"));
174
+ try {
175
+ await setupTestDb(tmpDir);
176
+ var PROXY = "10.0.0.7";
177
+ var TP = ["10.0.0.0/8"];
178
+
179
+ // --- Legacy default (no trustedProxies): binds to the proxy address, so a
180
+ // different real client behind the same proxy does NOT drift.
181
+ var sLegacy = await b.session.create({
182
+ userId: "u-legacy", req: _makeProxiedReq(PROXY, "203.0.113.10"),
183
+ fingerprintFields: ["clientIp"],
184
+ });
185
+ var legacy = await b.session.verify(sLegacy.token, {
186
+ req: _makeProxiedReq(PROXY, "198.51.100.9"), fingerprintFields: ["clientIp"],
187
+ });
188
+ check("bare-socket default: different real client behind proxy does NOT drift (proxy-bound)",
189
+ legacy && legacy.fingerprintDrift === false);
190
+
191
+ // --- Peer-gated (trustedProxies): resolves the real client from XFF.
192
+ var sGated = await b.session.create({
193
+ userId: "u-gated", req: _makeProxiedReq(PROXY, "203.0.113.10"),
194
+ fingerprintFields: ["clientIp"], trustedProxies: TP,
195
+ });
196
+ var sameClient = await b.session.verify(sGated.token, {
197
+ req: _makeProxiedReq(PROXY, "203.0.113.10"),
198
+ fingerprintFields: ["clientIp"], trustedProxies: TP,
199
+ });
200
+ check("peer-gated: same real client behind proxy does not drift",
201
+ sameClient && sameClient.fingerprintDrift === false);
202
+ var diffClient = await b.session.verify(sGated.token, {
203
+ req: _makeProxiedReq(PROXY, "198.51.100.9"),
204
+ fingerprintFields: ["clientIp"], trustedProxies: TP,
205
+ });
206
+ check("peer-gated: different real client behind proxy DRIFTS (real client bound)",
207
+ diffClient && diffClient.fingerprintDrift === true);
208
+
209
+ // A forged XFF from a NON-trusted peer must be ignored (peer-gating) — the
210
+ // resolve falls back to the untrusted socket address, so a forged header
211
+ // can't make the real-client binding drift on its own.
212
+ var sDirect = await b.session.create({
213
+ userId: "u-direct", req: _makeProxiedReq("203.0.113.50", "203.0.113.10"),
214
+ fingerprintFields: ["clientIp"], trustedProxies: TP,
215
+ });
216
+ var forged = await b.session.verify(sDirect.token, {
217
+ // same untrusted socket peer, attacker varies the forgeable XFF.
218
+ req: _makeProxiedReq("203.0.113.50", "8.8.8.8"),
219
+ fingerprintFields: ["clientIp"], trustedProxies: TP,
220
+ });
221
+ check("peer-gated: forged XFF from an untrusted peer is ignored (no drift on header alone)",
222
+ forged && forged.fingerprintDrift === false);
223
+ } finally {
224
+ await teardownTestDb(tmpDir);
225
+ }
226
+ }
227
+
155
228
  async function testPluggableStore() {
156
229
  var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ses-store-"));
157
230
  try {
@@ -403,6 +476,7 @@ async function run() {
403
476
  await testClientIpPrefixV4();
404
477
  await testClientIpPrefixV6();
405
478
  await testClientIpPrefixV4MappedV6();
479
+ await testFingerprintPeerGatedClientIp();
406
480
  await testPluggableStore();
407
481
  await testDestroyAllForUserPluggableNoDb();
408
482
  await testPluggableStoreValidation();
@@ -128,6 +128,25 @@ async function run() {
128
128
  check("LIKE escapes wildcards with ~", likeq.sql.indexOf("ESCAPE '~'") !== -1 &&
129
129
  likeq.params[0].indexOf("~%") !== -1 && likeq.params[0].indexOf("~_") !== -1);
130
130
 
131
+ // ---- NULL-equality footgun: `col = NULL` is UNKNOWN in SQL ----
132
+ rejects("where(col, '=', null) refused (use whereNull)", function () {
133
+ return sql.select("t").where("c", "=", null).toSql();
134
+ }, "sql-builder/null-equality");
135
+ rejects("where({ col: null }) refused (object form is = null)", function () {
136
+ return sql.select("t").where({ c: null }).toSql();
137
+ }, "sql-builder/null-equality");
138
+ var isNullq = sql.select("t").whereNull("c").toSql();
139
+ check("whereNull emits IS NULL with no bound param",
140
+ isNullq.sql.indexOf('"c" IS NULL') !== -1 && isNullq.params.length === 0);
141
+
142
+ // ---- whereInArray: per-element validation parity across dialects ----
143
+ rejects("whereInArray undefined element refused (PG = ANY would bind it silently)", function () {
144
+ return sql.select("t", { dialect: "postgres" }).whereInArray("id", [1, undefined, 3]).toSql();
145
+ }, "sql-builder/bad-in-value");
146
+ var anyq = sql.select("t", { dialect: "postgres" }).whereInArray("id", [1, 2]).toSql();
147
+ check("whereInArray PG path binds the array as one = ANY(?) param",
148
+ anyq.sql.indexOf("= ANY(?)") !== -1 && anyq.params.length === 1);
149
+
131
150
  // ---- JSONB guard + jsonb_exists emission (inherited from db-query) ----
132
151
  var jc = sql.select("docs", { dialect: "postgres" }).where("meta", "@>", { a: 1 }).toSql();
133
152
  check("@> binds canonical JSON", jc.sql.indexOf('"meta" @> ?') !== -1 && jc.params[0] === '{"a":1}');
@@ -54,6 +54,19 @@ function _makeServer(opts) {
54
54
  socket.write(headerLines.join("\r\n"));
55
55
 
56
56
  var ws = b.websocket;
57
+ // Memory-exhaustion mode: stream a text frame + continuation frames with
58
+ // fin:false whose running total exceeds the client's maxMessageBytes, and
59
+ // never send FIN. A client that only checks the cap at FIN would buffer
60
+ // them without bound.
61
+ if (opts.floodFragments) {
62
+ var part = Buffer.alloc(opts.floodFragments.partBytes || 600, 0x61);
63
+ socket.write(ws.serializeFrame(0x01, part, { fin: false })); // text start
64
+ var fcount = opts.floodFragments.count || 4;
65
+ for (var fk = 0; fk < fcount; fk += 1) {
66
+ socket.write(ws.serializeFrame(0x00, part, { fin: false })); // continuation, never FIN
67
+ }
68
+ return;
69
+ }
57
70
  var fp = new ws.FrameParser({ maxFrameBytes: 1024 * 1024 });
58
71
  socket.on("data", function (chunk) {
59
72
  var frames = fp.push(chunk) || [];
@@ -248,6 +261,25 @@ async function run() {
248
261
  await _sleep(50);
249
262
  server7.close();
250
263
 
264
+ // ---- maxMessageBytes guard on RECEIVE across non-FIN fragments ----
265
+ // A peer that streams continuation frames and never sends FIN must not be
266
+ // able to grow the reassembly buffer past maxMessageBytes — the cap is
267
+ // enforced on the running fragment total, not only at FIN (CWE-770).
268
+ var serverFlood = await _makeServer({ floodFragments: { partBytes: 600, count: 4 } });
269
+ var portFlood = serverFlood.address().port;
270
+ var cFlood = b.wsClient.connect("ws://127.0.0.1:" + portFlood, {
271
+ maxMessageBytes: 1024, // 600 + 600 = 1200 > 1024 before any FIN
272
+ reconnect: false, audit: false, allowInternal: true,
273
+ });
274
+ var floodErr = null;
275
+ cFlood.on("error", function (e) { floodErr = e; });
276
+ await _sleep(400);
277
+ check("receive: running fragment total over maxMessageBytes errors before FIN",
278
+ floodErr !== null && /maxMessageBytes/.test(floodErr.message || ""));
279
+ try { cFlood.close(); } catch (_e) { /* already torn down */ }
280
+ await _sleep(50);
281
+ serverFlood.close();
282
+
251
283
  // ---- url + readyState getters ----
252
284
  var server8 = await _makeServer({});
253
285
  var port8 = server8.address().port;
@@ -97,6 +97,17 @@ function _stubHttpClient(body) {
97
97
  }
98
98
 
99
99
  async function run() {
100
+ // ---- 0. Public surface: the CA-bit issuer test is reachable on `b` so a
101
+ // consumer validating a chain outside a TLS handshake uses the hardened,
102
+ // fail-closed test instead of raw X509Certificate.checkIssued().
103
+ check("b.x509Chain is exposed on the public surface", b.x509Chain && typeof b.x509Chain === "object");
104
+ check("b.x509Chain.isCaCert is the internal helper", b.x509Chain.isCaCert === x509Chain.isCaCert);
105
+ check("b.x509Chain.issuerValidlyIssued is the internal helper",
106
+ b.x509Chain.issuerValidlyIssued === x509Chain.issuerValidlyIssued);
107
+ check("b.x509Chain.isCaCert fails closed on a missing cert", b.x509Chain.isCaCert(null) === false);
108
+ check("b.x509Chain.issuerValidlyIssued fails closed on garbage input",
109
+ b.x509Chain.issuerValidlyIssued(null, null) === false);
110
+
100
111
  // ---- 1. Shared primitive: the cA enforcement every walker routes through.
101
112
  var c = await _mintChain({ interCa: false }); // intermediate is cA:FALSE
102
113
  check("isCaCert: root (cA:TRUE) is a CA", x509Chain.isCaCert(c.root) === true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.74",
3
+ "version": "0.4.75",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {