@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.
- package/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +48 -44
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +23 -2
- package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +5 -1
- package/lib/vendor/blamejs/examples/wiki/lib/harvest-vendored-deps.js +6 -1
- package/lib/vendor/blamejs/examples/wiki/test/codebase-patterns.test.js +8 -4
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/auth/ciba.js +32 -8
- package/lib/vendor/blamejs/lib/auth/dpop.js +9 -0
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +25 -12
- package/lib/vendor/blamejs/lib/auth/jwt.js +19 -3
- package/lib/vendor/blamejs/lib/auth/oauth.js +8 -2
- package/lib/vendor/blamejs/lib/auth/saml.js +19 -9
- package/lib/vendor/blamejs/lib/crypto-field.js +19 -1
- package/lib/vendor/blamejs/lib/csp.js +9 -0
- package/lib/vendor/blamejs/lib/db-query.js +33 -2
- package/lib/vendor/blamejs/lib/mail-auth.js +24 -1
- package/lib/vendor/blamejs/lib/mail-dkim.js +20 -7
- package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +39 -5
- package/lib/vendor/blamejs/lib/pipl-cn.js +11 -8
- package/lib/vendor/blamejs/lib/request-helpers.js +146 -13
- package/lib/vendor/blamejs/lib/safe-json.js +26 -0
- package/lib/vendor/blamejs/lib/session.js +35 -117
- package/lib/vendor/blamejs/lib/sql.js +22 -0
- package/lib/vendor/blamejs/lib/ws-client.js +26 -0
- package/lib/vendor/blamejs/lib/x509-chain.js +71 -24
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.15.json +73 -0
- package/lib/vendor/blamejs/test/00-primitives.js +54 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +130 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +21 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +40 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-compose-pipeline.test.js +74 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +12 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +77 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +74 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/x509-chain-ca-enforcement.test.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.15.15",
|
|
4
|
+
"date": "2026-06-21",
|
|
5
|
+
"headline": "A focused correctness and hardening release: more authentication and signature verifiers fail closed (JWT issuer, SAML recipient binding, OAuth nonce, CIBA token binding, FIDO certification level), the WebSocket client bounds a fragmented message before it completes, DMARC resolves the From domain before the policy lookup, and the CSP builder refuses a directive-injecting source; fixes a middleware pipeline promise that never settled on a write-and-halt, and exposes the X.509 CA-bit issuer test, an IP-prefix helper, and a reverse-proxy-aware session fingerprint option",
|
|
6
|
+
"summary": "This release continues to tighten existing protections without adding opt-ins on the request path. A further set of authentication and signature verifiers now refuse malformed or unbound credentials: JWT verify rejects an array-valued issuer claim (an any-match bypass, CVE-2025-30144 class), SAML requires the mandatory Recipient on a Bearer or holder-of-key confirmation and enforces every AudienceRestriction, OAuth no longer skips the ID-token nonce check on an empty nonce, CIBA binds the returned ID token to its auth_req_id, the FIDO MDS3 certification level reflects the authenticator's current status rather than its historical maximum, and a JOSE header that decodes to a non-object is a typed error rather than an uncaught throw. The WebSocket client now bounds a fragmented incoming message by its running total instead of only at the final frame, DMARC validates the From-header domain before the policy lookup, the data-residency write gate compares the residency column case-insensitively, and the SQL builder refuses an equality against NULL and validates every IN-list element. The CSP builder refuses a source token containing whitespace or a semicolon (directive injection). A middleware pipeline that a request handler halted by writing the response without calling next() left its promise pending forever, retaining the request closure — it now settles. New surface is additive and backward compatible: the basicConstraints-enforcing X.509 issuer test is exposed publicly, the session device fingerprint can resolve the client IP through a trusted-proxy allowlist, and the /24+/64 IP-prefix masking it uses is exposed for reuse.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "b.x509Chain — the CA-bit-enforcing X.509 issuer test",
|
|
13
|
+
"body": "isCaCert(cert) and issuerValidlyIssued(issuer, subject) are now public. Node's X509Certificate.checkIssued() does not enforce the basicConstraints cA bit (CVE-2002-0862 class), so a non-CA leaf can be accepted as an issuer; these helpers require cA:TRUE and verify the signature, fail closed on any malformed input, and are the same test the framework's own chain walkers use. A consumer validating a chain outside a TLS handshake (an operator-uploaded CA bundle, a non-handshake PQ-signed certificate) can now use the hardened path instead of raw checkIssued()."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"title": "Reverse-proxy-aware session device fingerprint",
|
|
17
|
+
"body": "b.session.create / verify / rotate accept { trustedProxies } (CIDRs) or { clientIpResolver } so the built-in clientIp / clientIpPrefix fingerprint fields resolve the real client IP behind a trusted proxy, consistent with b.requestHelpers.trustedClientIp. Without the option the fingerprint still binds to the bare socket peer (unchanged default), so existing fingerprints keep matching; pass the same option to create, verify, and rotate. The /24 (IPv4) + /64 (IPv6) subnet masking is exposed as b.requestHelpers.ipPrefix for an operator using a function-form fingerprint field."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"heading": "Security",
|
|
23
|
+
"items": [
|
|
24
|
+
{
|
|
25
|
+
"title": "More authentication and signature verifiers fail closed",
|
|
26
|
+
"body": "JWT verify rejects an array-valued iss claim (it had passed through a generic any-match built for the legitimately-array aud claim, so a multi-issuer array satisfied a single-issuer expectation — CVE-2025-30144 class), matching the external JWT and OIDC verifiers. SAML verifyResponse requires the Recipient attribute on a Bearer or holder-of-key SubjectConfirmation and confirms it equals the ACS URL (an absent Recipient was silently skipped), and enforces every AudienceRestriction (AND-combined per SAML core). OAuth no longer fails open and skips the ID-token nonce replay check when the supplied nonce is empty. CIBA binds the polled / notified ID token to its auth_req_id so a token minted for another request can't be accepted. The FIDO MDS3 certification level reflects the authenticator's current status, not the highest level it ever held, so a later decertification or downgrade is honored by a step-up policy. A JOSE header or payload that decodes to JSON null or a non-object now raises a typed authentication error instead of an uncaught TypeError."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"title": "WebSocket fragmented-message reassembly is bounded before completion",
|
|
30
|
+
"body": "The client enforced maxMessageBytes only when a fragmented message's final frame arrived, so a peer could stream unbounded continuation frames and exhaust memory before the limit was ever checked. The running reassembly total is now enforced per frame, and a frame arriving after close is ignored."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"title": "DMARC resolves the From domain before the policy lookup",
|
|
34
|
+
"body": "The From-header bare addr-spec domain extraction did not reject RFC 5322 group syntax or a trailing semicolon, so a crafted From header could yield a corrupted domain that defeated the DMARC alignment / policy lookup. The domain is now validated (length-bounded, rejecting the group and punctuation forms) before it is used."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"title": "Data-residency and SQL builder correctness",
|
|
38
|
+
"body": "The per-row data-residency write gate compares the residency column name case-insensitively, so a raw UPDATE that spells the column in a different letter case can no longer slip the cross-border transfer check. The SQL builder refuses an equality against NULL (col = NULL is always false; use IS NULL) and validates every element of an IN-list on the Postgres = ANY(?) path, matching the sqlite / MySQL path."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"title": "CSP builder refuses a directive-injecting source",
|
|
42
|
+
"body": "A CSP source is a single non-whitespace token. build() and mergeDirectives() now reject a source containing whitespace or a semicolon — because the emitter space-joins sources and semicolon-joins directives, a value like \"https://x; script-src https://evil\" injected a live directive."
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"heading": "Changed",
|
|
48
|
+
"items": [
|
|
49
|
+
{
|
|
50
|
+
"title": "DKIM l= is counted over the canonicalized body",
|
|
51
|
+
"body": "On verify, the l= body-length tag was applied to the raw body before canonicalization, so a legitimate relaxed/relaxed signature whose l= matched the canonicalized length was rejected with a body-hash mismatch whenever relaxed canonicalization changed the byte count within the first l= octets. The body is now canonicalized first and the canonicalized octet stream truncated to l= (RFC 6376 §3.4.5)."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "PIPL cross-border security-assessment threshold documentation corrected",
|
|
55
|
+
"body": "The b.pipl.sccFilingAssessment contract stated the CAC security assessment becomes mandatory above 100,000 cumulative PI subjects (the superseded 2022 figure). The builder enforces the current CAC Provisions on Promoting and Regulating Cross-Border Data Flows — a security assessment above 1,000,000 non-sensitive PI subjects or 10,000 sensitive, with the 100,000–1,000,000 band in the standard-contract / certification tier. The documentation now matches the enforced thresholds."
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"heading": "Fixed",
|
|
61
|
+
"items": [
|
|
62
|
+
{
|
|
63
|
+
"title": "composePipeline settles its promise when a middleware halts",
|
|
64
|
+
"body": "A regular middleware that wrote the response and returned without calling next() — the intended way to halt the chain from an auth / rate-limit / bot block — left the promise returned by b.middleware.composePipeline pending forever, retaining its request/response closure (an unbounded leak under sustained blocked traffic). The halt path now settles the promise without advancing to the route handler; the same applies to an error handler that consumes the error without calling next()."
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"title": "Quality-list header parsing is quote-aware",
|
|
68
|
+
"body": "parseQualityList mis-read a q= substring that appeared inside a quoted media-range or Accept-* parameter, mis-weighting content negotiation. It now parses the q parameter with quote-aware splitting."
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -1356,6 +1356,17 @@ async function testAuthJwtIssuerAudienceSubject() {
|
|
|
1356
1356
|
check("issuer mismatch throws auth-jwt/iss-mismatch",
|
|
1357
1357
|
threw && threw.code === "auth-jwt/iss-mismatch");
|
|
1358
1358
|
|
|
1359
|
+
// Array-valued iss must NOT any-of match a single-issuer expectation
|
|
1360
|
+
// (CVE-2025-30144 / fast-jwt class): iss is StringOrURI, not a list.
|
|
1361
|
+
var arrIssTok = await j.sign({ iss: ["https://evil.example", "https://blamejs.example.com"], sub: "u" }, {
|
|
1362
|
+
privateKey: k.privateKey, algorithm: "ML-DSA-87",
|
|
1363
|
+
});
|
|
1364
|
+
threw = null;
|
|
1365
|
+
try { await j.verify(arrIssTok, Object.assign({}, verifyOpts, { issuer: "https://blamejs.example.com" })); }
|
|
1366
|
+
catch (e) { threw = e; }
|
|
1367
|
+
check("array-valued iss refused (no any-of issuer bypass)",
|
|
1368
|
+
threw && threw.code === "auth-jwt/iss-mismatch");
|
|
1369
|
+
|
|
1359
1370
|
// Audience mismatch
|
|
1360
1371
|
threw = null;
|
|
1361
1372
|
try { await j.verify(token, Object.assign({}, verifyOpts, { audience: "api-c" })); }
|
|
@@ -7363,6 +7374,37 @@ async function testOAuthExchangeCodeRequiresVerifier() {
|
|
|
7363
7374
|
check("exchangeCode: empty opts rejected", threw && threw.code === "auth-oauth/no-code");
|
|
7364
7375
|
}
|
|
7365
7376
|
|
|
7377
|
+
async function testOAuthExchangeCodeRejectsFalsyNonce() {
|
|
7378
|
+
// OIDC nonce enforcement must require a NON-EMPTY nonce, not merely a defined
|
|
7379
|
+
// one. A falsy nonce (null / "") would slip a `=== undefined` guard and the
|
|
7380
|
+
// downstream ID-token nonce check is truthiness-gated, so the check would be
|
|
7381
|
+
// silently skipped and a token captured from another session replayed. The
|
|
7382
|
+
// guard fires before the token endpoint, so no network is needed.
|
|
7383
|
+
var oa = b.auth.oauth.create({
|
|
7384
|
+
clientId: "x", redirectUri: "https://app/cb",
|
|
7385
|
+
authorizationEndpoint: "https://example.com/auth",
|
|
7386
|
+
tokenEndpoint: "https://example.com/token",
|
|
7387
|
+
issuer: "https://example.com",
|
|
7388
|
+
isOidc: true,
|
|
7389
|
+
});
|
|
7390
|
+
var args = { code: "abc", state: "s", verifier: "v" };
|
|
7391
|
+
var threwEmpty = null;
|
|
7392
|
+
try { await oa.exchangeCode(Object.assign({ nonce: "" }, args)); } catch (e) { threwEmpty = e; }
|
|
7393
|
+
check("exchangeCode: empty-string nonce rejected on OIDC",
|
|
7394
|
+
threwEmpty && threwEmpty.code === "auth-oauth/no-nonce");
|
|
7395
|
+
var threwNull = null;
|
|
7396
|
+
try { await oa.exchangeCode(Object.assign({ nonce: null }, args)); } catch (e) { threwNull = e; }
|
|
7397
|
+
check("exchangeCode: null nonce rejected on OIDC",
|
|
7398
|
+
threwNull && threwNull.code === "auth-oauth/no-nonce");
|
|
7399
|
+
// A non-empty nonce passes the guard (then fails later on the unreachable
|
|
7400
|
+
// endpoint — NOT with no-nonce), confirming the guard isn't over-broad.
|
|
7401
|
+
var threwValid = null;
|
|
7402
|
+
try { await oa.exchangeCode(Object.assign({ nonce: "real-nonce-value" }, args)); }
|
|
7403
|
+
catch (e) { threwValid = e; }
|
|
7404
|
+
check("exchangeCode: non-empty nonce passes the nonce guard",
|
|
7405
|
+
threwValid && threwValid.code !== "auth-oauth/no-nonce");
|
|
7406
|
+
}
|
|
7407
|
+
|
|
7366
7408
|
async function testOAuthExchangeCodeRoundTrip() {
|
|
7367
7409
|
// Spin a fake IdP that accepts a code + verifier and returns tokens.
|
|
7368
7410
|
var receivedBody = null;
|
|
@@ -13448,6 +13490,16 @@ function testJsonModuleSurface() {
|
|
|
13448
13490
|
check("safeJson.validate is a function", typeof b.safeJson.validate === "function");
|
|
13449
13491
|
check("safeJson.canonical is a function", typeof b.safeJson.canonical === "function");
|
|
13450
13492
|
check("safeJson.SafeJsonError exists", typeof b.safeJson.SafeJsonError === "function");
|
|
13493
|
+
// isJsonObject — only a plain object is true; null / arrays / scalars (all
|
|
13494
|
+
// valid JSON documents parse() accepts) are not, so a parsed header / claims
|
|
13495
|
+
// set must be re-checked before its fields are dereferenced.
|
|
13496
|
+
check("safeJson.isJsonObject is a function", typeof b.safeJson.isJsonObject === "function");
|
|
13497
|
+
check("safeJson.isJsonObject true for a plain object", b.safeJson.isJsonObject({ a: 1 }) === true);
|
|
13498
|
+
check("safeJson.isJsonObject false for null", b.safeJson.isJsonObject(null) === false);
|
|
13499
|
+
check("safeJson.isJsonObject false for an array", b.safeJson.isJsonObject([1, 2]) === false);
|
|
13500
|
+
check("safeJson.isJsonObject false for a scalar", b.safeJson.isJsonObject("x") === false);
|
|
13501
|
+
check("safeJson.isJsonObject false for a parsed JSON null",
|
|
13502
|
+
b.safeJson.isJsonObject(b.safeJson.parse("null")) === false);
|
|
13451
13503
|
}
|
|
13452
13504
|
|
|
13453
13505
|
function testJsonParse() {
|
|
@@ -18441,6 +18493,7 @@ async function run() {
|
|
|
18441
18493
|
await testOAuthAuthorizationUrlGenericPreset();
|
|
18442
18494
|
await testOAuthAuthorizationUrlExtraParams();
|
|
18443
18495
|
await testOAuthExchangeCodeRequiresVerifier();
|
|
18496
|
+
await testOAuthExchangeCodeRejectsFalsyNonce();
|
|
18444
18497
|
await testOAuthExchangeCodeRoundTrip();
|
|
18445
18498
|
await testOAuthRefreshAccessToken();
|
|
18446
18499
|
await testOAuthFetchUserInfo();
|
|
@@ -19169,6 +19222,7 @@ module.exports = {
|
|
|
19169
19222
|
testOAuthAuthorizationUrlGenericPreset: testOAuthAuthorizationUrlGenericPreset,
|
|
19170
19223
|
testOAuthAuthorizationUrlExtraParams: testOAuthAuthorizationUrlExtraParams,
|
|
19171
19224
|
testOAuthExchangeCodeRequiresVerifier: testOAuthExchangeCodeRequiresVerifier,
|
|
19225
|
+
testOAuthExchangeCodeRejectsFalsyNonce: testOAuthExchangeCodeRejectsFalsyNonce,
|
|
19172
19226
|
testOAuthExchangeCodeRoundTrip: testOAuthExchangeCodeRoundTrip,
|
|
19173
19227
|
testOAuthRefreshAccessToken: testOAuthRefreshAccessToken,
|
|
19174
19228
|
testOAuthFetchUserInfo: testOAuthFetchUserInfo,
|
|
@@ -330,7 +330,29 @@ function testDpopMiddlewareShutdownExposed() {
|
|
|
330
330
|
check("AUTH-36 — shutdown/revoke run without throwing", true);
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
// A JOSE header segment of base64url("null") parses to JSON null; the verifier
|
|
334
|
+
// must reject it with a typed malformed error, not dereference null and throw a
|
|
335
|
+
// raw TypeError (broken error contract — a consumer branching on .code or
|
|
336
|
+
// expecting AuthError gets an unhandled TypeError instead).
|
|
337
|
+
async function testNullJoseHeaderTypedError() {
|
|
338
|
+
var _b = function (o) { return Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url"); };
|
|
339
|
+
var nullHeaderTok = _b("null") + "." + _b({ sub: "x" }) + "." + _b("sig");
|
|
340
|
+
|
|
341
|
+
var jwtThrew = null;
|
|
342
|
+
try { b.auth.jwt.decode(nullHeaderTok); } catch (e) { jwtThrew = e; }
|
|
343
|
+
check("jwt.decode null header → typed auth-jwt/malformed (not TypeError)",
|
|
344
|
+
jwtThrew && jwtThrew.code === "auth-jwt/malformed");
|
|
345
|
+
|
|
346
|
+
var dpopThrew = null;
|
|
347
|
+
try {
|
|
348
|
+
await b.auth.dpop.verify(nullHeaderTok, { htm: "POST", htu: "https://api.example.com/token" });
|
|
349
|
+
} catch (e) { dpopThrew = e; }
|
|
350
|
+
check("dpop.verify null header → typed auth-dpop/malformed (not TypeError)",
|
|
351
|
+
dpopThrew && dpopThrew.code === "auth-dpop/malformed");
|
|
352
|
+
}
|
|
353
|
+
|
|
333
354
|
async function run() {
|
|
355
|
+
await testNullJoseHeaderTypedError();
|
|
334
356
|
await testAlgKtyMismatchRsaWithEs256();
|
|
335
357
|
await testAlgKtyMismatchEcCurveConfusion();
|
|
336
358
|
await testCrossRealmIssRefused();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.auth.ciba.client — the ID Token returned by a CIBA token response /
|
|
4
|
+
* push notification MUST carry urn:openid:params:jwt:claim:auth_req_id, and the
|
|
5
|
+
* RP MUST verify it equals the auth_req_id this flow used (OpenID CIBA Core
|
|
6
|
+
* §7.3 / §10.1). Without it, an id_token minted for a DIFFERENT auth_req_id
|
|
7
|
+
* (another user's CIBA flow at the same RP) can be substituted.
|
|
8
|
+
*
|
|
9
|
+
* Drives the real parseNotification (push) consumer path against a local OIDC
|
|
10
|
+
* discovery + JWKS server, with an ES256-signed id_token.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
var http = require("http");
|
|
14
|
+
var nodeCrypto = require("crypto");
|
|
15
|
+
|
|
16
|
+
var helpers = require("../helpers");
|
|
17
|
+
var b = helpers.b;
|
|
18
|
+
var check = helpers.check;
|
|
19
|
+
|
|
20
|
+
var CLIENT_ID = "rp-ciba";
|
|
21
|
+
var KID = "test-es256";
|
|
22
|
+
var CNT = "client-notification-token-very-long-and-opaque-enough-for-ciba-min-entropy-padding";
|
|
23
|
+
|
|
24
|
+
var _kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
25
|
+
var _pubJwk = _kp.publicKey.export({ format: "jwk" });
|
|
26
|
+
_pubJwk.kid = KID; _pubJwk.alg = "ES256"; _pubJwk.use = "sig";
|
|
27
|
+
|
|
28
|
+
function _b64url(x) { return Buffer.from(x).toString("base64url"); }
|
|
29
|
+
|
|
30
|
+
function _mintIdToken(issuer, authReqIdClaim) {
|
|
31
|
+
var header = { alg: "ES256", kid: KID, typ: "JWT" };
|
|
32
|
+
var now = Math.floor(Date.now() / 1000);
|
|
33
|
+
var payload = {
|
|
34
|
+
iss: issuer, aud: CLIENT_ID, sub: "user-1", iat: now, exp: now + 3600,
|
|
35
|
+
};
|
|
36
|
+
if (authReqIdClaim !== undefined) payload["urn:openid:params:jwt:claim:auth_req_id"] = authReqIdClaim;
|
|
37
|
+
var input = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
|
|
38
|
+
var sig = nodeCrypto.sign("sha256", Buffer.from(input), { key: _kp.privateKey, dsaEncoding: "ieee-p1363" });
|
|
39
|
+
return input + "." + _b64url(sig);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function _withIdp(fn) {
|
|
43
|
+
var holder = { issuer: null };
|
|
44
|
+
var server = http.createServer(function (req, res) {
|
|
45
|
+
var u = new URL(req.url, "http://localhost");
|
|
46
|
+
if (u.pathname === "/.well-known/openid-configuration") {
|
|
47
|
+
var doc = JSON.stringify({
|
|
48
|
+
issuer: holder.issuer, authorization_endpoint: holder.issuer + "/auth",
|
|
49
|
+
token_endpoint: holder.issuer + "/token", jwks_uri: holder.issuer + "/jwks",
|
|
50
|
+
});
|
|
51
|
+
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(doc) });
|
|
52
|
+
res.end(doc); return;
|
|
53
|
+
}
|
|
54
|
+
if (u.pathname === "/jwks") {
|
|
55
|
+
var jwks = JSON.stringify({ keys: [_pubJwk] });
|
|
56
|
+
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(jwks) });
|
|
57
|
+
res.end(jwks); return;
|
|
58
|
+
}
|
|
59
|
+
res.writeHead(404); res.end();
|
|
60
|
+
});
|
|
61
|
+
await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
|
|
62
|
+
holder.issuer = "http://127.0.0.1:" + server.address().port;
|
|
63
|
+
try { return await fn(holder.issuer); }
|
|
64
|
+
finally { await new Promise(function (r) { server.close(function () { r(); }); }); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _cibaClient(issuer) {
|
|
68
|
+
return b.auth.ciba.client.create({
|
|
69
|
+
issuer: issuer, clientId: CLIENT_ID,
|
|
70
|
+
clientSecret: "ciba-client-secret-very-long-and-opaque-enough-for-min-entropy-guard-padding-xx",
|
|
71
|
+
tokenEndpoint: issuer + "/token",
|
|
72
|
+
backchannelAuthenticationEndpoint: issuer + "/bc-auth",
|
|
73
|
+
deliveryMode: "push", clientNotificationToken: CNT,
|
|
74
|
+
allowHttp: true, allowInternal: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _push(idToken, bodyAuthReqId) {
|
|
79
|
+
return {
|
|
80
|
+
req: { headers: { authorization: "Bearer " + CNT } },
|
|
81
|
+
opts: { body: { auth_req_id: bodyAuthReqId, access_token: "at", id_token: idToken } },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function run() {
|
|
86
|
+
await _withIdp(async function (issuer) {
|
|
87
|
+
var ciba = _cibaClient(issuer);
|
|
88
|
+
|
|
89
|
+
// Matching auth_req_id claim → accepted.
|
|
90
|
+
var okPush = _push(_mintIdToken(issuer, "req-AAA"), "req-AAA");
|
|
91
|
+
var okInfo = await ciba.parseNotification(okPush.req, okPush.opts);
|
|
92
|
+
check("parseNotification: id_token bound to the notification's auth_req_id is accepted",
|
|
93
|
+
okInfo && okInfo.authReqId === "req-AAA" && okInfo.claims && okInfo.claims.claims &&
|
|
94
|
+
okInfo.claims.claims["urn:openid:params:jwt:claim:auth_req_id"] === "req-AAA");
|
|
95
|
+
|
|
96
|
+
// id_token minted for a DIFFERENT auth_req_id → refused (substitution).
|
|
97
|
+
var badPush = _push(_mintIdToken(issuer, "req-BBB"), "req-AAA");
|
|
98
|
+
var threwMismatch = null;
|
|
99
|
+
try { await ciba.parseNotification(badPush.req, badPush.opts); }
|
|
100
|
+
catch (e) { threwMismatch = e; }
|
|
101
|
+
check("parseNotification: id_token for a different auth_req_id refused",
|
|
102
|
+
threwMismatch && threwMismatch.code === "auth-ciba/id-token-authreqid-mismatch");
|
|
103
|
+
|
|
104
|
+
// id_token with no auth_req_id claim → refused (MUST be present).
|
|
105
|
+
var noClaimPush = _push(_mintIdToken(issuer, undefined), "req-AAA");
|
|
106
|
+
var threwAbsent = null;
|
|
107
|
+
try { await ciba.parseNotification(noClaimPush.req, noClaimPush.opts); }
|
|
108
|
+
catch (e) { threwAbsent = e; }
|
|
109
|
+
check("parseNotification: id_token missing the auth_req_id claim refused",
|
|
110
|
+
threwAbsent && threwAbsent.code === "auth-ciba/id-token-authreqid-mismatch");
|
|
111
|
+
|
|
112
|
+
// An EMPTY auth_req_id in the notification body must be refused outright —
|
|
113
|
+
// otherwise it reaches the binding helper as a falsy expected value and the
|
|
114
|
+
// substitution defense is skipped, so an id_token minted for ANOTHER flow
|
|
115
|
+
// would be returned as trusted.
|
|
116
|
+
var emptyPush = _push(_mintIdToken(issuer, "req-attacker"), "");
|
|
117
|
+
var threwEmpty = null;
|
|
118
|
+
try { await ciba.parseNotification(emptyPush.req, emptyPush.opts); }
|
|
119
|
+
catch (e) { threwEmpty = e; }
|
|
120
|
+
check("parseNotification: empty auth_req_id in the body is refused",
|
|
121
|
+
threwEmpty && threwEmpty.code === "auth-ciba/no-auth-req-id-in-body");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { run: run };
|
|
126
|
+
|
|
127
|
+
if (require.main === module) {
|
|
128
|
+
run().then(function () { console.log("OK ciba-authreqid-binding — " + helpers.getChecks() + " checks"); })
|
|
129
|
+
.catch(function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); });
|
|
130
|
+
}
|
|
@@ -3061,6 +3061,7 @@ async function testNoDuplicateCodeBlocks() {
|
|
|
3061
3061
|
"lib/archive-tar-read.js:_assertGuardMetadata",
|
|
3062
3062
|
"lib/archive-tar-read.js:extract",
|
|
3063
3063
|
"lib/auth/ciba.js:_registerInitialInterval",
|
|
3064
|
+
"lib/auth/ciba.js:_verifyIdTokenIfPresent",
|
|
3064
3065
|
"lib/auth/ciba.js:pollToken",
|
|
3065
3066
|
"lib/auth/oauth.js:exchangeToken",
|
|
3066
3067
|
"lib/auth/oauth.js:pollDeviceCode",
|
|
@@ -5144,6 +5145,42 @@ var KNOWN_ANTIPATTERNS = [
|
|
|
5144
5145
|
allowlist: ["lib/x509-chain.js"],
|
|
5145
5146
|
reason: "basicConstraints cA:TRUE enforcement is owned by x509Chain.issuerValidlyIssued / x509Chain.isCaCert; tsa/mail-bimi/mail-crypto-smime route through it. Any lib file calling X.checkIssued(Y) (Y!=X) directly bypasses the cA check and must use x509Chain instead. lib/x509-chain.js is the home of the primitive.",
|
|
5146
5147
|
},
|
|
5148
|
+
{
|
|
5149
|
+
id: "compose-pipeline-settle-on-response-ended-not-return",
|
|
5150
|
+
primitive: "b.middleware.composePipeline",
|
|
5151
|
+
scanScope: "lib",
|
|
5152
|
+
skipCommentLines: true,
|
|
5153
|
+
// The REGULAR (3-arg) middleware branch must settle on a HALT (the
|
|
5154
|
+
// response ended), never merely because the middleware FUNCTION returned —
|
|
5155
|
+
// a callback-style middleware that calls next() later (timer/stream/legacy)
|
|
5156
|
+
// returns with advanced===false but is NOT halted; a bare `if (!advanced)`
|
|
5157
|
+
// resolve there marks the pipeline finished so the deferred next() is
|
|
5158
|
+
// ignored and the chain stalls (Codex PR#357). The fix gates on the
|
|
5159
|
+
// response: `if (!advanced && _responseEnded(res))`. Anchored on the 3-arg
|
|
5160
|
+
// call `entry.mw(req, res, _next)` so it targets ONLY the regular branch —
|
|
5161
|
+
// the error-handler branch (`entry.mw(err, req, res, _next)`) legitimately
|
|
5162
|
+
// settles on a bare `if (!advanced)` per the Express "no next = handled"
|
|
5163
|
+
// convention and must not be flagged.
|
|
5164
|
+
regex: /entry\.mw\(req, res, _next\)[\s\S]{0,200}?if \(\s*!advanced\s*\)\s*_resolveOnce/,
|
|
5165
|
+
allowlist: [],
|
|
5166
|
+
reason: "the REGULAR-middleware settle in compose-pipeline must be response-gated (`if (!advanced && _responseEnded(res))`), not a bare `if (!advanced) _resolveOnce()` — the bare form breaks callback-style deferred-next middleware (Codex PR#357). The behavioral guard is testDeferredNextContinuesChain; this detector anchors on the 3-arg entry.mw(req, res, _next) call so the error-handler branch's legitimate bare settle is not matched.",
|
|
5167
|
+
},
|
|
5168
|
+
{
|
|
5169
|
+
id: "ciba-authreqid-binding-not-truthiness-gated",
|
|
5170
|
+
primitive: "b.auth.ciba",
|
|
5171
|
+
scanScope: "lib",
|
|
5172
|
+
skipCommentLines: true,
|
|
5173
|
+
// The CIBA id_token auth_req_id binding must not be guarded by a bare
|
|
5174
|
+
// truthiness test: an empty-string auth_req_id (a valid-typed but falsy
|
|
5175
|
+
// value reaching the helper from a push notification body) would skip the
|
|
5176
|
+
// substitution defense entirely (Codex PR#357 — same class as the oauth
|
|
5177
|
+
// empty-nonce fail-open). The fixed form fails closed:
|
|
5178
|
+
// `if (typeof expectedAuthReqId !== "string" || expectedAuthReqId.length === 0)`.
|
|
5179
|
+
// `expectedAuthReqId` is unique to ciba.js, so no allowlist is needed.
|
|
5180
|
+
regex: /if\s*\(\s*expectedAuthReqId\s*\)/,
|
|
5181
|
+
allowlist: [],
|
|
5182
|
+
reason: "the CIBA auth_req_id binding must fail closed on an empty/missing id (`typeof x !== 'string' || x.length === 0`), never skip behind a bare `if (expectedAuthReqId)` — a falsy auth_req_id otherwise bypasses the cross-user token-substitution defense (Codex PR#357).",
|
|
5183
|
+
},
|
|
5147
5184
|
{
|
|
5148
5185
|
id: "trusted-proxy-cidr-must-canonicalize-peer",
|
|
5149
5186
|
primitive: "b.requestHelpers.trustedClientIp",
|
|
@@ -50,6 +50,26 @@ function testRefusesUnsafeKeywords() {
|
|
|
50
50
|
check("csp.build refuses 'unsafe-inline'", threw === "csp/unsafe-keyword");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function testRefusesDirectiveInjectionInSource() {
|
|
54
|
+
// A CSP source is a single non-whitespace token. A ';' or whitespace inside a
|
|
55
|
+
// source value would inject a new directive (the emitter ';'-joins directives,
|
|
56
|
+
// space-joins sources) — refuse both in build() and mergeDirectives().
|
|
57
|
+
var threwSemi = null;
|
|
58
|
+
try { b.csp.build({ "img-src": ["https://evil.com; script-src https://attacker.example"] }); }
|
|
59
|
+
catch (e) { threwSemi = e.code; }
|
|
60
|
+
check("csp.build refuses ';' in a source (directive injection)", threwSemi === "csp/bad-source");
|
|
61
|
+
|
|
62
|
+
var threwWs = null;
|
|
63
|
+
try { b.csp.build({ "img-src": ["https://evil.com script-src"] }); }
|
|
64
|
+
catch (e) { threwWs = e.code; }
|
|
65
|
+
check("csp.build refuses whitespace in a source", threwWs === "csp/bad-source");
|
|
66
|
+
|
|
67
|
+
var threwMerge = null;
|
|
68
|
+
try { b.csp.mergeDirectives(undefined, { "img-src": ["https://evil.com; script-src https://x"] }); }
|
|
69
|
+
catch (e) { threwMerge = e.code; }
|
|
70
|
+
check("csp.mergeDirectives refuses ';' in an added source", threwMerge === "csp/bad-source");
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
function testRefusesCatchAll() {
|
|
54
74
|
var threw = null;
|
|
55
75
|
try { b.csp.build({ "default-src": ["*"] }); }
|
|
@@ -155,6 +175,7 @@ function run() {
|
|
|
155
175
|
testWebrtcDirective();
|
|
156
176
|
testBuild();
|
|
157
177
|
testRefusesUnsafeKeywords();
|
|
178
|
+
testRefusesDirectiveInjectionInSource();
|
|
158
179
|
testRefusesCatchAll();
|
|
159
180
|
testRefusesDataInImg();
|
|
160
181
|
testRefusesUnknownDirective();
|
|
@@ -177,6 +177,28 @@ async function run() {
|
|
|
177
177
|
});
|
|
178
178
|
check("read-only WITH...SELECT is not gated (no residency error)", cteReadErr === null);
|
|
179
179
|
|
|
180
|
+
// RAW PATH 7: SQL unquoted identifiers are case-insensitive. An INSERT /
|
|
181
|
+
// UPDATE that spells the residency column in a different case (DATAREGION /
|
|
182
|
+
// DataRegion) must still engage the gate — a case-sensitive lookup let the
|
|
183
|
+
// column miss the `residencyColumn: "dataRegion"` declaration and admitted a
|
|
184
|
+
// cross-border write (CWE-178 / CWE-863).
|
|
185
|
+
var ciInsertCode = codeOf(function () {
|
|
186
|
+
b.db.runSql(
|
|
187
|
+
"INSERT INTO residents (_id, name, DATAREGION) VALUES ('raw-ci', 'x', 'us-east-1')");
|
|
188
|
+
});
|
|
189
|
+
check("differently-cased residency column on INSERT is still gated",
|
|
190
|
+
ciInsertCode === "db-query/row-residency-local-mismatch");
|
|
191
|
+
check("case-bypass INSERT cross-border row did not persist",
|
|
192
|
+
b.db.from("residents").where({ _id: "raw-ci" }).first() === null);
|
|
193
|
+
|
|
194
|
+
var ciUpdateCode = codeOf(function () {
|
|
195
|
+
b.db.runSql("UPDATE residents SET DataRegion='us-east-1' WHERE _id='raw-eu'");
|
|
196
|
+
});
|
|
197
|
+
check("differently-cased residency column on UPDATE is still gated",
|
|
198
|
+
ciUpdateCode === "db-query/row-residency-local-mismatch");
|
|
199
|
+
check("case-bypass UPDATE did not move the row cross-border",
|
|
200
|
+
(b.db.from("residents").where({ _id: "raw-eu" }).first() || {}).dataRegion === "eu-west-1");
|
|
201
|
+
|
|
180
202
|
// A writable-CTE write to a NON-residency table is not over-rejected.
|
|
181
203
|
b.db.runSql("CREATE TABLE IF NOT EXISTS notes (_id TEXT PRIMARY KEY, body TEXT)");
|
|
182
204
|
b.db.runSql("WITH s AS (SELECT 'hi' AS b) INSERT INTO notes (_id, body) SELECT 'n1', b FROM s");
|
|
@@ -184,10 +184,48 @@ function testVerifyAuthenticatorClean() {
|
|
|
184
184
|
check("verifyAuthenticator ok=true on clean entry", rv.ok === true);
|
|
185
185
|
check("verifyAuthenticator surfaces statement",
|
|
186
186
|
rv.statement && rv.statement.description === "Test A");
|
|
187
|
-
check("verifyAuthenticator certifiedLevel reflects
|
|
187
|
+
check("verifyAuthenticator certifiedLevel reflects the latest report",
|
|
188
188
|
rv.certifiedLevel.level === 2 && rv.certifiedLevel.plus === false);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function testVerifyAuthenticatorDecertified() {
|
|
192
|
+
// A later NOT_FIDO_CERTIFIED decertifies the authenticator: certifiedLevel
|
|
193
|
+
// must reset to 0, not report the historical max — otherwise a step-up / risk
|
|
194
|
+
// policy that requires L2 accepts a now-uncertified authenticator.
|
|
195
|
+
var blob = {
|
|
196
|
+
entries: [{
|
|
197
|
+
aaguid: "01234567-89ab-cdef-0123-456789abcdef",
|
|
198
|
+
metadataStatement: { description: "Test A" },
|
|
199
|
+
statusReports: [
|
|
200
|
+
{ status: "FIDO_CERTIFIED_L2", effectiveDate: "2020-01-01" },
|
|
201
|
+
{ status: "NOT_FIDO_CERTIFIED", effectiveDate: "2023-01-01" },
|
|
202
|
+
],
|
|
203
|
+
}],
|
|
204
|
+
};
|
|
205
|
+
var rv = b.auth.fidoMds3.verifyAuthenticator(blob, {
|
|
206
|
+
aaguid: "01234567-89ab-cdef-0123-456789abcdef",
|
|
207
|
+
});
|
|
208
|
+
check("verifyAuthenticator: later NOT_FIDO_CERTIFIED resets certifiedLevel to 0",
|
|
209
|
+
rv.certifiedLevel.level === 0 && rv.certifiedLevel.plus === false);
|
|
210
|
+
|
|
211
|
+
// A downgrade (L3 then L1) reports the CURRENT L1, not the historical L3.
|
|
212
|
+
var blob2 = {
|
|
213
|
+
entries: [{
|
|
214
|
+
aaguid: "01234567-89ab-cdef-0123-456789abcdef",
|
|
215
|
+
metadataStatement: { description: "Test B" },
|
|
216
|
+
statusReports: [
|
|
217
|
+
{ status: "FIDO_CERTIFIED_L3", effectiveDate: "2019-01-01" },
|
|
218
|
+
{ status: "FIDO_CERTIFIED_L1", effectiveDate: "2024-01-01" },
|
|
219
|
+
],
|
|
220
|
+
}],
|
|
221
|
+
};
|
|
222
|
+
var rv2 = b.auth.fidoMds3.verifyAuthenticator(blob2, {
|
|
223
|
+
aaguid: "01234567-89ab-cdef-0123-456789abcdef",
|
|
224
|
+
});
|
|
225
|
+
check("verifyAuthenticator: certifiedLevel is the latest report, not the historical max",
|
|
226
|
+
rv2.certifiedLevel.level === 1);
|
|
227
|
+
}
|
|
228
|
+
|
|
191
229
|
function testVerifyAuthenticatorRevoked() {
|
|
192
230
|
var blob = {
|
|
193
231
|
entries: [
|
|
@@ -412,6 +450,7 @@ async function run() {
|
|
|
412
450
|
testLookupAaguid();
|
|
413
451
|
testVerifyAuthenticatorClean();
|
|
414
452
|
testVerifyAuthenticatorRevoked();
|
|
453
|
+
testVerifyAuthenticatorDecertified();
|
|
415
454
|
testVerifyAuthenticatorPhysicalCompromise();
|
|
416
455
|
testVerifyAuthenticatorRemoteCompromise();
|
|
417
456
|
testVerifyAuthenticatorUnknownAaguid();
|
|
@@ -165,6 +165,34 @@ async function testInboundVerifySpoofRejected() {
|
|
|
165
165
|
check("inbound.verify: no authservId → no A-R header", v.authResults === null);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
async function testInboundVerifyGroupSyntaxFromRejected() {
|
|
169
|
+
// DMARC bypass via RFC 5322 group syntax: `From: Recipients:alice@victim;`
|
|
170
|
+
// carries no whitespace or comma but is not a bare addr-spec — the group
|
|
171
|
+
// `name:` prefix and trailing ';' would corrupt the extracted domain
|
|
172
|
+
// ("victim.example;"), which misses the real _dmarc.victim.example policy and
|
|
173
|
+
// would let the spoof through as "none". The From must be unparsable so the
|
|
174
|
+
// verdict fails closed (permerror → reject), not a silent pass.
|
|
175
|
+
var dnsLookup = _inboundDns({
|
|
176
|
+
"_dmarc.victim.example/TXT": [["v=DMARC1; p=reject"]],
|
|
177
|
+
});
|
|
178
|
+
var clean = await b.mail.inbound.verify({
|
|
179
|
+
ip: "203.0.113.9", helo: "evil.host", mailFrom: "x@evil.example",
|
|
180
|
+
message: "From: alice@victim.example\r\nSubject: hi\r\n\r\nbody\r\n",
|
|
181
|
+
dnsLookup: dnsLookup,
|
|
182
|
+
});
|
|
183
|
+
check("control: a clean From resolves the victim domain",
|
|
184
|
+
clean.from.domain === "victim.example");
|
|
185
|
+
var spoof = await b.mail.inbound.verify({
|
|
186
|
+
ip: "203.0.113.9", helo: "evil.host", mailFrom: "x@evil.example",
|
|
187
|
+
message: "From: Recipients:alice@victim.example;\r\nSubject: hi\r\n\r\nbody\r\n",
|
|
188
|
+
dnsLookup: dnsLookup,
|
|
189
|
+
});
|
|
190
|
+
check("group-syntax From yields no parsable domain (no corrupted hostname)",
|
|
191
|
+
spoof.from.domain === null);
|
|
192
|
+
check("group-syntax From fails closed (permerror → reject, not a silent pass)",
|
|
193
|
+
spoof.dmarc.result === "permerror" && spoof.dmarc.recommendedAction === "reject");
|
|
194
|
+
}
|
|
195
|
+
|
|
168
196
|
async function testInboundVerifyFromHeaderDiscipline() {
|
|
169
197
|
var dnsLookup = _inboundDns({});
|
|
170
198
|
// Two From fields — the header-duplication spoofing shape.
|
|
@@ -1439,6 +1467,7 @@ async function run() {
|
|
|
1439
1467
|
await testDmarcEvaluateNpPolicy();
|
|
1440
1468
|
await testInboundVerifyAlignedPass();
|
|
1441
1469
|
await testInboundVerifySpoofRejected();
|
|
1470
|
+
await testInboundVerifyGroupSyntaxFromRejected();
|
|
1442
1471
|
await testInboundVerifyFromHeaderDiscipline();
|
|
1443
1472
|
await testInboundVerifyTemperrorPrecedence();
|
|
1444
1473
|
await testInboundVerifyValidation();
|
|
@@ -626,6 +626,51 @@ function testDkimStripBTagValueAnchored() {
|
|
|
626
626
|
"v=1; pub=KEYDATA; b=");
|
|
627
627
|
}
|
|
628
628
|
|
|
629
|
+
// RFC 6376 §3.4.5 — the l= body-length count is octets of the body AFTER
|
|
630
|
+
// canonicalization. A legitimate relaxed/relaxed sender that sets
|
|
631
|
+
// l=<canonicalized-body-length> over a body whose raw form is longer (a WSP run
|
|
632
|
+
// the relaxed canon collapses) must verify. Slicing the RAW body to l= octets
|
|
633
|
+
// before canonicalizing yields a different body hash and wrongly rejects it.
|
|
634
|
+
async function testDkimVerifyLTagCountsCanonicalizedOctets() {
|
|
635
|
+
var dkim = b.mail.dkim;
|
|
636
|
+
dkim._resetDkimKeyCacheForTest(); // unique key — don't collide with a cached selector
|
|
637
|
+
var kp = _rsaKeypair();
|
|
638
|
+
var pubB64 = _spkiPemToB64(kp.publicKey);
|
|
639
|
+
var DOMAIN = "ltag.example", SELECTOR = "ltag1";
|
|
640
|
+
|
|
641
|
+
// Raw body carries a 10-space WSP run; relaxed canon collapses it to one SP,
|
|
642
|
+
// so the canonicalized octet count (l=) is smaller than the raw length.
|
|
643
|
+
var rawBody = "A B\r\n";
|
|
644
|
+
var canon = dkim._canonBodyRelaxedForTest(rawBody); // "A B\r\n"
|
|
645
|
+
var canonBuf = Buffer.from(canon, "utf8");
|
|
646
|
+
var lcap = canonBuf.length; // octets of the CANONICALIZED body
|
|
647
|
+
var bh = nodeCrypto.createHash("sha256").update(canonBuf.subarray(0, lcap)).digest("base64");
|
|
648
|
+
|
|
649
|
+
var fromValue = " Alice <alice@example.com>";
|
|
650
|
+
var unsignedSigValue = [
|
|
651
|
+
"v=1", "a=rsa-sha256", "c=relaxed/relaxed",
|
|
652
|
+
"d=" + DOMAIN, "s=" + SELECTOR, "h=from", "bh=" + bh, "l=" + lcap, "b=",
|
|
653
|
+
].join("; ");
|
|
654
|
+
// Reconstruct exactly what the verifier signs: each h= header, then the
|
|
655
|
+
// DKIM-Signature header with an empty b=, trailing CRLF stripped (§3.7).
|
|
656
|
+
var canonHeaders =
|
|
657
|
+
dkim._canonHeaderRelaxedForTest("From", fromValue) +
|
|
658
|
+
dkim._canonHeaderRelaxedForTest("DKIM-Signature", unsignedSigValue).replace(/\r\n$/, "");
|
|
659
|
+
var sig = nodeCrypto.createSign("RSA-SHA256").update(canonHeaders)
|
|
660
|
+
.sign(kp.privateKey).toString("base64");
|
|
661
|
+
var finalSigValue = unsignedSigValue.replace(/b=$/, "b=" + sig);
|
|
662
|
+
|
|
663
|
+
var message =
|
|
664
|
+
"From:" + fromValue + "\r\n" +
|
|
665
|
+
"DKIM-Signature: " + finalSigValue + "\r\n" +
|
|
666
|
+
"\r\n" + rawBody;
|
|
667
|
+
|
|
668
|
+
var dnsLookup = async function () { return [["v=DKIM1; k=rsa; p=" + pubB64]]; };
|
|
669
|
+
var rv = await dkim.verify(message, { dnsLookup: dnsLookup });
|
|
670
|
+
check("verify: l= over the canonicalized octet count passes (RFC 6376 §3.4.5)",
|
|
671
|
+
rv[0] && rv[0].result === "pass");
|
|
672
|
+
}
|
|
673
|
+
|
|
629
674
|
async function run() {
|
|
630
675
|
testDkimSurfaceAndValidation();
|
|
631
676
|
testDkimCanonicalization();
|
|
@@ -651,6 +696,7 @@ async function run() {
|
|
|
651
696
|
await testDkimVerifySignatureCountCapped();
|
|
652
697
|
await testDkimKeyCacheLru();
|
|
653
698
|
testDkimStripBTagValueAnchored();
|
|
699
|
+
await testDkimVerifyLTagCountsCanonicalizedOctets();
|
|
654
700
|
}
|
|
655
701
|
|
|
656
702
|
module.exports = { run: run };
|