@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.
@@ -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
+ };
@@ -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
+ };
@@ -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,