@blamejs/blamejs-shop 0.3.56 → 0.3.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +89 -10
- package/lib/security-middleware.js +17 -1
- package/lib/storefront.js +156 -51
- package/lib/vendor/MANIFEST.json +84 -72
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +3 -3
- package/lib/vendor/blamejs/SECURITY.md +5 -0
- package/lib/vendor/blamejs/api-snapshot.json +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
- package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
- package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
- package/lib/vendor/blamejs/lib/archive-read.js +215 -16
- package/lib/vendor/blamejs/lib/archive.js +206 -52
- package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
- package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
- package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
- package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
- package/lib/vendor/blamejs/lib/db-schema.js +120 -3
- package/lib/vendor/blamejs/lib/db.js +10 -3
- package/lib/vendor/blamejs/lib/error-page.js +93 -9
- package/lib/vendor/blamejs/lib/external-db.js +164 -13
- package/lib/vendor/blamejs/lib/guard-email.js +36 -3
- package/lib/vendor/blamejs/lib/http-client.js +37 -7
- package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
- package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
- package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
- package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
- package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
- package/lib/vendor/blamejs/lib/openapi.js +225 -100
- package/lib/vendor/blamejs/lib/problem-details.js +15 -3
- package/lib/vendor/blamejs/lib/queue-local.js +148 -38
- package/lib/vendor/blamejs/lib/queue.js +41 -11
- package/lib/vendor/blamejs/lib/render.js +21 -3
- package/lib/vendor/blamejs/lib/router.js +13 -6
- package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
- package/lib/vendor/blamejs/lib/sse.js +7 -5
- package/lib/vendor/blamejs/lib/static.js +46 -17
- package/lib/vendor/blamejs/lib/uri-template.js +3 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
- package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
- package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
- package/lib/vendor/blamejs/test/00-primitives.js +151 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
- package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
- package/package.json +1 -1
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - b.auth.saml (sp.buildAuthnRequest, sp.verifyResponse signature path)
|
|
6
6
|
* - b.auth.openidFederation (entity statement parse + verify, metadata-policy operators)
|
|
7
7
|
* - b.auth.oid4vp (DCQL validator + matcher)
|
|
8
|
-
* - b.auth.oid4vci (issuer config validation, metadata, offer creation shape
|
|
8
|
+
* - b.auth.oid4vci (issuer config validation, metadata, offer creation shape,
|
|
9
|
+
* kid-only proof resolution via resolveKid)
|
|
9
10
|
* - b.auth.ciba (client.create validation + binding-message rules)
|
|
10
11
|
* - b.auth.sdJwtVc (key_attestation header surfaces through present)
|
|
11
12
|
* - b.session.isAnonymous + b.session.create({ anonymous: true })
|
|
@@ -281,6 +282,124 @@ function testOid4vciIssuerConfig() {
|
|
|
281
282
|
check("OID4VCI: metadata has configurations", !!meta.credential_configurations_supported["id-card-1"]);
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
// ---- OID4VCI kid-only proof via resolveKid ---------------------------
|
|
286
|
+
|
|
287
|
+
// Build a holder-signed openid4vci-proof+jwt with a `kid` header and no
|
|
288
|
+
// inline `jwk` (the EUDI-Wallet attested-key shape). ES256 over the
|
|
289
|
+
// holder EC key.
|
|
290
|
+
function _b64url(buf) {
|
|
291
|
+
return Buffer.from(buf).toString("base64url");
|
|
292
|
+
}
|
|
293
|
+
function _signKidProof(holderPrivKey, kid, aud, nonce) {
|
|
294
|
+
var header = { typ: "openid4vci-proof+jwt", alg: "ES256", kid: kid };
|
|
295
|
+
var payload = { aud: aud, nonce: nonce, iat: Math.floor(Date.now() / 1000) };
|
|
296
|
+
var signingInput = _b64url(JSON.stringify(header)) + "." + _b64url(JSON.stringify(payload));
|
|
297
|
+
var sig = nodeCrypto.sign("sha256", Buffer.from(signingInput, "ascii"),
|
|
298
|
+
{ key: holderPrivKey, dsaEncoding: "ieee-p1363" });
|
|
299
|
+
return signingInput + "." + _b64url(sig);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function _runIssuanceWithProof(issuerOpts, proofForNonce) {
|
|
303
|
+
var captured = { holderKey: null };
|
|
304
|
+
var iss = b.auth.oid4vci.issuer.create(Object.assign({
|
|
305
|
+
credentialIssuerUrl: "https://issuer.example",
|
|
306
|
+
credentialEndpoint: "https://issuer.example/credential",
|
|
307
|
+
tokenEndpoint: "https://issuer.example/token",
|
|
308
|
+
sdJwtIssuer: { issue: async function (a) { captured.holderKey = a.holderKey; return { token: "fake-sd-jwt" }; } },
|
|
309
|
+
supportedCredentials: {
|
|
310
|
+
"id-card-1": { format: "vc+sd-jwt", vct: "https://example.com/vct/identity",
|
|
311
|
+
claims: { given_name: {} } },
|
|
312
|
+
},
|
|
313
|
+
}, issuerOpts));
|
|
314
|
+
var offer = await iss.createCredentialOffer({ subject: "user-7", credentialIds: ["id-card-1"] });
|
|
315
|
+
var tokens = await iss.exchangePreAuthorizedCode({ preAuthCode: offer.preAuthCode });
|
|
316
|
+
var rv = await iss.issueCredential({
|
|
317
|
+
accessToken: tokens.access_token,
|
|
318
|
+
credentialIdentifier: "id-card-1",
|
|
319
|
+
proof: proofForNonce(tokens.c_nonce),
|
|
320
|
+
claims: { given_name: "Alice" },
|
|
321
|
+
});
|
|
322
|
+
return { rv: rv, captured: captured };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function testOid4vciKidResolver() {
|
|
326
|
+
var holderKp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
327
|
+
var holderPubJwk = holderKp.publicKey.export({ format: "jwk" });
|
|
328
|
+
var aud = "https://issuer.example";
|
|
329
|
+
|
|
330
|
+
// Config-time: a non-function resolveKid is refused at create().
|
|
331
|
+
var threw = false;
|
|
332
|
+
try {
|
|
333
|
+
b.auth.oid4vci.issuer.create({
|
|
334
|
+
credentialIssuerUrl: aud, credentialEndpoint: aud + "/credential", tokenEndpoint: aud + "/token",
|
|
335
|
+
sdJwtIssuer: { issue: async function () { return { token: "x" }; } },
|
|
336
|
+
supportedCredentials: { "id-card-1": { format: "vc+sd-jwt", vct: "https://x" } },
|
|
337
|
+
resolveKid: "not-a-function",
|
|
338
|
+
});
|
|
339
|
+
} catch (e) { threw = /resolveKid.*must be a function/.test(e.message); }
|
|
340
|
+
check("OID4VCI: non-function resolveKid refused at config time", threw);
|
|
341
|
+
|
|
342
|
+
// kid-only proof with a matching resolveKid → verifies + binds cnf.
|
|
343
|
+
var resolved = await _runIssuanceWithProof(
|
|
344
|
+
{ resolveKid: function (kid) { return kid === "holder-key-1" ? holderPubJwk : null; } },
|
|
345
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
346
|
+
check("OID4VCI: kid-only proof with resolveKid issues credential",
|
|
347
|
+
resolved.rv && resolved.rv.credential === "fake-sd-jwt");
|
|
348
|
+
check("OID4VCI: resolved holder key bound to cnf",
|
|
349
|
+
resolved.captured.holderKey && resolved.captured.holderKey.x === holderPubJwk.x);
|
|
350
|
+
|
|
351
|
+
// resolveKid may return a node:crypto KeyObject (exported to JWK for cnf).
|
|
352
|
+
var resolvedKo = await _runIssuanceWithProof(
|
|
353
|
+
{ resolveKid: function () { return holderKp.publicKey; } },
|
|
354
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
355
|
+
check("OID4VCI: resolveKid KeyObject return verifies + exports cnf JWK",
|
|
356
|
+
resolvedKo.captured.holderKey && resolvedKo.captured.holderKey.kty === "EC");
|
|
357
|
+
|
|
358
|
+
// kid-only proof with NO resolveKid configured → clear refusal (back-compat).
|
|
359
|
+
threw = false;
|
|
360
|
+
try {
|
|
361
|
+
await _runIssuanceWithProof(
|
|
362
|
+
{},
|
|
363
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
364
|
+
} catch (e) { threw = /kid-resolver-not-supported/.test(e.code) || /resolveKid/.test(e.message); }
|
|
365
|
+
check("OID4VCI: kid-only proof without resolveKid still refused", threw);
|
|
366
|
+
|
|
367
|
+
// resolveKid returns a WRONG key (different holder) → signature fails cleanly.
|
|
368
|
+
var otherKp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
369
|
+
threw = false;
|
|
370
|
+
try {
|
|
371
|
+
await _runIssuanceWithProof(
|
|
372
|
+
{ resolveKid: function () { return otherKp.publicKey.export({ format: "jwk" }); } },
|
|
373
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
374
|
+
} catch (e) { threw = /proof-bad-signature|signature verification failed/.test(e.message); }
|
|
375
|
+
check("OID4VCI: resolveKid wrong key fails signature verification", threw);
|
|
376
|
+
|
|
377
|
+
// resolveKid returns garbage (no key) → clean refusal, no crash.
|
|
378
|
+
threw = false;
|
|
379
|
+
try {
|
|
380
|
+
await _runIssuanceWithProof(
|
|
381
|
+
{ resolveKid: function () { return null; } },
|
|
382
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
383
|
+
} catch (e) { threw = /kid-unresolved/.test(e.code) || /returned no key/.test(e.message); }
|
|
384
|
+
check("OID4VCI: resolveKid returning no key refused cleanly", threw);
|
|
385
|
+
|
|
386
|
+
// resolveKid that THROWS → wrapped as a stable AuthError code, not an
|
|
387
|
+
// unhandled rejection. The resolver is operator code, so its own
|
|
388
|
+
// message is allowed through for operator debugging.
|
|
389
|
+
threw = false;
|
|
390
|
+
var isAuthErr = false;
|
|
391
|
+
try {
|
|
392
|
+
await _runIssuanceWithProof(
|
|
393
|
+
{ resolveKid: function () { throw new Error("resolver-backend-down"); } },
|
|
394
|
+
function (nonce) { return _signKidProof(holderKp.privateKey, "holder-key-1", aud, nonce); });
|
|
395
|
+
} catch (e) {
|
|
396
|
+
threw = /kid-resolver-failed/.test(e.code) || /resolveKid threw/.test(e.message);
|
|
397
|
+
isAuthErr = e instanceof b.frameworkError.AuthError;
|
|
398
|
+
}
|
|
399
|
+
check("OID4VCI: resolveKid throwing yields kid-resolver-failed", threw);
|
|
400
|
+
check("OID4VCI: resolveKid failure is a typed AuthError", isAuthErr);
|
|
401
|
+
}
|
|
402
|
+
|
|
284
403
|
// ---- CIBA client config ----------------------------------------------
|
|
285
404
|
|
|
286
405
|
function testCibaConfig() {
|
|
@@ -440,6 +559,7 @@ async function run() {
|
|
|
440
559
|
testFederationParseAndPolicy();
|
|
441
560
|
testDcqlMatch();
|
|
442
561
|
testOid4vciIssuerConfig();
|
|
562
|
+
await testOid4vciKidResolver();
|
|
443
563
|
testCibaConfig();
|
|
444
564
|
await testSdJwtKeyAttestationStorage();
|
|
445
565
|
await testAnonymousSessions();
|
|
@@ -93,6 +93,19 @@ function testGuardEmailMixedScript() {
|
|
|
93
93
|
rv.issues.some(function (i) { return i.kind === "mixed-script-domain"; }));
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function testGuardEmailUnicodeLocalPartRejected() {
|
|
97
|
+
// Unicode local-part (RFC 6531 SMTPUTF8 / EAI) is NOT accepted — the
|
|
98
|
+
// local-part is ASCII atext only (RFC 5321 §4.1.2 / RFC 5322 §3.2.3).
|
|
99
|
+
// Build the accented codepoint programmatically so the source stays
|
|
100
|
+
// pure ASCII (no attack characters as literals). U+00E9 = "e acute".
|
|
101
|
+
var local = "u" + String.fromCodePoint(0x00e9) + "ser";
|
|
102
|
+
var rv = b.guardEmail.validateAddress(local + "@example.com", { profile: "strict" });
|
|
103
|
+
check("unicode local-part not accepted (no RFC 6531 EAI mailbox)",
|
|
104
|
+
rv.ok === false);
|
|
105
|
+
check("unicode local-part surfaces as address-syntax (ASCII-only contract)",
|
|
106
|
+
rv.issues.some(function (i) { return i.kind === "address-syntax"; }));
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
function testGuardEmailSyntaxReject() {
|
|
97
110
|
var rv = b.guardEmail.validateAddress("not an email", { profile: "strict" });
|
|
98
111
|
check("malformed address → multi-at or address-syntax issue",
|
|
@@ -218,6 +231,7 @@ async function run() {
|
|
|
218
231
|
testGuardEmailAddressComment();
|
|
219
232
|
testGuardEmailPunycode();
|
|
220
233
|
testGuardEmailMixedScript();
|
|
234
|
+
testGuardEmailUnicodeLocalPartRejected();
|
|
221
235
|
testGuardEmailSyntaxReject();
|
|
222
236
|
testGuardEmailBareLfSmuggling();
|
|
223
237
|
testGuardEmailCrlfHeaderInjection();
|
|
@@ -164,6 +164,58 @@ async function testDownloadHttpError() {
|
|
|
164
164
|
});
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
async function testStreamErrorBodyPreserved() {
|
|
168
|
+
// A streamed (responseMode "stream") non-2xx must surface a bounded prefix
|
|
169
|
+
// of the error body on the thrown error instead of draining it to nothing,
|
|
170
|
+
// so the caller can read the problem+json / encrypted error detail.
|
|
171
|
+
await _withServer(function (req, res) {
|
|
172
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
173
|
+
res.end(JSON.stringify({ error: "forbidden", detail: "stream-error-detail" }));
|
|
174
|
+
}, async function (baseUrl) {
|
|
175
|
+
var thrown = null;
|
|
176
|
+
try {
|
|
177
|
+
await b.httpClient.request({
|
|
178
|
+
url: baseUrl + "/x",
|
|
179
|
+
method: "GET",
|
|
180
|
+
responseMode: "stream",
|
|
181
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
|
|
182
|
+
allowInternal: true,
|
|
183
|
+
});
|
|
184
|
+
} catch (e) { thrown = e; }
|
|
185
|
+
check("stream non-2xx: threw HTTP_ERROR", thrown != null && thrown.code === "HTTP_ERROR");
|
|
186
|
+
check("stream non-2xx: message names the status", thrown && /403/.test(thrown.message));
|
|
187
|
+
check("stream non-2xx: err.body is a Buffer", thrown && Buffer.isBuffer(thrown.body));
|
|
188
|
+
check("stream non-2xx: err.body carries detail",
|
|
189
|
+
thrown && thrown.body && /stream-error-detail/.test(thrown.body.toString("utf8")));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// A large (> cap) error body must reject promptly with a bounded prefix —
|
|
193
|
+
// it must NOT leave the request promise pending until the whole body
|
|
194
|
+
// drains to close (if it did, this test would hang past the file timeout).
|
|
195
|
+
var BODY_LEN = 50003;
|
|
196
|
+
await _withServer(function (req, res) {
|
|
197
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
198
|
+
res.end("E".repeat(BODY_LEN) + "TAIL-MARKER");
|
|
199
|
+
}, async function (baseUrl) {
|
|
200
|
+
var thrown = null;
|
|
201
|
+
try {
|
|
202
|
+
await b.httpClient.request({
|
|
203
|
+
url: baseUrl + "/big",
|
|
204
|
+
method: "GET",
|
|
205
|
+
responseMode: "stream",
|
|
206
|
+
allowedProtocols: b.safeUrl.ALLOW_HTTP_ALL,
|
|
207
|
+
allowInternal: true,
|
|
208
|
+
});
|
|
209
|
+
} catch (e) { thrown = e; }
|
|
210
|
+
check("stream large-error: threw HTTP_ERROR (did not hang)",
|
|
211
|
+
thrown != null && thrown.code === "HTTP_ERROR");
|
|
212
|
+
check("stream large-error: body bounded well below sent size",
|
|
213
|
+
thrown && thrown.body && thrown.body.length > 0 && thrown.body.length < BODY_LEN);
|
|
214
|
+
check("stream large-error: tail beyond cap was dropped",
|
|
215
|
+
thrown && thrown.body && thrown.body.toString("utf8").indexOf("TAIL-MARKER") === -1);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
167
219
|
async function testDownloadBadOpts() {
|
|
168
220
|
var thrown = null;
|
|
169
221
|
try {
|
|
@@ -266,6 +318,7 @@ async function run() {
|
|
|
266
318
|
await testDownloadExpectedHashMatch();
|
|
267
319
|
await testDownloadHashMismatch();
|
|
268
320
|
await testDownloadHttpError();
|
|
321
|
+
await testStreamErrorBodyPreserved();
|
|
269
322
|
await testDownloadBadOpts();
|
|
270
323
|
await testUploadHappyPath();
|
|
271
324
|
await testUploadMissingFile();
|
|
@@ -682,6 +682,90 @@ function testDmarcRuaGunzipBombDistinguished() {
|
|
|
682
682
|
threw && /dmarc-rua-gunzip-bomb/.test(threw.code || ""));
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
+
function testDmarcRuaBuildRoundTrip() {
|
|
686
|
+
// RFC 7489 Appendix C — buildAggregateReport is the inverse of
|
|
687
|
+
// parseAggregateReport; a shaped report serialized to XML and re-
|
|
688
|
+
// parsed MUST deep-equal the input (the parser adds a derived
|
|
689
|
+
// `totals` convenience field, removed before comparison).
|
|
690
|
+
var shaped = {
|
|
691
|
+
reportMetadata: {
|
|
692
|
+
orgName: "reporter.example",
|
|
693
|
+
email: "dmarc@reporter.example",
|
|
694
|
+
reportId: "rpt-12345",
|
|
695
|
+
extraContact: null,
|
|
696
|
+
dateRange: { begin: 1700000000, end: 1700086400 },
|
|
697
|
+
},
|
|
698
|
+
policyPublished: {
|
|
699
|
+
domain: "example.com", adkim: "r", aspf: "r",
|
|
700
|
+
p: "reject", sp: "quarantine", pct: 100, fo: null,
|
|
701
|
+
},
|
|
702
|
+
records: [
|
|
703
|
+
{
|
|
704
|
+
sourceIp: "192.0.2.10", count: 7,
|
|
705
|
+
dispositions: {
|
|
706
|
+
disposition: "none", dkim: "pass", spf: "fail",
|
|
707
|
+
reasons: [{ type: "forwarded", comment: "mailing list" }],
|
|
708
|
+
},
|
|
709
|
+
identifiers: { headerFrom: "example.com", envelopeFrom: "example.com", envelopeTo: null },
|
|
710
|
+
authResults: {
|
|
711
|
+
dkim: [{ domain: "example.com", selector: "sel1", result: "pass", humanResult: null }],
|
|
712
|
+
spf: [{ domain: "example.com", result: "fail", scope: "mfrom" }],
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
// header_from carries XML metacharacters (a spoofed identifier
|
|
717
|
+
// observed in the wild) — escaping MUST survive the round-trip.
|
|
718
|
+
sourceIp: "203.0.113.5", count: 3,
|
|
719
|
+
dispositions: { disposition: "quarantine", dkim: "fail", spf: "fail", reasons: [] },
|
|
720
|
+
identifiers: { headerFrom: "evil<&>\".example.com", envelopeFrom: null, envelopeTo: null },
|
|
721
|
+
authResults: { dkim: [], spf: [{ domain: "spoof.example", result: "fail", scope: "mfrom" }] },
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
};
|
|
725
|
+
var xml = b.mail.dmarc.buildAggregateReport(shaped);
|
|
726
|
+
check("dmarc.buildAggregateReport returns an XML string with <feedback> root",
|
|
727
|
+
typeof xml === "string" && /^<\?xml/.test(xml) && xml.indexOf("<feedback>") !== -1);
|
|
728
|
+
// The injected metacharacters must be entity-escaped in the wire form.
|
|
729
|
+
check("dmarc.buildAggregateReport escapes XML metacharacters",
|
|
730
|
+
xml.indexOf("evil<&>") === -1 && xml.indexOf("<&>") !== -1);
|
|
731
|
+
|
|
732
|
+
var back = b.mail.dmarc.parseAggregateReport(xml);
|
|
733
|
+
delete back.totals;
|
|
734
|
+
check("dmarc.buildAggregateReport round-trips through parseAggregateReport",
|
|
735
|
+
JSON.stringify(back) === JSON.stringify(shaped));
|
|
736
|
+
|
|
737
|
+
// Optional gzip path mirrors the parser's gzip auto-detect.
|
|
738
|
+
var gz = b.mail.dmarc.buildAggregateReport(shaped, { gzip: true });
|
|
739
|
+
check("dmarc.buildAggregateReport({gzip:true}) → gzip Buffer",
|
|
740
|
+
Buffer.isBuffer(gz) && gz[0] === 0x1f && gz[1] === 0x8b);
|
|
741
|
+
var back2 = b.mail.dmarc.parseAggregateReport(gz, { contentType: "application/gzip" });
|
|
742
|
+
delete back2.totals;
|
|
743
|
+
check("dmarc.buildAggregateReport(gzip) round-trips",
|
|
744
|
+
JSON.stringify(back2) === JSON.stringify(shaped));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function testDmarcRuaBuildBadInput() {
|
|
748
|
+
// Config-time tier — a malformed report shape THROWS so the operator
|
|
749
|
+
// catches it before mailing the report to a peer.
|
|
750
|
+
var cases = [
|
|
751
|
+
{ arg: null, re: /object/ },
|
|
752
|
+
{ arg: { policyPublished: {}, records: [] }, re: /reportMetadata/ },
|
|
753
|
+
{ arg: { reportMetadata: {}, records: [] }, re: /policyPublished/ },
|
|
754
|
+
{ arg: { reportMetadata: {}, policyPublished: {} }, re: /records/ },
|
|
755
|
+
{ arg: { reportMetadata: {}, policyPublished: {},
|
|
756
|
+
records: [{ sourceIp: "1.2.3.4", count: "notnum",
|
|
757
|
+
dispositions: {}, identifiers: {}, authResults: {} }] },
|
|
758
|
+
re: /finite integer/ },
|
|
759
|
+
];
|
|
760
|
+
for (var i = 0; i < cases.length; i += 1) {
|
|
761
|
+
var threw = null;
|
|
762
|
+
try { b.mail.dmarc.buildAggregateReport(cases[i].arg); }
|
|
763
|
+
catch (e) { threw = e; }
|
|
764
|
+
check("dmarc.buildAggregateReport rejects bad input #" + i,
|
|
765
|
+
threw && cases[i].re.test(threw.message || ""));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
685
769
|
async function testIprevValidatesPtrShape() {
|
|
686
770
|
// MAIL-50 — PTR result MUST be a valid DNS-name shape (LDH labels,
|
|
687
771
|
// 1..63 octets, total 1..253 octets). Synthesize via a mocked
|
|
@@ -796,19 +880,104 @@ async function testSpfMechanismMxOverLimit() {
|
|
|
796
880
|
rv.result === "permerror" && /caps at 10/.test(rv.explanation || ""));
|
|
797
881
|
}
|
|
798
882
|
|
|
799
|
-
async function
|
|
883
|
+
async function testSpfMechanismExists() {
|
|
884
|
+
// RFC 7208 §5.7 + §7 — `exists:<macro-string>` resolves the expanded
|
|
885
|
+
// domain; a present A record matches. The macro `%{l}` expands to the
|
|
886
|
+
// sender local-part, so `exists:%{l}.spf.example.com` queries
|
|
887
|
+
// `alice.spf.example.com`.
|
|
888
|
+
var queried = [];
|
|
800
889
|
var dnsLookup = async function (host, type) {
|
|
801
890
|
if (host === "example.com" && type === "TXT") {
|
|
802
891
|
return [["v=spf1 exists:%{l}.spf.example.com -all"]];
|
|
803
892
|
}
|
|
893
|
+
if (type === "A") {
|
|
894
|
+
queried.push(host);
|
|
895
|
+
if (host === "alice.spf.example.com") return ["127.0.0.2"];
|
|
896
|
+
}
|
|
804
897
|
var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
|
|
805
898
|
};
|
|
806
899
|
var rv = await b.mail.spf.verify({
|
|
807
900
|
ip: "192.0.2.5", mailFrom: "alice@example.com", dnsLookup: dnsLookup,
|
|
808
901
|
});
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
902
|
+
check("spf.verify(exists:%{l}..., A present) -> pass + expanded qname",
|
|
903
|
+
rv.result === "pass" && queried.some(function (q) { return q === "alice.spf.example.com"; }));
|
|
904
|
+
|
|
905
|
+
// No A record at the expanded target -> exists misses -> falls to -all.
|
|
906
|
+
var dnsMiss = async function (host, type) {
|
|
907
|
+
if (host === "example.com" && type === "TXT") {
|
|
908
|
+
return [["v=spf1 exists:%{l}.spf.example.com -all"]];
|
|
909
|
+
}
|
|
910
|
+
var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
|
|
911
|
+
};
|
|
912
|
+
var rv2 = await b.mail.spf.verify({
|
|
913
|
+
ip: "192.0.2.5", mailFrom: "alice@example.com", dnsLookup: dnsMiss,
|
|
914
|
+
});
|
|
915
|
+
check("spf.verify(exists, no A at target) -> fail (-all)", rv2.result === "fail");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function testSpfMacroExpansionExistsIrV() {
|
|
919
|
+
// RFC 7208 §7.1-§7.4 — `%{ir}.%{v}._spf.%{d}` is the canonical per-IP
|
|
920
|
+
// exists query. For 192.0.2.10: %{ir} reverses the dotted IP to
|
|
921
|
+
// "10.2.0.192", %{v} -> "in-addr", %{d} -> the current domain.
|
|
922
|
+
// Expected qname: 10.2.0.192.in-addr._spf.example.com.
|
|
923
|
+
var queried = null;
|
|
924
|
+
var dnsLookup = async function (host, type) {
|
|
925
|
+
if (host === "example.com" && type === "TXT") {
|
|
926
|
+
return [["v=spf1 exists:%{ir}.%{v}._spf.%{d} -all"]];
|
|
927
|
+
}
|
|
928
|
+
if (type === "A") {
|
|
929
|
+
queried = host;
|
|
930
|
+
if (host === "10.2.0.192.in-addr._spf.example.com") return ["127.0.0.2"];
|
|
931
|
+
}
|
|
932
|
+
var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
|
|
933
|
+
};
|
|
934
|
+
var rv = await b.mail.spf.verify({
|
|
935
|
+
ip: "192.0.2.10", mailFrom: "alice@example.com",
|
|
936
|
+
helo: "mta.example.com", dnsLookup: dnsLookup,
|
|
937
|
+
});
|
|
938
|
+
check("spf macro %{ir}.%{v}._spf.%{d} expands correctly",
|
|
939
|
+
queried === "10.2.0.192.in-addr._spf.example.com" && rv.result === "pass");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function testSpfMacroBadSyntaxPermerror() {
|
|
943
|
+
// RFC 7208 §7.1 — malformed macro syntax MUST permerror.
|
|
944
|
+
var shapes = [
|
|
945
|
+
{ rec: "v=spf1 exists:%{z} -all", re: /7\.2/ }, // unknown letter
|
|
946
|
+
{ rec: "v=spf1 exists:%{i.x -all", re: /closing/ }, // no closing brace
|
|
947
|
+
{ rec: "v=spf1 exists:%q -all", re: /invalid/ }, // bad escape
|
|
948
|
+
{ rec: "v=spf1 exists:%{} -all", re: /empty/ }, // empty macro
|
|
949
|
+
];
|
|
950
|
+
for (var i = 0; i < shapes.length; i += 1) {
|
|
951
|
+
var rec = shapes[i].rec;
|
|
952
|
+
var dns = (function (r) {
|
|
953
|
+
return async function (host, type) {
|
|
954
|
+
if (host === "example.com" && type === "TXT") return [[r]];
|
|
955
|
+
var err = new Error("ENOTFOUND"); err.code = "ENOTFOUND"; throw err;
|
|
956
|
+
};
|
|
957
|
+
}(rec));
|
|
958
|
+
var rv = await b.mail.spf.verify({
|
|
959
|
+
ip: "192.0.2.1", mailFrom: "bob@example.com", dnsLookup: dns,
|
|
960
|
+
});
|
|
961
|
+
check("spf.verify(" + JSON.stringify(rec) + ") -> permerror (bad macro)",
|
|
962
|
+
rv.result === "permerror" && shapes[i].re.test(rv.explanation || ""));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function testSpfExistsRespectsLookupLimit() {
|
|
967
|
+
// RFC 7208 §4.6.4 — exists A queries count toward the 10-lookup and
|
|
968
|
+
// 2-void ceilings; a chain of exists: misses MUST permerror rather
|
|
969
|
+
// than amplifying resolver work without bound.
|
|
970
|
+
var dns = async function (host, type) {
|
|
971
|
+
if (type === "TXT" && host === "example.com") {
|
|
972
|
+
return [["v=spf1 exists:a.%{d} exists:b.%{d} exists:c.%{d} -all"]];
|
|
973
|
+
}
|
|
974
|
+
var err = new Error("ENODATA"); err.code = "ENODATA"; throw err;
|
|
975
|
+
};
|
|
976
|
+
var rv = await b.mail.spf.verify({
|
|
977
|
+
ip: "192.0.2.1", mailFrom: "bob@example.com", dnsLookup: dns,
|
|
978
|
+
});
|
|
979
|
+
check("spf.verify(exists chain) -> permerror (RFC 7208 §4.6.4 void cap)",
|
|
980
|
+
rv.result === "permerror" && /void-lookup limit/.test(rv.explanation || ""));
|
|
812
981
|
}
|
|
813
982
|
|
|
814
983
|
async function testSpfMechanismEmptyDualCidrRefused() {
|
|
@@ -860,7 +1029,10 @@ async function run() {
|
|
|
860
1029
|
await testSpfMechanismADualCidr();
|
|
861
1030
|
await testSpfMechanismMx();
|
|
862
1031
|
await testSpfMechanismMxOverLimit();
|
|
863
|
-
await
|
|
1032
|
+
await testSpfMechanismExists();
|
|
1033
|
+
await testSpfMacroExpansionExistsIrV();
|
|
1034
|
+
await testSpfMacroBadSyntaxPermerror();
|
|
1035
|
+
await testSpfExistsRespectsLookupLimit();
|
|
864
1036
|
await testSpfMechanismEmptyDualCidrRefused();
|
|
865
1037
|
await testSpfMechanismPtrRemainsDeferred();
|
|
866
1038
|
testDmarcParse();
|
|
@@ -898,6 +1070,8 @@ async function run() {
|
|
|
898
1070
|
await testDmarcPctSamplingDeterministic();
|
|
899
1071
|
await testDmarcAlignmentUsesPsl();
|
|
900
1072
|
testDmarcRuaGunzipBombDistinguished();
|
|
1073
|
+
testDmarcRuaBuildRoundTrip();
|
|
1074
|
+
testDmarcRuaBuildBadInput();
|
|
901
1075
|
await testIprevValidatesPtrShape();
|
|
902
1076
|
await testArcHeaderSourceOrder();
|
|
903
1077
|
}
|
|
@@ -60,6 +60,50 @@ function testFactoryRefusesBadOpts() {
|
|
|
60
60
|
});
|
|
61
61
|
check("dsn without onPermanentFailure → DeliverError",
|
|
62
62
|
e5 && e5.code === "deliver/bad-dsn-callback");
|
|
63
|
+
|
|
64
|
+
var e6 = threw(function () {
|
|
65
|
+
b.mail.send.deliver({ hostname: "m.example", port: 70000 }); // out of [1,65535]
|
|
66
|
+
});
|
|
67
|
+
check("out-of-range port → DeliverError",
|
|
68
|
+
e6 && e6.code === "deliver/bad-port");
|
|
69
|
+
|
|
70
|
+
var e7 = threw(function () {
|
|
71
|
+
b.mail.send.deliver({ hostname: "m.example", port: 0 }); // 0 is not a connect port
|
|
72
|
+
});
|
|
73
|
+
check("port 0 → DeliverError (connect port must be >=1)",
|
|
74
|
+
e7 && e7.code === "deliver/bad-port");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Submission/smarthost port ----
|
|
78
|
+
|
|
79
|
+
// The default is IANA SMTP 25; an operator routing through a submission
|
|
80
|
+
// relay sets port 587 (RFC 6409) / 465 (RFC 8314). The configured port
|
|
81
|
+
// must reach the transport factory.
|
|
82
|
+
async function testPortReachesTransport() {
|
|
83
|
+
var ports = [];
|
|
84
|
+
var fakeResolver = {
|
|
85
|
+
queryMx: async function (domain) {
|
|
86
|
+
return [{ exchange: "mx1." + domain, priority: 10 }];
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
var fakeTransport = function (opts) {
|
|
90
|
+
ports.push(opts.port);
|
|
91
|
+
return { send: async function () { return { ok: true, code: 250 }; } };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
var deliverDefault = b.mail.send.deliver({
|
|
95
|
+
hostname: "mta1.example.com", resolver: fakeResolver,
|
|
96
|
+
policy: { mtaSts: "off", dane: "off" }, transportFactory: fakeTransport, audit: false,
|
|
97
|
+
});
|
|
98
|
+
await deliverDefault({ from: "ops@example.com", to: ["a@recipient.com"], rfc822: Buffer.from("hi") });
|
|
99
|
+
check("port: default is 25 when unset", ports[ports.length - 1] === 25);
|
|
100
|
+
|
|
101
|
+
var deliver587 = b.mail.send.deliver({
|
|
102
|
+
hostname: "mta1.example.com", resolver: fakeResolver, port: 587,
|
|
103
|
+
policy: { mtaSts: "off", dane: "off" }, transportFactory: fakeTransport, audit: false,
|
|
104
|
+
});
|
|
105
|
+
await deliver587({ from: "ops@example.com", to: ["b@recipient.com"], rfc822: Buffer.from("hi") });
|
|
106
|
+
check("port: configured 587 reaches the transport", ports[ports.length - 1] === 587);
|
|
63
107
|
}
|
|
64
108
|
|
|
65
109
|
// ---- Envelope shape validation ----
|
|
@@ -324,6 +368,7 @@ async function run() {
|
|
|
324
368
|
await testTransientDefersPermanentFails();
|
|
325
369
|
await testNullMx();
|
|
326
370
|
await testMxFailover();
|
|
371
|
+
await testPortReachesTransport();
|
|
327
372
|
}
|
|
328
373
|
|
|
329
374
|
module.exports = { run: run };
|
|
@@ -4,12 +4,61 @@
|
|
|
4
4
|
* - parseCallback (RFC 9207 AS Issuer Identifier validation)
|
|
5
5
|
* - parseJarmResponse (OAuth 2.0 JARM signed authorization response)
|
|
6
6
|
* - refreshAccessToken seen() callback (RFC 9700 §4.13 / OAuth 2.1 §6.1)
|
|
7
|
+
* - authorizationUrl PKCE-downgrade refusal (RFC 9700 §4.13 / RFC 7636)
|
|
7
8
|
*/
|
|
8
9
|
|
|
10
|
+
var http = require("node:http");
|
|
9
11
|
var helpers = require("../helpers");
|
|
10
12
|
var b = helpers.b;
|
|
11
13
|
var check = helpers.check;
|
|
12
14
|
|
|
15
|
+
// Minimal OIDC discovery server: serves /.well-known/openid-configuration
|
|
16
|
+
// with operator-chosen code_challenge_methods_supported so the PKCE
|
|
17
|
+
// downgrade gate runs against a real discovery round-trip.
|
|
18
|
+
function _spawnDiscoveryServer(methods) {
|
|
19
|
+
var issuerHolder = { value: null };
|
|
20
|
+
var server = http.createServer(function (req, res) {
|
|
21
|
+
var u = new URL(req.url, "http://localhost");
|
|
22
|
+
if (u.pathname !== "/.well-known/openid-configuration") { res.writeHead(404); res.end(); return; }
|
|
23
|
+
var doc = {
|
|
24
|
+
issuer: issuerHolder.value,
|
|
25
|
+
authorization_endpoint: issuerHolder.value + "/auth",
|
|
26
|
+
token_endpoint: issuerHolder.value + "/token",
|
|
27
|
+
jwks_uri: issuerHolder.value + "/jwks",
|
|
28
|
+
};
|
|
29
|
+
if (methods !== undefined) doc.code_challenge_methods_supported = methods;
|
|
30
|
+
var body = JSON.stringify(doc);
|
|
31
|
+
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
|
|
32
|
+
res.end(body);
|
|
33
|
+
});
|
|
34
|
+
server._issuerHolder = issuerHolder;
|
|
35
|
+
return server;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function _pkceDowngradeCase(methods, expectRefusal, label) {
|
|
39
|
+
var server = _spawnDiscoveryServer(methods);
|
|
40
|
+
await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
|
|
41
|
+
var issuer = "http://127.0.0.1:" + server.address().port;
|
|
42
|
+
server._issuerHolder.value = issuer;
|
|
43
|
+
try {
|
|
44
|
+
var oa = b.auth.oauth.create({
|
|
45
|
+
issuer: issuer,
|
|
46
|
+
clientId: "rp-dl",
|
|
47
|
+
redirectUri: "https://rp.example/cb",
|
|
48
|
+
isOidc: true,
|
|
49
|
+
allowHttp: true,
|
|
50
|
+
allowInternal: true,
|
|
51
|
+
});
|
|
52
|
+
var err = null;
|
|
53
|
+
try { await oa.authorizationUrl(); } catch (e) { err = e; }
|
|
54
|
+
if (expectRefusal) {
|
|
55
|
+
check(label, err !== null && err.code === "auth-oauth/pkce-downgrade");
|
|
56
|
+
} else {
|
|
57
|
+
check(label, err === null);
|
|
58
|
+
}
|
|
59
|
+
} finally { server.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
13
62
|
async function run() {
|
|
14
63
|
var oauth = b.auth.oauth.create({
|
|
15
64
|
issuer: "https://idp.example",
|
|
@@ -60,6 +109,37 @@ async function run() {
|
|
|
60
109
|
await oauth.refreshAccessToken("rt-1", { seen: async function () { return true; } });
|
|
61
110
|
} catch (e) { threw = /refresh-token-replay/.test(e.code); }
|
|
62
111
|
check("oauth.refreshAccessToken: seen()=true refuses replay", threw);
|
|
112
|
+
|
|
113
|
+
// PKCE downgrade defense (RFC 9700 §4.13 / RFC 7636). An OP whose
|
|
114
|
+
// discovery metadata advertises code_challenge_methods_supported
|
|
115
|
+
// without "S256" is refused at authorizationUrl; S256-capable or
|
|
116
|
+
// field-absent OPs keep working (back-compat).
|
|
117
|
+
await _pkceDowngradeCase(["plain"], true,
|
|
118
|
+
"oauth.authorizationUrl: plain-only OP refused (PKCE downgrade)");
|
|
119
|
+
await _pkceDowngradeCase([], true,
|
|
120
|
+
"oauth.authorizationUrl: empty methods list refused (no S256)");
|
|
121
|
+
await _pkceDowngradeCase(["S256"], false,
|
|
122
|
+
"oauth.authorizationUrl: S256-only OP accepted");
|
|
123
|
+
await _pkceDowngradeCase(["S256", "plain"], false,
|
|
124
|
+
"oauth.authorizationUrl: S256+plain OP accepted");
|
|
125
|
+
await _pkceDowngradeCase(undefined, false,
|
|
126
|
+
"oauth.authorizationUrl: field-absent OP accepted (back-compat)");
|
|
127
|
+
|
|
128
|
+
// Static-endpoint client with no discovery must never gain a network
|
|
129
|
+
// fetch or a downgrade refusal — the gate only inspects already-
|
|
130
|
+
// resolved discovery metadata.
|
|
131
|
+
var oaStatic = b.auth.oauth.create({
|
|
132
|
+
issuer: "https://static.example",
|
|
133
|
+
clientId: "rp-static",
|
|
134
|
+
redirectUri: "https://rp.example/cb",
|
|
135
|
+
isOidc: true,
|
|
136
|
+
authorizationEndpoint: "https://static.example/auth",
|
|
137
|
+
tokenEndpoint: "https://static.example/token",
|
|
138
|
+
});
|
|
139
|
+
var staticErr = null;
|
|
140
|
+
try { await oaStatic.authorizationUrl(); } catch (e) { staticErr = e; }
|
|
141
|
+
check("oauth.authorizationUrl: static endpoints skip discovery (no fetch, no refusal)",
|
|
142
|
+
staticErr === null);
|
|
63
143
|
}
|
|
64
144
|
|
|
65
145
|
module.exports = { run: run };
|