@blamejs/core 0.8.13 → 0.8.16
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 +6 -0
- package/README.md +3 -1
- package/index.js +12 -1
- package/lib/a2a.js +272 -0
- package/lib/ai-input.js +151 -0
- package/lib/audit.js +6 -0
- package/lib/dark-patterns.js +357 -0
- package/lib/framework-error.js +34 -0
- package/lib/graphql-federation.js +176 -0
- package/lib/http-client.js +16 -0
- package/lib/mail-auth.js +33 -10
- package/lib/mail-dkim.js +44 -2
- package/lib/mcp.js +301 -0
- package/lib/middleware/sse.js +18 -20
- package/lib/network-smtp-policy.js +57 -5
- package/lib/network-tls.js +33 -0
- package/lib/request-helpers.js +34 -0
- package/lib/router.js +28 -0
- package/lib/sse.js +349 -0
- package/lib/vault/index.js +4 -0
- package/lib/vault/seal-pem-file.js +283 -0
- package/lib/websocket.js +15 -0
- package/package.json +2 -2
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FTC Dark-Patterns / Click-to-Cancel UX-parity attestation.
|
|
4
|
+
*
|
|
5
|
+
* The FTC's Negative Option Rule (effective 2024; expanded 2025-2026
|
|
6
|
+
* via state click-to-cancel laws) requires that the steps to cancel a
|
|
7
|
+
* subscription / withdraw consent be no more burdensome than the
|
|
8
|
+
* steps to subscribe / grant consent. The standard breakdown:
|
|
9
|
+
*
|
|
10
|
+
* - prominence parity — same call-to-action visibility
|
|
11
|
+
* - click-count parity — cancel <= signup
|
|
12
|
+
* - contrast / font parity — accessible-text contrast and font
|
|
13
|
+
* weight match
|
|
14
|
+
* - method parity — operator that signed up over the web
|
|
15
|
+
* must let the subject cancel over the
|
|
16
|
+
* web (not phone-only)
|
|
17
|
+
* - confirmation parity — single-confirmation if signup was
|
|
18
|
+
* single-confirmation
|
|
19
|
+
*
|
|
20
|
+
* The framework can't measure pixel-level UI parity from server code.
|
|
21
|
+
* What it CAN do is provide a primitive that:
|
|
22
|
+
*
|
|
23
|
+
* 1. Records an operator-attested signup-flow snapshot (clicks,
|
|
24
|
+
* visible call-to-action text, font weight, contrast ratio).
|
|
25
|
+
* 2. Records an attested cancel-flow snapshot.
|
|
26
|
+
* 3. Computes the parity verdict and emits an audit trail.
|
|
27
|
+
* 4. Refuses to emit a "consent-withdrawn" event in postures that
|
|
28
|
+
* require parity if the snapshots show degradation.
|
|
29
|
+
*
|
|
30
|
+
* Public API:
|
|
31
|
+
*
|
|
32
|
+
* darkPatterns.recordSignupFlow(opts) -> snapshot
|
|
33
|
+
* darkPatterns.recordCancelFlow(opts) -> snapshot
|
|
34
|
+
* opts: {
|
|
35
|
+
* channel: "web" | "mobile" | "phone" | "email" | "in-person",
|
|
36
|
+
* clickCount: integer 1..50,
|
|
37
|
+
* cta: { text, fontWeight, contrastRatio },
|
|
38
|
+
* confirmations: integer 0..10,
|
|
39
|
+
* requiresLogin: bool,
|
|
40
|
+
* resourceId: operator-supplied id linking signup<->cancel,
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* darkPatterns.assertParity(signup, cancel, opts) -> { ok, breaches }
|
|
44
|
+
* opts:
|
|
45
|
+
* toleranceClicks — how many extra cancel clicks tolerated
|
|
46
|
+
* (default 0).
|
|
47
|
+
* toleranceContrast — minimum contrast ratio absolute value
|
|
48
|
+
* required of cancel (default 4.5 — AA).
|
|
49
|
+
* posture — "ftc-2024" | "ca-sb942" | "strict".
|
|
50
|
+
* errorClass — DarkPatternsError (mapped to McpError
|
|
51
|
+
* namespace? no — uses a dedicated class).
|
|
52
|
+
*
|
|
53
|
+
* darkPatterns.attest(opts) -> { id, signupFlow, cancelFlow, verdict, signedAt }
|
|
54
|
+
* One-shot composer used by operators that capture both flows
|
|
55
|
+
* during a regression test of their UI.
|
|
56
|
+
*
|
|
57
|
+
* darkPatterns.middleware(opts) -> middleware(req, res, next)
|
|
58
|
+
* Attached to the cancel-flow endpoint. Verifies the operator has
|
|
59
|
+
* a parity attestation on file (via opts.lookupAttestation) and
|
|
60
|
+
* refuses with 451 (legal reasons) if missing.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
var audit = require("./audit");
|
|
64
|
+
var { defineClass } = require("./framework-error");
|
|
65
|
+
|
|
66
|
+
var STR_LEN_MAX = 256; // allow:raw-byte-literal — string-length cap, not bytes
|
|
67
|
+
var FONT_WEIGHT_MAX = 1000; // allow:raw-byte-literal — CSS font-weight ceiling (CSS Fonts L4)
|
|
68
|
+
var DarkPatternsError = defineClass("DarkPatternsError", { alwaysPermanent: true });
|
|
69
|
+
|
|
70
|
+
var CHANNELS = ["web", "mobile", "phone", "email", "in-person", "mail"];
|
|
71
|
+
|
|
72
|
+
var POSTURES = {
|
|
73
|
+
// FTC Negative Option Rule baseline — clicks must not exceed signup;
|
|
74
|
+
// cancel must use the same channel; contrast/font weight must not
|
|
75
|
+
// degrade.
|
|
76
|
+
"ftc-2024": {
|
|
77
|
+
toleranceClicks: 0,
|
|
78
|
+
requireSameChannel: true,
|
|
79
|
+
toleranceContrast: 4.5,
|
|
80
|
+
requireSameFont: true,
|
|
81
|
+
},
|
|
82
|
+
// California SB-942 / AB-2863 — stricter; cancel UI must use same
|
|
83
|
+
// medium AND require <= signup confirmations.
|
|
84
|
+
"ca-sb942": {
|
|
85
|
+
toleranceClicks: 0,
|
|
86
|
+
requireSameChannel: true,
|
|
87
|
+
toleranceContrast: 4.5,
|
|
88
|
+
requireSameFont: true,
|
|
89
|
+
requireSameConfirmations: true,
|
|
90
|
+
},
|
|
91
|
+
"strict": {
|
|
92
|
+
toleranceClicks: 0,
|
|
93
|
+
requireSameChannel: true,
|
|
94
|
+
toleranceContrast: 7.0,
|
|
95
|
+
requireSameFont: true,
|
|
96
|
+
requireSameConfirmations: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function _validateFlowOpts(opts, label, errorClass) {
|
|
101
|
+
if (!opts || typeof opts !== "object") {
|
|
102
|
+
throw errorClass.factory("BAD_OPTS",
|
|
103
|
+
"darkPatterns.record" + label + ": opts required");
|
|
104
|
+
}
|
|
105
|
+
if (CHANNELS.indexOf(opts.channel) === -1) {
|
|
106
|
+
throw errorClass.factory("BAD_CHANNEL",
|
|
107
|
+
"darkPatterns: channel must be one of " + CHANNELS.join(","));
|
|
108
|
+
}
|
|
109
|
+
if (typeof opts.clickCount !== "number" || !isFinite(opts.clickCount) ||
|
|
110
|
+
opts.clickCount < 1 || opts.clickCount > 50 ||
|
|
111
|
+
Math.floor(opts.clickCount) !== opts.clickCount) {
|
|
112
|
+
throw errorClass.factory("BAD_CLICKS",
|
|
113
|
+
"darkPatterns: clickCount must be integer 1..50");
|
|
114
|
+
}
|
|
115
|
+
if (!opts.cta || typeof opts.cta !== "object") {
|
|
116
|
+
throw errorClass.factory("BAD_CTA",
|
|
117
|
+
"darkPatterns: cta object required (text, fontWeight, contrastRatio)");
|
|
118
|
+
}
|
|
119
|
+
if (typeof opts.cta.text !== "string" || opts.cta.text.length === 0 ||
|
|
120
|
+
opts.cta.text.length > STR_LEN_MAX) {
|
|
121
|
+
throw errorClass.factory("BAD_CTA_TEXT",
|
|
122
|
+
"darkPatterns: cta.text must be 1-256 char string");
|
|
123
|
+
}
|
|
124
|
+
if (typeof opts.cta.fontWeight !== "number" || opts.cta.fontWeight < 100 ||
|
|
125
|
+
opts.cta.fontWeight > FONT_WEIGHT_MAX) {
|
|
126
|
+
throw errorClass.factory("BAD_FONT_WEIGHT",
|
|
127
|
+
"darkPatterns: cta.fontWeight must be 100..1000");
|
|
128
|
+
}
|
|
129
|
+
if (typeof opts.cta.contrastRatio !== "number" ||
|
|
130
|
+
opts.cta.contrastRatio < 1 || opts.cta.contrastRatio > 21) {
|
|
131
|
+
throw errorClass.factory("BAD_CONTRAST",
|
|
132
|
+
"darkPatterns: cta.contrastRatio must be 1..21");
|
|
133
|
+
}
|
|
134
|
+
if (typeof opts.confirmations !== "number" ||
|
|
135
|
+
opts.confirmations < 0 || opts.confirmations > 10 ||
|
|
136
|
+
Math.floor(opts.confirmations) !== opts.confirmations) {
|
|
137
|
+
throw errorClass.factory("BAD_CONFIRMATIONS",
|
|
138
|
+
"darkPatterns: confirmations must be integer 0..10");
|
|
139
|
+
}
|
|
140
|
+
if (typeof opts.resourceId !== "string" || opts.resourceId.length === 0 ||
|
|
141
|
+
opts.resourceId.length > STR_LEN_MAX) {
|
|
142
|
+
throw errorClass.factory("BAD_RESOURCE_ID",
|
|
143
|
+
"darkPatterns: resourceId must be 1-256 char string");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recordSignupFlow(opts) {
|
|
148
|
+
_validateFlowOpts(opts, "SignupFlow", DarkPatternsError);
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
kind: "signup",
|
|
151
|
+
channel: opts.channel,
|
|
152
|
+
clickCount: opts.clickCount,
|
|
153
|
+
cta: Object.freeze({
|
|
154
|
+
text: opts.cta.text,
|
|
155
|
+
fontWeight: opts.cta.fontWeight,
|
|
156
|
+
contrastRatio: opts.cta.contrastRatio,
|
|
157
|
+
}),
|
|
158
|
+
confirmations: opts.confirmations,
|
|
159
|
+
requiresLogin: opts.requiresLogin === true,
|
|
160
|
+
resourceId: opts.resourceId,
|
|
161
|
+
recordedAt: Date.now(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function recordCancelFlow(opts) {
|
|
166
|
+
_validateFlowOpts(opts, "CancelFlow", DarkPatternsError);
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
kind: "cancel",
|
|
169
|
+
channel: opts.channel,
|
|
170
|
+
clickCount: opts.clickCount,
|
|
171
|
+
cta: Object.freeze({
|
|
172
|
+
text: opts.cta.text,
|
|
173
|
+
fontWeight: opts.cta.fontWeight,
|
|
174
|
+
contrastRatio: opts.cta.contrastRatio,
|
|
175
|
+
}),
|
|
176
|
+
confirmations: opts.confirmations,
|
|
177
|
+
requiresLogin: opts.requiresLogin === true,
|
|
178
|
+
resourceId: opts.resourceId,
|
|
179
|
+
recordedAt: Date.now(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertParity(signup, cancel, opts) {
|
|
184
|
+
opts = opts || {};
|
|
185
|
+
var errorClass = opts.errorClass || DarkPatternsError;
|
|
186
|
+
if (!signup || signup.kind !== "signup") {
|
|
187
|
+
throw errorClass.factory("BAD_SIGNUP_FLOW",
|
|
188
|
+
"darkPatterns.assertParity: signup must be a recorded signup flow");
|
|
189
|
+
}
|
|
190
|
+
if (!cancel || cancel.kind !== "cancel") {
|
|
191
|
+
throw errorClass.factory("BAD_CANCEL_FLOW",
|
|
192
|
+
"darkPatterns.assertParity: cancel must be a recorded cancel flow");
|
|
193
|
+
}
|
|
194
|
+
if (signup.resourceId !== cancel.resourceId) {
|
|
195
|
+
throw errorClass.factory("RESOURCE_MISMATCH",
|
|
196
|
+
"darkPatterns.assertParity: resourceId differs between flows");
|
|
197
|
+
}
|
|
198
|
+
var postureName = opts.posture || "ftc-2024";
|
|
199
|
+
var posture = POSTURES[postureName];
|
|
200
|
+
if (!posture) {
|
|
201
|
+
throw errorClass.factory("BAD_POSTURE",
|
|
202
|
+
"darkPatterns.assertParity: unknown posture " + postureName);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
var toleranceClicks = typeof opts.toleranceClicks === "number"
|
|
206
|
+
? opts.toleranceClicks : posture.toleranceClicks;
|
|
207
|
+
var toleranceContrast = typeof opts.toleranceContrast === "number"
|
|
208
|
+
? opts.toleranceContrast : posture.toleranceContrast;
|
|
209
|
+
|
|
210
|
+
var breaches = [];
|
|
211
|
+
|
|
212
|
+
if (cancel.clickCount > signup.clickCount + toleranceClicks) {
|
|
213
|
+
breaches.push({
|
|
214
|
+
kind: "click-count",
|
|
215
|
+
detail: "cancel " + cancel.clickCount + " > signup " + signup.clickCount +
|
|
216
|
+
" + tolerance " + toleranceClicks,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (posture.requireSameChannel && cancel.channel !== signup.channel) {
|
|
220
|
+
breaches.push({
|
|
221
|
+
kind: "channel-mismatch",
|
|
222
|
+
detail: "signup=" + signup.channel + " cancel=" + cancel.channel,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (cancel.cta.contrastRatio < toleranceContrast) {
|
|
226
|
+
breaches.push({
|
|
227
|
+
kind: "contrast-below-floor",
|
|
228
|
+
detail: "cancel contrast " + cancel.cta.contrastRatio +
|
|
229
|
+
" < required " + toleranceContrast,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (cancel.cta.contrastRatio < signup.cta.contrastRatio - 0.5) {
|
|
233
|
+
breaches.push({
|
|
234
|
+
kind: "contrast-degradation",
|
|
235
|
+
detail: "cancel " + cancel.cta.contrastRatio +
|
|
236
|
+
" < signup " + signup.cta.contrastRatio + " - 0.5",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (posture.requireSameFont && cancel.cta.fontWeight < signup.cta.fontWeight) {
|
|
240
|
+
breaches.push({
|
|
241
|
+
kind: "font-weight-degradation",
|
|
242
|
+
detail: "cancel " + cancel.cta.fontWeight +
|
|
243
|
+
" < signup " + signup.cta.fontWeight,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (posture.requireSameConfirmations &&
|
|
247
|
+
cancel.confirmations > signup.confirmations) {
|
|
248
|
+
breaches.push({
|
|
249
|
+
kind: "confirmation-step-added",
|
|
250
|
+
detail: "cancel " + cancel.confirmations +
|
|
251
|
+
" > signup " + signup.confirmations,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (cancel.requiresLogin && !signup.requiresLogin) {
|
|
255
|
+
breaches.push({
|
|
256
|
+
kind: "login-required-only-for-cancel",
|
|
257
|
+
detail: "signup did not require login; cancel does",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { ok: breaches.length === 0, breaches: breaches, posture: postureName };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function attest(opts) {
|
|
265
|
+
opts = opts || {};
|
|
266
|
+
var errorClass = opts.errorClass || DarkPatternsError;
|
|
267
|
+
var signup = recordSignupFlow(opts.signup || {});
|
|
268
|
+
var cancel = recordCancelFlow(opts.cancel || {});
|
|
269
|
+
var verdict = assertParity(signup, cancel, {
|
|
270
|
+
errorClass: errorClass,
|
|
271
|
+
posture: opts.posture,
|
|
272
|
+
});
|
|
273
|
+
var auditOn = opts.audit !== false;
|
|
274
|
+
if (auditOn) {
|
|
275
|
+
audit.safeEmit({
|
|
276
|
+
action: "darkpatterns.attest",
|
|
277
|
+
outcome: verdict.ok ? "success" : "denied",
|
|
278
|
+
reason: verdict.ok ? null : "parity-breach",
|
|
279
|
+
metadata: {
|
|
280
|
+
resourceId: signup.resourceId,
|
|
281
|
+
posture: verdict.posture,
|
|
282
|
+
breaches: verdict.breaches.map(function (b) { return b.kind; }),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
id: signup.resourceId,
|
|
288
|
+
signupFlow: signup,
|
|
289
|
+
cancelFlow: cancel,
|
|
290
|
+
verdict: verdict,
|
|
291
|
+
signedAt: Date.now(),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function middleware(opts) {
|
|
296
|
+
opts = opts || {};
|
|
297
|
+
var errorClass = opts.errorClass || DarkPatternsError;
|
|
298
|
+
if (typeof opts.lookupAttestation !== "function") {
|
|
299
|
+
throw errorClass.factory("BAD_OPTS",
|
|
300
|
+
"darkPatterns.middleware: lookupAttestation function required");
|
|
301
|
+
}
|
|
302
|
+
if (typeof opts.resourceIdFromReq !== "function") {
|
|
303
|
+
throw errorClass.factory("BAD_OPTS",
|
|
304
|
+
"darkPatterns.middleware: resourceIdFromReq function required");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return function darkPatternsMw(req, res, next) {
|
|
308
|
+
Promise.resolve().then(function () {
|
|
309
|
+
var resourceId;
|
|
310
|
+
try { resourceId = opts.resourceIdFromReq(req); }
|
|
311
|
+
catch (e) {
|
|
312
|
+
return _refuse(res, "darkPatterns: resourceIdFromReq threw: " + e.message);
|
|
313
|
+
}
|
|
314
|
+
if (typeof resourceId !== "string" || resourceId.length === 0) {
|
|
315
|
+
return _refuse(res, "darkPatterns: missing resourceId");
|
|
316
|
+
}
|
|
317
|
+
return Promise.resolve(opts.lookupAttestation(resourceId)).then(function (att) {
|
|
318
|
+
if (!att || !att.verdict || !att.verdict.ok) {
|
|
319
|
+
audit.safeEmit({
|
|
320
|
+
action: "darkpatterns.cancel_blocked",
|
|
321
|
+
outcome: "denied",
|
|
322
|
+
reason: att && att.verdict ? "parity-breach" : "no-attestation",
|
|
323
|
+
metadata: { resourceId: resourceId, breaches: att && att.verdict ? att.verdict.breaches.map(function (b) { return b.kind; }) : [] },
|
|
324
|
+
});
|
|
325
|
+
if (typeof res.setHeader === "function") {
|
|
326
|
+
res.setHeader("Content-Type", "application/json");
|
|
327
|
+
}
|
|
328
|
+
res.statusCode = 451;
|
|
329
|
+
res.end(JSON.stringify({
|
|
330
|
+
error: "cancel-flow-not-attested",
|
|
331
|
+
detail: att && att.verdict ? att.verdict.breaches : "no attestation on file",
|
|
332
|
+
}));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (typeof next === "function") next();
|
|
336
|
+
});
|
|
337
|
+
}).catch(function (e) {
|
|
338
|
+
_refuse(res, "darkPatterns middleware error: " + (e && e.message));
|
|
339
|
+
});
|
|
340
|
+
function _refuse(r, msg) {
|
|
341
|
+
if (typeof r.setHeader === "function") r.setHeader("Content-Type", "application/json");
|
|
342
|
+
r.statusCode = 500;
|
|
343
|
+
r.end(JSON.stringify({ error: msg }));
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
recordSignupFlow: recordSignupFlow,
|
|
350
|
+
recordCancelFlow: recordCancelFlow,
|
|
351
|
+
assertParity: assertParity,
|
|
352
|
+
attest: attest,
|
|
353
|
+
middleware: middleware,
|
|
354
|
+
POSTURES: Object.keys(POSTURES),
|
|
355
|
+
CHANNELS: CHANNELS.slice(),
|
|
356
|
+
DarkPatternsError: DarkPatternsError,
|
|
357
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -378,6 +378,35 @@ var SmtpPolicyError = defineClass("SmtpPolicyError", { alwaysPermane
|
|
|
378
378
|
// record shape, fetch failures, missing keys, alignment issues.
|
|
379
379
|
// Permanent — DNS-config / message-shape errors, not transient.
|
|
380
380
|
var MailAuthError = defineClass("MailAuthError", { alwaysPermanent: true });
|
|
381
|
+
// SseError covers Server-Sent Events stream-shape violations: newline
|
|
382
|
+
// or CR or NUL injection in event:/id:/data: fields (CVE-2026-33128
|
|
383
|
+
// h3, CVE-2026-29085 Hono, CVE-2026-44217 sse-channel — newline in
|
|
384
|
+
// any of the three fields enables event-spoofing, data-injection, or
|
|
385
|
+
// Last-Event-ID reconnect corruption), control-char injection in
|
|
386
|
+
// retry: numeric, oversized field caps, attempts to write after
|
|
387
|
+
// stream close. Permanent — these are caller-shape errors.
|
|
388
|
+
var SseError = defineClass("SseError", { alwaysPermanent: true });
|
|
389
|
+
// McpError covers Model Context Protocol server-side violations:
|
|
390
|
+
// unauthenticated tool/resource invocations (CVE-2026-33032 nginx-ui
|
|
391
|
+
// auth-bypass class), confused-deputy via static client IDs +
|
|
392
|
+
// dynamic client registration (CVE-2025-6514 mcp-remote OAuth RCE
|
|
393
|
+
// class), consent-cookie leakage, malformed Authorization header,
|
|
394
|
+
// tool/resource name path traversal. Permanent — protocol-shape
|
|
395
|
+
// errors.
|
|
396
|
+
var McpError = defineClass("McpError", { alwaysPermanent: true });
|
|
397
|
+
// AiInputError covers prompt-injection classifier violations: malformed
|
|
398
|
+
// input shape, classifier-result-shape errors, oversized input bypass.
|
|
399
|
+
// Permanent — caller-shape errors.
|
|
400
|
+
var AiInputError = defineClass("AiInputError", { alwaysPermanent: true });
|
|
401
|
+
// A2aError covers A2A (Agent-to-Agent) protocol violations: signed-
|
|
402
|
+
// agent-card signature mismatch, expired card, unknown card id,
|
|
403
|
+
// malformed card shape, signature-algorithm allowlist drift.
|
|
404
|
+
// Permanent.
|
|
405
|
+
var A2aError = defineClass("A2aError", { alwaysPermanent: true });
|
|
406
|
+
// GraphqlFederationError covers _service.sdl trust-boundary violations:
|
|
407
|
+
// missing or malformed router-token, replay (nonce already seen),
|
|
408
|
+
// unauthorized SDL probe. Permanent.
|
|
409
|
+
var GraphqlFederationError = defineClass("GraphqlFederationError", { alwaysPermanent: true });
|
|
381
410
|
|
|
382
411
|
module.exports = {
|
|
383
412
|
FrameworkError: FrameworkError,
|
|
@@ -438,4 +467,9 @@ module.exports = {
|
|
|
438
467
|
ComplianceError: ComplianceError,
|
|
439
468
|
SmtpPolicyError: SmtpPolicyError,
|
|
440
469
|
MailAuthError: MailAuthError,
|
|
470
|
+
SseError: SseError,
|
|
471
|
+
McpError: McpError,
|
|
472
|
+
AiInputError: AiInputError,
|
|
473
|
+
A2aError: A2aError,
|
|
474
|
+
GraphqlFederationError: GraphqlFederationError,
|
|
441
475
|
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* GraphQL Federation _service.sdl trust-boundary guard.
|
|
4
|
+
*
|
|
5
|
+
* Apollo Federation subgraphs expose the schema via _service.sdl
|
|
6
|
+
* which is independent of the introspection toggle — operators who
|
|
7
|
+
* disable introspection in production still leak the full SDL.
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
* graphqlFederation.guardSdl(opts) -> middleware
|
|
11
|
+
* graphqlFederation.queryProbesSdl(query) -> bool
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
var crypto = require("crypto");
|
|
15
|
+
var C = require("./constants");
|
|
16
|
+
var nb = require("./numeric-bounds");
|
|
17
|
+
var safeJson = require("./safe-json");
|
|
18
|
+
var safeBuffer = require("./safe-buffer");
|
|
19
|
+
var requestHelpers = require("./request-helpers");
|
|
20
|
+
var audit = require("./audit");
|
|
21
|
+
var { GraphqlFederationError } = require("./framework-error");
|
|
22
|
+
|
|
23
|
+
var SDL_PROBE_MAX = C.BYTES.kib(64);
|
|
24
|
+
var ROUTER_TOKEN_MIN_LEN = 32; // allow:raw-byte-literal — string-length floor for token entropy, not bytes
|
|
25
|
+
var NONCE_MIN_LEN = 16; // allow:raw-byte-literal — string-length floor for nonce entropy, not bytes
|
|
26
|
+
var NONCE_MAX_LEN = 256; // allow:raw-byte-literal — string-length cap, not bytes
|
|
27
|
+
var NONCE_PREVIEW_LEN = 8; // allow:raw-byte-literal — log-preview slice length, not bytes
|
|
28
|
+
var SDL_PROBE_RE = /(^|[\s,{])_service\b|_entities\b/;
|
|
29
|
+
|
|
30
|
+
function queryProbesSdl(query) {
|
|
31
|
+
if (typeof query !== "string") return false;
|
|
32
|
+
if (query.length > SDL_PROBE_MAX) return false; // length-bound before regex test
|
|
33
|
+
return SDL_PROBE_RE.test(query);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _readBearer(req) {
|
|
37
|
+
var h = req.headers && req.headers.authorization;
|
|
38
|
+
if (typeof h !== "string") return null;
|
|
39
|
+
if (h.length > C.BYTES.kib(8)) return null;
|
|
40
|
+
var m = /^Bearer\s+([A-Za-z0-9._~+/=-]+)$/.exec(h.trim());
|
|
41
|
+
return m ? m[1] : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function _timingSafeEqual(a, b) {
|
|
45
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
46
|
+
var ab = Buffer.from(a, "utf8");
|
|
47
|
+
var bb = Buffer.from(b, "utf8");
|
|
48
|
+
if (ab.length !== bb.length) return false;
|
|
49
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _readBody(req, errorClass) {
|
|
53
|
+
if (req.body !== undefined && req.body !== null) {
|
|
54
|
+
return Promise.resolve(req.body);
|
|
55
|
+
}
|
|
56
|
+
var cap = C.BYTES.mib(1);
|
|
57
|
+
return new Promise(function (resolve, reject) {
|
|
58
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: cap });
|
|
59
|
+
req.on("data", function (chunk) {
|
|
60
|
+
try { collector.push(chunk); }
|
|
61
|
+
catch (_e) {
|
|
62
|
+
req.destroy();
|
|
63
|
+
reject(errorClass.factory("BODY_TOO_LARGE",
|
|
64
|
+
"graphqlFederation: body exceeds " + cap + " bytes"));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
req.on("end", function () { resolve(collector.result().toString("utf8")); });
|
|
68
|
+
req.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function guardSdl(opts) {
|
|
73
|
+
opts = opts || {};
|
|
74
|
+
var errorClass = opts.errorClass || GraphqlFederationError;
|
|
75
|
+
var publicSchemaOk = opts.publicSchemaOk === true;
|
|
76
|
+
var routerToken = typeof opts.routerToken === "string" ? opts.routerToken : null;
|
|
77
|
+
if (!publicSchemaOk && (!routerToken || routerToken.length < ROUTER_TOKEN_MIN_LEN)) {
|
|
78
|
+
throw errorClass.factory("BAD_OPTS",
|
|
79
|
+
"graphqlFederation.guardSdl: routerToken (32+ char) required unless publicSchemaOk=true");
|
|
80
|
+
}
|
|
81
|
+
var nonceStore = opts.nonceStore && typeof opts.nonceStore.has === "function" &&
|
|
82
|
+
typeof opts.nonceStore.remember === "function" ? opts.nonceStore : null;
|
|
83
|
+
nb.requirePositiveFiniteIntIfPresent(opts.nonceTtlMs, "graphqlFederation.guardSdl: opts.nonceTtlMs", errorClass, "BAD_TTL");
|
|
84
|
+
var nonceTtlMs = opts.nonceTtlMs || C.TIME.minutes(5);
|
|
85
|
+
var auditOn = opts.audit !== false;
|
|
86
|
+
|
|
87
|
+
function _emitDenied(req, reason, metadata) {
|
|
88
|
+
if (!auditOn) return;
|
|
89
|
+
audit.safeEmit({
|
|
90
|
+
action: "graphqlfederation.sdl_refused",
|
|
91
|
+
outcome: "denied",
|
|
92
|
+
reason: reason,
|
|
93
|
+
metadata: Object.assign({
|
|
94
|
+
ip: requestHelpers.clientIp(req),
|
|
95
|
+
path: req && req.url,
|
|
96
|
+
}, metadata || {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _refuse(res, status, message) {
|
|
101
|
+
if (typeof res.setHeader === "function") {
|
|
102
|
+
res.setHeader("Content-Type", "application/json");
|
|
103
|
+
}
|
|
104
|
+
res.statusCode = status;
|
|
105
|
+
res.end(JSON.stringify({ errors: [{ message: message }] }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return function graphqlFedGuard(req, res, next) {
|
|
109
|
+
Promise.resolve().then(function () {
|
|
110
|
+
return _readBody(req, errorClass).then(function (rawBody) {
|
|
111
|
+
var query = null;
|
|
112
|
+
try {
|
|
113
|
+
var parsed = typeof rawBody === "string" ? safeJson.parse(rawBody, { maxBytes: C.BYTES.mib(1) }) : rawBody; // allow:JSON.parse — routed via safeJson.parse
|
|
114
|
+
query = parsed && typeof parsed === "object" ? parsed.query : null;
|
|
115
|
+
} catch (_e) { /* not JSON; pass through */ }
|
|
116
|
+
if (req.body === undefined) req.body = rawBody;
|
|
117
|
+
|
|
118
|
+
if (!queryProbesSdl(query)) {
|
|
119
|
+
if (typeof next === "function") next();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (publicSchemaOk) {
|
|
124
|
+
if (typeof next === "function") next();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var bearer = _readBearer(req);
|
|
129
|
+
if (!bearer || !_timingSafeEqual(bearer, routerToken)) {
|
|
130
|
+
_emitDenied(req, "missing or bad router-token", {});
|
|
131
|
+
return _refuse(res, 401, "graphql federation: router token required for _service / _entities");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (nonceStore) {
|
|
135
|
+
var nonce = req.headers && req.headers["x-apollographql-router-nonce"];
|
|
136
|
+
if (typeof nonce !== "string" || nonce.length < NONCE_MIN_LEN || nonce.length > NONCE_MAX_LEN) {
|
|
137
|
+
_emitDenied(req, "missing nonce", {});
|
|
138
|
+
return _refuse(res, 401, "graphql federation: nonce required");
|
|
139
|
+
}
|
|
140
|
+
return Promise.resolve(nonceStore.has(nonce)).then(function (seen) {
|
|
141
|
+
if (seen) {
|
|
142
|
+
_emitDenied(req, "nonce replay", { nonce: nonce.slice(0, NONCE_PREVIEW_LEN) + "..." });
|
|
143
|
+
return _refuse(res, 401, "graphql federation: nonce replay");
|
|
144
|
+
}
|
|
145
|
+
return Promise.resolve(nonceStore.remember(nonce, nonceTtlMs)).then(function () {
|
|
146
|
+
if (auditOn) {
|
|
147
|
+
audit.safeEmit({
|
|
148
|
+
action: "graphqlfederation.sdl_allowed",
|
|
149
|
+
outcome: "success",
|
|
150
|
+
metadata: {},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (typeof next === "function") next();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (auditOn) {
|
|
158
|
+
audit.safeEmit({
|
|
159
|
+
action: "graphqlfederation.sdl_allowed",
|
|
160
|
+
outcome: "success",
|
|
161
|
+
metadata: {},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (typeof next === "function") next();
|
|
165
|
+
});
|
|
166
|
+
}).catch(function (err) {
|
|
167
|
+
_emitDenied(req, "guard error: " + (err && err.message), {});
|
|
168
|
+
if (!res.writableEnded) _refuse(res, 500, "internal guard error");
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
guardSdl: guardSdl,
|
|
175
|
+
queryProbesSdl: queryProbesSdl,
|
|
176
|
+
};
|
package/lib/http-client.js
CHANGED
|
@@ -376,6 +376,7 @@ function _toH2Headers(method, u, headers) {
|
|
|
376
376
|
h2Headers[":path"] = u.pathname + (u.search || "");
|
|
377
377
|
h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
|
|
378
378
|
h2Headers[":authority"] = u.host;
|
|
379
|
+
var sawAcceptEncoding = false;
|
|
379
380
|
for (var k in headers) {
|
|
380
381
|
if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
|
|
381
382
|
var lk = k.toLowerCase();
|
|
@@ -383,8 +384,12 @@ function _toH2Headers(method, u, headers) {
|
|
|
383
384
|
if (lk === "connection" || lk === "host" ||
|
|
384
385
|
lk === "keep-alive" || lk === "transfer-encoding" ||
|
|
385
386
|
lk === "upgrade" || lk === "proxy-connection") continue;
|
|
387
|
+
if (lk === "accept-encoding") sawAcceptEncoding = true;
|
|
386
388
|
h2Headers[lk] = headers[k];
|
|
387
389
|
}
|
|
390
|
+
// CVE-2026-22036 mitigation — same identity default as the h1 path.
|
|
391
|
+
// Refuse compressed responses unless the operator explicitly opts in.
|
|
392
|
+
if (!sawAcceptEncoding) h2Headers["accept-encoding"] = "identity";
|
|
388
393
|
return h2Headers;
|
|
389
394
|
}
|
|
390
395
|
|
|
@@ -1020,6 +1025,17 @@ function _requestH1(transport, u, opts) {
|
|
|
1020
1025
|
if (Buffer.isBuffer(opts.body)) {
|
|
1021
1026
|
headers["Content-Length"] = opts.body.length;
|
|
1022
1027
|
}
|
|
1028
|
+
// CVE-2026-22036 mitigation — refuse compressed responses by
|
|
1029
|
+
// default. The framework's http-client returns raw bytes capped
|
|
1030
|
+
// at maxResponseBytes; if a server sends gzip/br/zstd the cap is
|
|
1031
|
+
// on-wire bytes only, and any operator-side decompression is the
|
|
1032
|
+
// operator's responsibility to bound. Identity by default closes
|
|
1033
|
+
// the decompression-bomb amplification class. Operators who DO
|
|
1034
|
+
// want compressed responses opt in by passing an explicit
|
|
1035
|
+
// Accept-Encoding header (lowercase or canonical form).
|
|
1036
|
+
if (!headers["Accept-Encoding"] && !headers["accept-encoding"]) {
|
|
1037
|
+
headers["Accept-Encoding"] = "identity";
|
|
1038
|
+
}
|
|
1023
1039
|
|
|
1024
1040
|
var reqOpts = {
|
|
1025
1041
|
method: method,
|