mcp-google-multi 6.0.0-alpha.64 → 6.0.0-alpha.66

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.
@@ -1,5 +1,5 @@
1
1
  import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
- import { sliceClean } from './trim.js';
2
+ import { logSafe, sliceClean } from './trim.js';
3
3
  // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
4
  // the handler entirely: no handler of ours runs, so no envelope exists and the
5
5
  // client gets free text. The text is prose in both SDK eras (v1 prefixed it
@@ -115,8 +115,9 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
115
115
  if (screened.unknown.length === 0 && screened.redundant.length === 0)
116
116
  return { action: 'forward', msg };
117
117
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
- // Key names only; argument VALUES never reach the log.
119
- log(`[args] ${tool}: undeclared ${echoNames(all)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
118
+ // Key names only; argument VALUES never reach the log. The names are the
119
+ // caller's, so they are escaped here and only here: the envelope is JSON.
120
+ log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
121
  try {
121
122
  // Only ever a DECLARED key or the literal placeholder, so the metrics
122
123
  // closed-vocabulary rule holds: the caller's key is never persisted.
@@ -0,0 +1,38 @@
1
+ export declare const CIMD_TTL_MS: number;
2
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
3
+ export declare const CIMD_MAX_STALE_MS: number;
4
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
5
+ export declare const CIMD_MAX_IN_FLIGHT = 4;
6
+ export declare const CIMD_CACHE_MAX = 256;
7
+ export type CimdDocument = Record<string, unknown>;
8
+ type CimdError = {
9
+ error: 'E_CIMD_SSRF_BLOCKED' | 'E_CIMD_INVALID';
10
+ };
11
+ /** `fresh` is true only for the request whose fetch produced the document. */
12
+ export type CimdResolution = {
13
+ doc: CimdDocument;
14
+ fresh: boolean;
15
+ } | {
16
+ busy: true;
17
+ } | CimdError;
18
+ export interface CimdCacheDeps {
19
+ fetch: (clientId: string) => Promise<CimdDocument>;
20
+ now: () => number;
21
+ log: (line: string) => void;
22
+ }
23
+ export declare class CimdClientCache {
24
+ private readonly deps;
25
+ private readonly entries;
26
+ private readonly inFlight;
27
+ private busyLoggedAt;
28
+ private busySuppressed;
29
+ constructor(deps: CimdCacheDeps);
30
+ get size(): number;
31
+ /** `validate` runs once per fetch, in the request that started it, and every
32
+ * request waiting on that fetch gets the same answer. */
33
+ resolve(clientId: string, validate: (doc: CimdDocument) => boolean): Promise<CimdResolution>;
34
+ private fetchFor;
35
+ private store;
36
+ private noteBusy;
37
+ }
38
+ export {};
@@ -0,0 +1,117 @@
1
+ // H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
2
+ // issuer with a path never seen before forces an outbound fetch. So the
3
+ // fetches running at once are capped, one client_id is fetched once however
4
+ // many requests wait on it, and only a document that passed validation is
5
+ // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
+ // not help anyway: the attack varies the path, so its key never repeats.
7
+ import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
+ import { logSafe } from './trim.js';
9
+ export const CIMD_TTL_MS = 5 * 60_000;
10
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
11
+ export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
12
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
13
+ export const CIMD_MAX_IN_FLIGHT = 4;
14
+ export const CIMD_CACHE_MAX = 256;
15
+ const BUSY_LOG_EVERY_MS = 60_000;
16
+ export class CimdClientCache {
17
+ deps;
18
+ entries = new Map();
19
+ // One entry per running fetch, so its size is the number of permits taken.
20
+ inFlight = new Map();
21
+ busyLoggedAt = -Infinity;
22
+ busySuppressed = 0;
23
+ constructor(deps) {
24
+ this.deps = deps;
25
+ }
26
+ get size() {
27
+ return this.entries.size;
28
+ }
29
+ /** `validate` runs once per fetch, in the request that started it, and every
30
+ * request waiting on that fetch gets the same answer. */
31
+ async resolve(clientId, validate) {
32
+ const entry = this.entries.get(clientId);
33
+ const age = entry ? this.deps.now() - entry.at : 0;
34
+ if (entry && age < CIMD_TTL_MS)
35
+ return { doc: entry.doc, fresh: false };
36
+ if (entry && age >= CIMD_MAX_STALE_MS)
37
+ this.entries.delete(clientId);
38
+ const running = this.inFlight.get(clientId);
39
+ if (running) {
40
+ const out = await running;
41
+ return 'doc' in out ? { doc: out.doc, fresh: false } : out;
42
+ }
43
+ if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
44
+ // A flood of fresh paths is exactly when the cap is full, and a client
45
+ // that signed in before must still be able to.
46
+ const stale = this.entries.get(clientId);
47
+ if (stale)
48
+ return { doc: stale.doc, fresh: false };
49
+ this.noteBusy();
50
+ return { busy: true };
51
+ }
52
+ return this.fetchFor(clientId, validate);
53
+ }
54
+ async fetchFor(clientId, validate) {
55
+ // Never rejects: neither core nor a host installs an unhandledRejection
56
+ // handler, and a waiter that stopped listening must not take the process down.
57
+ let settle;
58
+ this.inFlight.set(clientId, new Promise((r) => (settle = r)));
59
+ let out = { error: 'E_CIMD_INVALID' };
60
+ try {
61
+ const doc = await this.deps.fetch(clientId);
62
+ if (validate(doc)) {
63
+ this.store(clientId, doc);
64
+ out = { doc, fresh: true };
65
+ }
66
+ else {
67
+ this.entries.delete(clientId);
68
+ }
69
+ }
70
+ catch (e) {
71
+ out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
+ // A document that went bad is never served stale. An issuer that is down
73
+ // or overloaded says nothing about the document, so the last good one
74
+ // stays and answers this fetch's requests too (stale-if-error; the error
75
+ // itself is never stored).
76
+ const stale = this.entries.get(clientId);
77
+ if (!saysNothingAboutDocument(e))
78
+ this.entries.delete(clientId);
79
+ else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
+ out = { doc: stale.doc, fresh: false };
81
+ // #13: the detail goes to the log only; the caller gets a generic answer.
82
+ this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
+ }
84
+ finally {
85
+ this.inFlight.delete(clientId);
86
+ settle(out);
87
+ }
88
+ return out;
89
+ }
90
+ store(clientId, doc) {
91
+ this.entries.delete(clientId);
92
+ while (this.entries.size >= CIMD_CACHE_MAX) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined)
95
+ break;
96
+ this.entries.delete(oldest);
97
+ }
98
+ this.entries.set(clientId, { doc, at: this.deps.now() });
99
+ }
100
+ // Over-cap requests arrive at whatever rate the caller likes.
101
+ noteBusy() {
102
+ const now = this.deps.now();
103
+ if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
104
+ this.busySuppressed++;
105
+ return;
106
+ }
107
+ const suppressed = this.busySuppressed;
108
+ this.busyLoggedAt = now;
109
+ this.busySuppressed = 0;
110
+ this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
111
+ }
112
+ }
113
+ function saysNothingAboutDocument(e) {
114
+ if (isTransientFetchError(e))
115
+ return true;
116
+ return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
117
+ }
@@ -2,6 +2,7 @@ import type { McpServer, Transport } from "@modelcontextprotocol/server";
2
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
3
3
  import type { HttpConfig } from './http-config.js';
4
4
  import { type ArgShape, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
5
+ export { logSafe } from './trim.js';
5
6
  export type AuthOutcome = {
6
7
  ok: true;
7
8
  sub?: string;
@@ -73,7 +74,9 @@ export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
73
74
  * connector calls /mcp server-to-server with no Origin, BV-4). */
74
75
  export declare function originAllowed(origin: string | undefined, allowed: string[]): boolean;
75
76
  export declare function hostAllowed(host: string | undefined, allowed: string[]): boolean;
76
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
77
+ /** JSON-RPC method name(s) for an observability log line: no params, no PII.
78
+ * The method is the caller's: anything not shaped like a method name, which
79
+ * includes a value whose String() throws, is logged as `<invalid>`. */
77
80
  export declare function jsonRpcMethod(body: unknown): string;
78
81
  export declare class HttpTransportHost {
79
82
  private readonly opts;
@@ -5,6 +5,9 @@ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
5
5
  // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
6
  import { createServer } from 'node:http';
7
7
  import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
8
+ import { logSafe } from './trim.js';
9
+ // A host wrapping the log callbacks it hands core needs this, and trim has no package export.
10
+ export { logSafe } from './trim.js';
8
11
  // A hung handler that keeps the connection open would otherwise hold the global
9
12
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
13
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
@@ -37,12 +40,25 @@ export function hostAllowed(host, allowed) {
37
40
  const bare = host.replace(/:\d+$/, '');
38
41
  return allowed.includes(host) || allowed.includes(bare);
39
42
  }
40
- /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
43
+ const METHOD_NAME = /^[A-Za-z0-9_./$-]{1,64}$/;
44
+ const BATCH_METHODS_SHOWN = 4;
45
+ /** JSON-RPC method name(s) for an observability log line: no params, no PII.
46
+ * The method is the caller's: anything not shaped like a method name, which
47
+ * includes a value whose String() throws, is logged as `<invalid>`. */
41
48
  export function jsonRpcMethod(body) {
42
- const one = (b) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
43
- if (Array.isArray(body))
44
- return body.map(one).filter(Boolean).join(',') || 'batch';
45
- return one(body) ?? 'unknown';
49
+ const one = (b) => {
50
+ if (!b || typeof b !== 'object' || !('method' in b))
51
+ return undefined;
52
+ const m = b.method;
53
+ return typeof m === 'string' && METHOD_NAME.test(m) ? m : '<invalid>';
54
+ };
55
+ if (!Array.isArray(body))
56
+ return one(body) ?? 'unknown';
57
+ const names = body.map(one).filter((n) => n !== undefined);
58
+ if (names.length === 0)
59
+ return 'batch';
60
+ const rest = names.length - BATCH_METHODS_SHOWN;
61
+ return rest > 0 ? `${names.slice(0, BATCH_METHODS_SHOWN).join(',')},+${rest}` : names.join(',');
46
62
  }
47
63
  /** setTimeout fires at once for a delay above this, so a longer grace would
48
64
  * cut every connection immediately. */
@@ -160,7 +176,7 @@ export class HttpTransportHost {
160
176
  finally {
161
177
  // A throwing log must not reach the destroy below: the answer is sent.
162
178
  try {
163
- this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? e.message : 'non-Error throw'}`);
179
+ this.log(`500 internal_error path=${pathOf(req)}: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
164
180
  }
165
181
  catch {
166
182
  // nowhere left to report it
@@ -292,12 +308,12 @@ export class HttpTransportHost {
292
308
  frontGuard(req, res) {
293
309
  const { allowedHosts, allowedOrigins } = this.opts.config;
294
310
  if (!hostAllowed(req.headers.host, allowedHosts)) {
295
- this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
311
+ this.log(`403 host_rejected host=${logSafe(req.headers.host ?? '', 64)}`);
296
312
  this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
297
313
  return false;
298
314
  }
299
315
  if (!originAllowed(req.headers.origin, allowedOrigins)) {
300
- this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
316
+ this.log(`403 origin_rejected origin=${logSafe(req.headers.origin ?? '', 64)}`);
301
317
  this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
302
318
  return false;
303
319
  }
@@ -1,7 +1,7 @@
1
1
  export declare const ACCESS_TTL_DEFAULT = 600;
2
2
  export declare const STATE_TTL_DEFAULT = 600;
3
3
  export declare const CODE_TTL_DEFAULT = 60;
4
- export declare const REPLAY_CAP_DEFAULT = 10000;
4
+ export declare const REPLAY_CAP_DEFAULT = 100000;
5
5
  /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
6
6
  export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
7
7
  export interface AccessTokenParams {
@@ -46,8 +46,9 @@ export interface CodePayload {
46
46
  sub: string;
47
47
  }
48
48
  export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
49
- export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
49
+ export declare function verifyState(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
50
50
  jti: string;
51
+ exp: number;
51
52
  }>;
52
53
  /** A re-auth link is handed out in a tool error, so it has to survive until
53
54
  * the user clicks it; an hour, not the 10-minute state TTL. */
@@ -64,21 +65,41 @@ export declare function verifyReauthLink(base: string, secret: Uint8Array, param
64
65
  /** A pending-authorization artifact for the DCR consent interstitial (same
65
66
  * shape as `state`, distinct purpose so the two can't be confused). */
66
67
  export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
67
- export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
68
+ export declare function verifyPending(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
68
69
  jti: string;
70
+ exp: number;
69
71
  }>;
70
72
  export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
71
- export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
73
+ export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<CodePayload & {
72
74
  jti: string;
75
+ exp: number;
73
76
  }>;
77
+ /** `full`: there is no room without forgetting a jti that is still live. */
78
+ export type ReplaySpend = 'ok' | 'replay' | 'full';
74
79
  export declare class ReplayGuard {
75
80
  private readonly cap;
76
81
  private readonly seen;
82
+ private earliest;
83
+ private sweptAt;
77
84
  constructor(cap?: number);
78
- /** Record a jti as spent. Returns false if it was already spent (replay). */
85
+ /** Hold a slot for an artifact about to be handed out, so its spend can
86
+ * never meet a full guard. The flood then lands where the artifact is
87
+ * minted, and refusing there loses nothing. */
88
+ reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
89
+ /** Record a jti as spent. A full guard refuses instead of evicting a live
90
+ * jti: a flood of spends would otherwise make a spent artifact usable again,
91
+ * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
92
+ * keeps the record alive as long as the artifact itself, even when the clock
93
+ * stepped back between its signing and this spend. */
94
+ spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
95
+ /** Undo a spend whose next step could not go ahead. The slot stays held, so
96
+ * nothing is forgotten, and the artifact can be spent once more. */
97
+ unspend(jti: string): void;
98
+ /** `spend` as a boolean: false for a replay and for a full guard alike. */
79
99
  consume(jti: string, ttlMs: number, nowMs: number): boolean;
80
100
  get size(): number;
81
- private evictExpired;
101
+ private record;
102
+ private room;
82
103
  }
83
104
  /** An earlier release's record, keyed by its live token. */
84
105
  export interface RefreshRecord {
package/dist/mcp-token.js CHANGED
@@ -12,7 +12,7 @@ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
12
12
  export const ACCESS_TTL_DEFAULT = 600; // seconds
13
13
  export const STATE_TTL_DEFAULT = 600;
14
14
  export const CODE_TTL_DEFAULT = 60;
15
- export const REPLAY_CAP_DEFAULT = 10_000;
15
+ export const REPLAY_CAP_DEFAULT = 100_000;
16
16
  /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
17
17
  export function jwtSecretFrom(jwtKey) {
18
18
  return new Uint8Array(deriveKey(jwtKey));
@@ -52,17 +52,19 @@ async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
52
52
  .setJti(newJti())
53
53
  .sign(secret);
54
54
  }
55
- async function verifyArtifact(token, purpose, base, secret) {
56
- const { payload } = await jwtVerify(token, secret, { issuer: base });
55
+ // `nowSec`: the caller's clock, so expiry is judged by the same clock as the
56
+ // replay guard that records the artifact's spend.
57
+ async function verifyArtifact(token, purpose, base, secret, nowSec) {
58
+ const { payload } = await jwtVerify(token, secret, { issuer: base, ...(nowSec !== undefined ? { currentDate: new Date(nowSec * 1000) } : {}) });
57
59
  if (payload.purpose !== purpose)
58
60
  throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
59
- return { ...payload, jti: String(payload.jti ?? '') };
61
+ return { ...payload, jti: String(payload.jti ?? ''), exp: Number(payload.exp ?? 0) };
60
62
  }
61
63
  export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
62
64
  return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
63
65
  }
64
- export async function verifyState(token, base, secret) {
65
- return (await verifyArtifact(token, 'mcp_state', base, secret));
66
+ export async function verifyState(token, base, secret, nowSec) {
67
+ return (await verifyArtifact(token, 'mcp_state', base, secret, nowSec));
66
68
  }
67
69
  // --- alias_reauth link -------------------------------------------------------
68
70
  /** A re-auth link is handed out in a tool error, so it has to survive until
@@ -93,44 +95,97 @@ export function verifyReauthLink(base, secret, params, nowSec) {
93
95
  export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
94
96
  return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
95
97
  }
96
- export async function verifyPending(token, base, secret) {
97
- return (await verifyArtifact(token, 'mcp_pending', base, secret));
98
+ export async function verifyPending(token, base, secret, nowSec) {
99
+ return (await verifyArtifact(token, 'mcp_pending', base, secret, nowSec));
98
100
  }
99
101
  export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
100
102
  return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
101
103
  }
102
- export async function verifyAuthzCode(token, base, secret) {
103
- return (await verifyArtifact(token, 'mcp_code', base, secret));
104
+ export async function verifyAuthzCode(token, base, secret, nowSec) {
105
+ return (await verifyArtifact(token, 'mcp_code', base, secret, nowSec));
104
106
  }
105
- // --- Replay guard (C10/C17: single-use, capped, TTL-evicted) ----------------
107
+ /** A full guard refuses rather than sweeping again sooner than this. */
108
+ const FULL_SWEEP_EVERY_MS = 1000;
106
109
  export class ReplayGuard {
107
110
  cap;
108
- seen = new Map(); // jti -> expiry (ms)
111
+ seen = new Map();
112
+ // A lower bound on every entry's expiry: below it nothing can have expired.
113
+ earliest = Infinity;
114
+ sweptAt = -Infinity;
109
115
  constructor(cap = REPLAY_CAP_DEFAULT) {
110
116
  this.cap = cap;
111
117
  }
112
- /** Record a jti as spent. Returns false if it was already spent (replay). */
113
- consume(jti, ttlMs, nowMs) {
114
- this.evictExpired(nowMs);
115
- if (this.seen.has(jti))
116
- return false;
117
- if (this.seen.size >= this.cap) {
118
- // drop the oldest-inserted entry to bound memory (C17)
119
- const oldest = this.seen.keys().next().value;
120
- if (oldest !== undefined)
121
- this.seen.delete(oldest);
118
+ /** Hold a slot for an artifact about to be handed out, so its spend can
119
+ * never meet a full guard. The flood then lands where the artifact is
120
+ * minted, and refusing there loses nothing. */
121
+ reserve(jti, ttlMs, nowMs) {
122
+ if (!this.room(nowMs))
123
+ return 'full';
124
+ this.record(jti, nowMs + ttlMs, false);
125
+ return 'ok';
126
+ }
127
+ /** Record a jti as spent. A full guard refuses instead of evicting a live
128
+ * jti: a flood of spends would otherwise make a spent artifact usable again,
129
+ * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
130
+ * keeps the record alive as long as the artifact itself, even when the clock
131
+ * stepped back between its signing and this spend. */
132
+ spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
133
+ const exp = Math.max(nowMs + ttlMs, artifactExpMs);
134
+ const held = this.seen.get(jti);
135
+ if (held && held.exp > nowMs) {
136
+ if (held.spent)
137
+ return 'replay';
138
+ held.spent = true;
139
+ held.exp = Math.max(held.exp, exp);
140
+ return 'ok';
122
141
  }
123
- this.seen.set(jti, nowMs + ttlMs);
124
- return true;
142
+ if (held)
143
+ this.seen.delete(jti);
144
+ if (!this.room(nowMs))
145
+ return 'full';
146
+ this.record(jti, exp, true);
147
+ return 'ok';
148
+ }
149
+ /** Undo a spend whose next step could not go ahead. The slot stays held, so
150
+ * nothing is forgotten, and the artifact can be spent once more. */
151
+ unspend(jti) {
152
+ const held = this.seen.get(jti);
153
+ if (held)
154
+ held.spent = false;
155
+ }
156
+ /** `spend` as a boolean: false for a replay and for a full guard alike. */
157
+ consume(jti, ttlMs, nowMs) {
158
+ return this.spend(jti, ttlMs, nowMs) === 'ok';
125
159
  }
126
160
  get size() {
127
161
  return this.seen.size;
128
162
  }
129
- evictExpired(nowMs) {
130
- for (const [jti, exp] of this.seen) {
131
- if (exp <= nowMs)
163
+ record(jti, exp, spent) {
164
+ this.seen.set(jti, { exp, spent });
165
+ if (exp < this.earliest)
166
+ this.earliest = exp;
167
+ }
168
+ // Expired entries are dropped only here, when the map is at the cap: a refusal
169
+ // costs O(1), and a sweep runs only once something can have expired, at most
170
+ // once a second. Walking the map from its head on every call is not cheap: a
171
+ // Map iterator steps over every deleted slot until the table is rebuilt.
172
+ room(nowMs) {
173
+ if (this.seen.size < this.cap)
174
+ return true;
175
+ if (nowMs < this.earliest)
176
+ return false;
177
+ if (nowMs >= this.sweptAt && nowMs - this.sweptAt < FULL_SWEEP_EVERY_MS)
178
+ return false;
179
+ this.sweptAt = nowMs;
180
+ let earliest = Infinity;
181
+ for (const [jti, e] of this.seen) {
182
+ if (e.exp <= nowMs)
132
183
  this.seen.delete(jti);
184
+ else if (e.exp < earliest)
185
+ earliest = e.exp;
133
186
  }
187
+ this.earliest = earliest;
188
+ return this.seen.size < this.cap;
134
189
  }
135
190
  }
136
191
  /** Store files this release writes; an absent `format` is an earlier release's. */
@@ -122,7 +122,9 @@ export interface AuthServer {
122
122
  /** Mint a signed, single-use, TTL-bound clientless flow URL
123
123
  * (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
124
124
  * INSIDE the signed state, so nothing at /authorize or /callback trusts a
125
- * caller-supplied identity. Inert unless something calls it. */
125
+ * caller-supplied identity. The URL works only on this AuthServer, and only
126
+ * until its process restarts: sign-in artifacts are signed with a key each
127
+ * buildAuthServer call generates. Inert unless something calls it. */
126
128
  mintFlowState: (opts: {
127
129
  flow: 'alias_add';
128
130
  alias: string;
package/dist/oauth-as.js CHANGED
@@ -7,8 +7,11 @@
7
7
  // whole surface is exercised by an offline harness.
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { isIPv4 } from 'node:net';
10
+ import { decodeJwt } from 'jose';
10
11
  import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
11
- import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
12
+ import { fetchCimdDocument } from './ssrf-guard.js';
13
+ import { CimdClientCache } from './cimd-cache.js';
14
+ import { logSafe } from './trim.js';
12
15
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
16
  // DCR (/register) is public + unauthenticated, so its in-memory store must be
14
17
  // bounded like every sibling store (the ReplayGuard cap) or a request loop
@@ -51,8 +54,8 @@ function json(res, status, body, extraHeaders = {}) {
51
54
  res.end(JSON.stringify(body));
52
55
  return true;
53
56
  }
54
- function errorPage(res, status, slug, message) {
55
- res.writeHead(status, { 'Content-Type': 'text/plain' });
57
+ function errorPage(res, status, slug, message, extraHeaders = {}) {
58
+ res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
56
59
  res.end(`${slug}: ${message}`);
57
60
  return true;
58
61
  }
@@ -61,6 +64,14 @@ class SubjectCheckUnavailable extends Error {
61
64
  function subjectUnavailable(res) {
62
65
  return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
63
66
  }
67
+ // Not invalid_client: clients and people read that as a permanent misconfiguration.
68
+ function clientUnavailable(res) {
69
+ return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
70
+ }
71
+ // The replay guard is full: every slot holds a jti that is still live.
72
+ function signInBusy(res) {
73
+ return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
74
+ }
64
75
  function redirect(res, location, extraHeaders = {}) {
65
76
  res.writeHead(302, { Location: location, ...extraHeaders });
66
77
  res.end();
@@ -177,6 +188,11 @@ export function buildAuthServer(config, deps = {}) {
177
188
  const base = config.base;
178
189
  const resource = config.resourceUri;
179
190
  const secret = config.secret;
191
+ // States, approvals and codes live minutes, and their single use rests on an
192
+ // in-memory replay guard that a restart empties. So they are signed with a
193
+ // key only this AuthServer holds: a restart invalidates every one in flight,
194
+ // instead of letting a spent or unreserved one pass the new, empty guard.
195
+ const flowSecret = new Uint8Array(randomBytes(32));
180
196
  const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
181
197
  const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
182
198
  const replay = deps.replayGuard ?? new ReplayGuard();
@@ -189,6 +205,26 @@ export function buildAuthServer(config, deps = {}) {
189
205
  }
190
206
  const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
191
207
  const nowSec = () => Math.floor(now() / 1000);
208
+ // A full guard is refused at whatever rate the caller likes, so its line is
209
+ // written at most once a minute (or at once after the clock stepped back).
210
+ let fullLoggedAt = -Infinity;
211
+ let fullSuppressed = 0;
212
+ const noteFull = (at) => {
213
+ if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
214
+ fullSuppressed++;
215
+ return;
216
+ }
217
+ log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
218
+ fullLoggedAt = at;
219
+ fullSuppressed = 0;
220
+ };
221
+ const spend = (artifact, ttlSec) => {
222
+ const at = now();
223
+ const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
224
+ if (r === 'full')
225
+ noteFull(at);
226
+ return r;
227
+ };
192
228
  const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
193
229
  const issHeader = { 'Cache-Control': 'no-store' };
194
230
  const prm = {
@@ -210,8 +246,7 @@ export function buildAuthServer(config, deps = {}) {
210
246
  authorization_response_iss_parameter_supported: true,
211
247
  scopes_supported: ['mcp:use'],
212
248
  };
213
- const cimdCache = new Map();
214
- const CIMD_CACHE_TTL_MS = 5 * 60_000;
249
+ const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
215
250
  async function validateClient(clientId) {
216
251
  const reg = registered.get(clientId);
217
252
  if (reg)
@@ -229,47 +264,36 @@ export function buildAuthServer(config, deps = {}) {
229
264
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
230
265
  }
231
266
  if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
232
- log(`CIMD rejected: client_id ${clientId} issuer not allowlisted`);
267
+ // The issuer and a length, never the path: an attacker picks the path freely.
268
+ log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
233
269
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
234
270
  }
235
- let doc;
236
- const cached = cimdCache.get(clientId);
237
- if (cached && cached.exp > now()) {
238
- doc = cached.doc;
239
- }
240
- else {
241
- try {
242
- doc = await cimd(clientId);
243
- }
244
- catch (e) {
245
- // #13: never echo internal fetch details to the caller (blind-SSRF
246
- // oracle); log server-side, return a generic message.
247
- log(`CIMD fetch failed for ${clientId}: ${e.message}`);
248
- const slug = e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID';
249
- return { error: slug, message: 'invalid client' };
250
- }
251
- cimdCache.set(clientId, { doc, exp: now() + CIMD_CACHE_TTL_MS }); // #15
252
- }
253
- if (doc.client_id !== clientId)
254
- return { error: 'E_CIMD_INVALID', message: 'invalid client' };
255
- const uris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris : [];
256
- if (uris.length === 0)
257
- return { error: 'E_CIMD_INVALID', message: 'invalid client' };
258
- // C6 #14: warn on a localhost-only redirect set (loopback impersonation).
259
- if (uris.every((u) => { try {
271
+ // #15: cached only once it validates; the cache logs a failed fetch.
272
+ const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
273
+ if ('busy' in got)
274
+ return got;
275
+ // #13: never echo internal fetch details to the caller (blind-SSRF oracle).
276
+ if ('error' in got)
277
+ return { error: got.error, message: 'invalid client' };
278
+ const uris = got.doc.redirect_uris;
279
+ // C6 #14: warn on a localhost-only redirect set (loopback impersonation),
280
+ // once per fetch rather than on every sign-in the cache serves.
281
+ if (got.fresh && uris.every((u) => { try {
260
282
  return isLoopbackHostname(new URL(u).hostname);
261
283
  }
262
284
  catch {
263
285
  return false;
264
286
  } })) {
265
- log(`client ${clientId} advertises only loopback redirect_uris`);
287
+ log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
266
288
  }
267
289
  return { redirect_uris: uris, dcr: false };
268
290
  }
269
291
  function escapeHtml(s) {
270
292
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
271
293
  }
272
- async function toGoogle(res, sp) {
294
+ /** `incoming`: the artifact spent to get here. If no slot is left for the
295
+ * new state it is unspent, so retrying it works once a slot frees. */
296
+ async function toGoogle(res, sp, incoming) {
273
297
  // H4 F1: without this, a Google URL minted here and forwarded to someone
274
298
  // else signs THEM in and hands their subject to the starting client.
275
299
  const headers = {};
@@ -279,7 +303,16 @@ export function buildAuthServer(config, deps = {}) {
279
303
  sp = { ...sp, bind };
280
304
  headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
281
305
  }
282
- const state = await signState(sp, base, secret, nowSec());
306
+ const state = await signState(sp, base, flowSecret, nowSec());
307
+ // Its /callback must find a slot even while a flood fills the guard, or a
308
+ // refusal there (the browser binding) could not be recorded.
309
+ const at = now();
310
+ if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
311
+ if (incoming)
312
+ replay.unspend(incoming);
313
+ noteFull(at);
314
+ return signInBusy(res);
315
+ }
283
316
  const authUrl = deps.buildGoogleAuthUrl
284
317
  ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
285
318
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
@@ -298,22 +331,25 @@ export function buildAuthServer(config, deps = {}) {
298
331
  }
299
332
  let pending;
300
333
  try {
301
- pending = await verifyPending(form.pending ?? '', base, secret);
334
+ pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
302
335
  }
303
336
  catch {
304
337
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
305
338
  }
306
- // Only the client leg signs pendings, and it is always the owner sign-in;
307
- // a pending signed before that rule (flow=alias_reauth) is refused here.
339
+ // Only the client leg signs pendings, and it is always the owner sign-in.
340
+ // Defence in depth: a pending from an earlier release, which could carry
341
+ // flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
308
342
  if (pending.flow !== 'owner_gate') {
309
343
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
310
344
  }
311
- if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
345
+ const spent = spend(pending, STATE_TTL_DEFAULT);
346
+ if (spent === 'full')
347
+ return signInBusy(res);
348
+ if (spent === 'replay')
312
349
  return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
313
- }
314
350
  const { jti: _drop, ...sp } = pending;
315
351
  void _drop;
316
- return toGoogle(res, sp);
352
+ return toGoogle(res, sp, pending.jti);
317
353
  }
318
354
  const q = url.searchParams;
319
355
  const clientId = q.get('client_id') ?? '';
@@ -330,7 +366,7 @@ export function buildAuthServer(config, deps = {}) {
330
366
  if (q.get('flow') === 'alias_add' && !clientId) {
331
367
  let minted;
332
368
  try {
333
- minted = await verifyState(q.get('state') ?? '', base, secret);
369
+ minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
334
370
  }
335
371
  catch {
336
372
  return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
@@ -338,12 +374,14 @@ export function buildAuthServer(config, deps = {}) {
338
374
  if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
339
375
  return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
340
376
  }
341
- if (!replay.consume(minted.jti, STATE_TTL_DEFAULT * 1000, now())) {
377
+ const spent = spend(minted, STATE_TTL_DEFAULT);
378
+ if (spent === 'full')
379
+ return signInBusy(res);
380
+ if (spent === 'replay')
342
381
  return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
343
- }
344
382
  const { jti: _spent, ...sp } = minted;
345
383
  void _spent;
346
- return toGoogle(res, sp);
384
+ return toGoogle(res, sp, minted.jti);
347
385
  }
348
386
  // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
349
387
  // client leg — no code is delivered to any client, the refreshed token is
@@ -366,6 +404,8 @@ export function buildAuthServer(config, deps = {}) {
366
404
  if (!clientId)
367
405
  return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
368
406
  const client = await validateClient(clientId);
407
+ if ('busy' in client)
408
+ return clientUnavailable(res);
369
409
  if ('error' in client)
370
410
  return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
371
411
  // (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
@@ -399,7 +439,7 @@ export function buildAuthServer(config, deps = {}) {
399
439
  // it — their redirect_uris come from an issuer-allowlisted document a rogue
400
440
  // cannot forge, so there is nothing to spoof.
401
441
  if (client.dcr) {
402
- const pendingToken = await signPending(statePayload, base, secret, nowSec());
442
+ const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
403
443
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
404
444
  res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
405
445
  `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
@@ -419,14 +459,16 @@ export function buildAuthServer(config, deps = {}) {
419
459
  const stateToken = url.searchParams.get('state') ?? '';
420
460
  let st;
421
461
  try {
422
- st = await verifyState(stateToken, base, secret);
462
+ st = await verifyState(stateToken, base, flowSecret, nowSec());
423
463
  }
424
464
  catch {
425
465
  return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
426
466
  }
427
- if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
467
+ const spent = spend(st, STATE_TTL_DEFAULT);
468
+ if (spent === 'full')
469
+ return signInBusy(res);
470
+ if (spent === 'replay')
428
471
  return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
429
- }
430
472
  // After the spend, so a refused callback URL cannot be replayed from the
431
473
  // browser that holds the cookie; before the exchange and resolveSubject.
432
474
  if (st.flow === 'owner_gate') {
@@ -446,7 +488,7 @@ export function buildAuthServer(config, deps = {}) {
446
488
  catch (e) {
447
489
  // The detail can name proxies and hosts; it goes to the log, not the browser.
448
490
  const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
449
- log(`callback exchange failed flow=${st.flow}: ${detail.replace(/[\r\n]+/g, ' ')}`);
491
+ log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
450
492
  return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
451
493
  }
452
494
  if (st.flow === 'alias_add') {
@@ -464,17 +506,17 @@ export function buildAuthServer(config, deps = {}) {
464
506
  }
465
507
  catch (e) {
466
508
  // Any rejection reason, even none, must still reach this answer.
467
- log(`alias_add bind failed for "${st.alias}": ${e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection'}`);
509
+ log(`alias_add bind failed for "${logSafe(st.alias, 64)}": ${logSafe(e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection', 200)}`);
468
510
  return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
469
511
  }
470
512
  if (refusal) {
471
513
  // The slug lands in a text/plain body; keep it a bare identifier.
472
514
  const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
473
515
  const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
474
- log(`alias_add refused for "${st.alias}": ${slug}`);
516
+ log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
475
517
  return errorPage(res, 403, slug, message);
476
518
  }
477
- log(`callback ok flow=alias_add alias=${st.alias}`);
519
+ log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
478
520
  res.writeHead(200, { 'Content-Type': 'text/html' });
479
521
  res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
480
522
  return true;
@@ -490,7 +532,7 @@ export function buildAuthServer(config, deps = {}) {
490
532
  const gotEmail = (exchanged.email ?? '').toLowerCase();
491
533
  const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
492
534
  if (!expected || !gotEmail || gotEmail !== expected) {
493
- log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
535
+ log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
494
536
  return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
495
537
  }
496
538
  // A completion without a refresh token (a Google URL built without
@@ -498,7 +540,7 @@ export function buildAuthServer(config, deps = {}) {
498
540
  // dies within the hour. Keep the stored token instead.
499
541
  const refreshToken = exchanged.tokens.refresh_token;
500
542
  if (typeof refreshToken !== 'string' || refreshToken === '') {
501
- log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
543
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
502
544
  return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
503
545
  }
504
546
  const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
@@ -508,13 +550,13 @@ export function buildAuthServer(config, deps = {}) {
508
550
  // for an hour and the Google URL's scope is not signed, so a narrower
509
551
  // grant is not necessarily the account holder's choice.
510
552
  if (missing.length > 0 && deps.hasToken?.(st.alias)) {
511
- log(`alias_reauth for "${st.alias}" granted ${missing.length} fewer scope(s); stored token kept`);
553
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
512
554
  res.writeHead(400, { 'Content-Type': 'text/html' });
513
555
  res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
514
556
  return true;
515
557
  }
516
558
  deps.writeToken?.(st.alias, exchanged.tokens);
517
- log(`callback ok flow=alias_reauth alias=${st.alias}`);
559
+ log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
518
560
  // With no stored token a partial grant still beats none; say what is missing.
519
561
  const gap = missing.length
520
562
  ? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
@@ -534,7 +576,7 @@ export function buildAuthServer(config, deps = {}) {
534
576
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
535
577
  return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
536
578
  }
537
- const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, secret, nowSec());
579
+ const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
538
580
  log('callback ok flow=owner_gate');
539
581
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
540
582
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
@@ -553,14 +595,16 @@ export function buildAuthServer(config, deps = {}) {
553
595
  if (grant === 'authorization_code') {
554
596
  let code;
555
597
  try {
556
- code = await verifyAuthzCode(form.code ?? '', base, secret);
598
+ code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
557
599
  }
558
600
  catch {
559
601
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
560
602
  }
561
- if (!replay.consume(code.jti, CODE_TTL_DEFAULT * 1000, now())) {
603
+ const spent = spend(code, CODE_TTL_DEFAULT);
604
+ if (spent === 'full')
605
+ return subjectUnavailable(res);
606
+ if (spent === 'replay')
562
607
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
563
- }
564
608
  if (form.redirect_uri !== code.redirect_uri) {
565
609
  return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
566
610
  }
@@ -577,7 +621,9 @@ export function buildAuthServer(config, deps = {}) {
577
621
  active = deps.subjectActive(code.sub);
578
622
  }
579
623
  catch (e) {
580
- log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? e.message : 'non-Error throw'}`);
624
+ log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
625
+ // Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
626
+ replay.unspend(code.jti);
581
627
  return subjectUnavailable(res);
582
628
  }
583
629
  if (!active) {
@@ -612,7 +658,7 @@ export function buildAuthServer(config, deps = {}) {
612
658
  catch (e) {
613
659
  if (!(e instanceof SubjectCheckUnavailable))
614
660
  throw e;
615
- log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? e.cause.message : 'non-Error throw'}`);
661
+ log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
616
662
  return subjectUnavailable(res);
617
663
  }
618
664
  if (!next)
@@ -633,9 +679,18 @@ export function buildAuthServer(config, deps = {}) {
633
679
  catch {
634
680
  return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
635
681
  }
636
- const redirectUris = Array.isArray(parsed.redirect_uris) ? parsed.redirect_uris.map(String) : [];
682
+ // Shape first: a throw here is an unauthenticated 500 per request, and a
683
+ // `null` body has no properties while String() throws on {"toString":1}.
684
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
685
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
686
+ }
687
+ const raw = parsed.redirect_uris;
688
+ const redirectUris = Array.isArray(raw) ? raw : [];
637
689
  if (redirectUris.length === 0)
638
690
  return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
691
+ if (!redirectUris.every((u) => typeof u === 'string')) {
692
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
693
+ }
639
694
  if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
640
695
  return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
641
696
  }
@@ -681,7 +736,7 @@ export function buildAuthServer(config, deps = {}) {
681
736
  // here would be a state no route verifies the way its flow needs.
682
737
  if (flow !== 'alias_add')
683
738
  throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
684
- const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
739
+ const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
685
740
  return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
686
741
  };
687
742
  const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
@@ -1,6 +1,12 @@
1
1
  export declare class SsrfBlockedError extends Error {
2
2
  constructor(message: string);
3
3
  }
4
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
5
+ * overloaded for now from one that says the document is gone. */
6
+ export declare class CimdHttpError extends SsrfBlockedError {
7
+ readonly status: number;
8
+ constructor(status: number);
9
+ }
4
10
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
5
11
  export declare function isBlockedIPv4(ip: string): boolean;
6
12
  /** True if an IPv6 literal is blocked. */
@@ -10,28 +16,35 @@ export interface SsrfDeps {
10
16
  /** Injectable resolver (defaults to dns.lookup all-addresses) for tests. */
11
17
  resolveAll?: (host: string) => Promise<string[]>;
12
18
  }
19
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
20
+ export declare const MAX_UNSETTLED_LOOKUPS = 4;
13
21
  /**
14
22
  * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
15
- * the host resolves to is public. Throws SsrfBlockedError otherwise, and
16
- * returns the resolved IPs.
23
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
24
+ * that a resolver error with a transient code (a lookup that could not answer
25
+ * this time) is rethrown as is. Returns the resolved IPs.
17
26
  *
18
27
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
19
28
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
20
29
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
21
30
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
22
31
  * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
23
- * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
24
- * this public-range check is defense-in-depth on top of that.
32
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
33
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
34
+ * public-range check is defense-in-depth on top of that.
25
35
  */
26
36
  export declare function assertPublicHttpsUrl(rawUrl: string, deps?: SsrfDeps): Promise<{
27
37
  url: URL;
28
38
  addresses: string[];
29
39
  }>;
30
40
  export interface CimdFetchOptions {
41
+ /** Default 5120: CIMD section 8.7 recommends at most 5 KB. */
31
42
  maxBytes?: number;
43
+ /** One attempt's cap, never past what is left of deadlineMs (default 5000). */
32
44
  timeoutMs?: number;
33
- maxRedirects?: number;
34
- /** Transient-network-error retries per hop (default 2 → up to 3 attempts). */
45
+ /** One budget for every attempt, DNS lookups and backoff included (default 8000). */
46
+ deadlineMs?: number;
47
+ /** Transient-network-error retries (default 1, so at most 2 attempts). */
35
48
  retries?: number;
36
49
  /** Base backoff between retries in ms; grows linearly per attempt (default 200). */
37
50
  retryBackoffMs?: number;
@@ -40,9 +53,10 @@ export interface CimdFetchOptions {
40
53
  sleepImpl?: (ms: number) => Promise<void>;
41
54
  ssrf?: SsrfDeps;
42
55
  }
56
+ export declare function isTransientFetchError(e: unknown): boolean;
43
57
  /**
44
- * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
45
- * (including redirects) is HTTPS + public-IP checked; response is size- and
46
- * time-capped. Returns the parsed JSON object.
58
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
59
+ * public-IP checked, no redirect followed, size-capped, and every attempt
60
+ * inside one deadline. Returns the parsed JSON object.
47
61
  */
48
62
  export declare function fetchCimdDocument(rawUrl: string, opts?: CimdFetchOptions): Promise<Record<string, unknown>>;
@@ -1,8 +1,9 @@
1
1
  // B13 / C11: SSRF guard for every server-side fetch (the CIMD client_id doc).
2
2
  // D4 decision (dependencies-and-bv §2.2): a small AUDITED private-range check,
3
3
  // no dependency — HTTPS-only, resolve-then-check ALL addresses (anti-rebind),
4
- // and cap redirects / size / time. Blocks 10/8, 172.16/12, 192.168/16, 127/8,
5
- // 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and v4-mapped forms.
4
+ // no redirects followed, and capped size and time. Blocks 10/8, 172.16/12,
5
+ // 192.168/16, 127/8, 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
6
+ // v4-mapped forms.
6
7
  import { lookup } from 'node:dns/promises';
7
8
  import { isIPv4, isIPv6, BlockList } from 'node:net';
8
9
  export class SsrfBlockedError extends Error {
@@ -11,6 +12,16 @@ export class SsrfBlockedError extends Error {
11
12
  this.name = 'SsrfBlockedError';
12
13
  }
13
14
  }
15
+ /** A non-2xx answer, kept apart so the cache can tell an issuer that is
16
+ * overloaded for now from one that says the document is gone. */
17
+ export class CimdHttpError extends SsrfBlockedError {
18
+ status;
19
+ constructor(status) {
20
+ super(`CIMD fetch failed: HTTP ${status}`);
21
+ this.name = 'CimdHttpError';
22
+ this.status = status;
23
+ }
24
+ }
14
25
  /** True if an IPv4 literal is in a blocked (private/link-local/loopback/etc) range. */
15
26
  export function isBlockedIPv4(ip) {
16
27
  const parts = ip.split('.').map(Number);
@@ -61,25 +72,45 @@ export function isBlockedIp(ip) {
61
72
  return isBlockedIPv6(ip);
62
73
  return true; // unparseable → block
63
74
  }
75
+ /** The most lookups started here that may be unsettled at once, abandoned ones included; process-wide, as the libuv threadpool is. */
76
+ export const MAX_UNSETTLED_LOOKUPS = 4;
77
+ let unsettledLookups = 0;
78
+ /** The lookup cap refused to start one: transient for the cache, but never retried, since the hung lookups it waits on outlast any backoff. */
79
+ class LookupBusyError extends Error {
80
+ code = 'EAI_AGAIN';
81
+ }
64
82
  async function defaultResolveAll(host) {
65
83
  // A bare IP literal needs no DNS; check it directly.
66
84
  if (isIPv4(host) || isIPv6(host))
67
85
  return [host];
68
- const results = await lookup(host, { all: true });
69
- return results.map((r) => r.address);
86
+ // getaddrinfo keeps its thread until it returns, even once no attempt waits
87
+ // on it, so a new lookup past the cap would only queue behind the hung ones.
88
+ if (unsettledLookups >= MAX_UNSETTLED_LOOKUPS) {
89
+ throw new LookupBusyError(`DNS lookup for ${host} not started: ${MAX_UNSETTLED_LOOKUPS} earlier lookups have not returned`);
90
+ }
91
+ unsettledLookups++;
92
+ try {
93
+ const results = await lookup(host, { all: true });
94
+ return results.map((r) => r.address);
95
+ }
96
+ finally {
97
+ unsettledLookups--;
98
+ }
70
99
  }
71
100
  /**
72
101
  * Assert a URL is safe to fetch server-side: HTTPS scheme, and every address
73
- * the host resolves to is public. Throws SsrfBlockedError otherwise, and
74
- * returns the resolved IPs.
102
+ * the host resolves to is public. Throws SsrfBlockedError otherwise, except
103
+ * that a resolver error with a transient code (a lookup that could not answer
104
+ * this time) is rethrown as is. Returns the resolved IPs.
75
105
  *
76
106
  * NOTE: this checks the host's addresses but does NOT pin them — the caller
77
107
  * fetches by hostname, so undici re-resolves at connect time (a check-time vs
78
108
  * connect-time TOCTOU / short-TTL rebind window). We deliberately do not pin
79
109
  * (CDN IPs rotate). The real anti-SSRF control is the caller-side issuer
80
110
  * allowlist (oauth-as validateClient rejects any non-allowlisted host BEFORE
81
- * fetching), so an attacker can't steer the fetch at an arbitrary hostname;
82
- * this public-range check is defense-in-depth on top of that.
111
+ * fetching, and fetchCimdDocument follows no redirect, so no hop leaves it),
112
+ * so an attacker can't steer the fetch at an arbitrary hostname; this
113
+ * public-range check is defense-in-depth on top of that.
83
114
  */
84
115
  export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
85
116
  let url;
@@ -98,6 +129,11 @@ export async function assertPublicHttpsUrl(rawUrl, deps = {}) {
98
129
  addresses = await resolveAll(url.hostname);
99
130
  }
100
131
  catch (e) {
132
+ // A resolver that could not answer this time says nothing about the host,
133
+ // so the fetch may retry it; a name that does not exist stays a block.
134
+ const code = e?.code;
135
+ if (typeof code === 'string' && TRANSIENT_CODES.has(code))
136
+ throw e;
101
137
  throw new SsrfBlockedError(`DNS resolution failed for ${url.hostname}: ${e.message}`);
102
138
  }
103
139
  if (addresses.length === 0)
@@ -124,7 +160,7 @@ const TRANSIENT_CODES = new Set([
124
160
  'UND_ERR_CONNECT_TIMEOUT',
125
161
  'UND_ERR_SOCKET',
126
162
  ]);
127
- function isTransientFetchError(e) {
163
+ export function isTransientFetchError(e) {
128
164
  if (e instanceof SsrfBlockedError)
129
165
  return false; // deterministic security/shape reject
130
166
  const name = e?.name;
@@ -140,73 +176,76 @@ function isTransientFetchError(e) {
140
176
  return true;
141
177
  return false;
142
178
  }
179
+ // dns.lookup cannot be cancelled, so an attempt stops waiting on it instead.
180
+ function untilAborted(work, signal) {
181
+ return new Promise((resolve, reject) => {
182
+ const onAbort = () => reject(signal.reason);
183
+ signal.addEventListener('abort', onAbort, { once: true });
184
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
185
+ });
186
+ }
143
187
  /**
144
- * SSRF-guarded fetch of a CIMD client-metadata document (JSON). Every hop
145
- * (including redirects) is HTTPS + public-IP checked; response is size- and
146
- * time-capped. Returns the parsed JSON object.
188
+ * SSRF-guarded fetch of a CIMD client-metadata document (JSON): HTTPS and
189
+ * public-IP checked, no redirect followed, size-capped, and every attempt
190
+ * inside one deadline. Returns the parsed JSON object.
147
191
  */
148
192
  export async function fetchCimdDocument(rawUrl, opts = {}) {
149
- const maxBytes = opts.maxBytes ?? 64 * 1024;
193
+ const maxBytes = opts.maxBytes ?? 5120;
150
194
  const timeoutMs = opts.timeoutMs ?? 5000;
151
- const maxRedirects = opts.maxRedirects ?? 3;
152
- const retries = opts.retries ?? 2;
195
+ const deadline = Date.now() + (opts.deadlineMs ?? 8000);
196
+ const retries = opts.retries ?? 1;
153
197
  const backoffMs = opts.retryBackoffMs ?? 200;
154
198
  const doFetch = opts.fetchImpl ?? fetch;
155
199
  const sleep = opts.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
156
- let current = rawUrl;
157
- for (let hop = 0; hop <= maxRedirects; hop++) {
158
- let redirectTo = null;
159
- let doc = null;
160
- // Retry transient connection failures IN PLACE. Each attempt re-runs the
161
- // resolve-then-check so a retry can never skip the anti-rebind guard, and
162
- // the timer is per-attempt (armed across the body read, #10).
163
- for (let attempt = 0;; attempt++) {
164
- await assertPublicHttpsUrl(current, opts.ssrf);
165
- const controller = new AbortController();
166
- const timer = setTimeout(() => controller.abort(), timeoutMs);
200
+ // Retry fast transient failures IN PLACE. Each attempt re-runs the
201
+ // resolve-then-check so a retry can never skip the anti-rebind guard, and
202
+ // the timer is per-attempt (armed across the lookup and the body read, #10).
203
+ for (let attempt = 0;; attempt++) {
204
+ const controller = new AbortController();
205
+ let timedOut = false;
206
+ const timer = setTimeout(() => {
207
+ timedOut = true;
208
+ controller.abort();
209
+ }, Math.max(0, Math.min(timeoutMs, deadline - Date.now())));
210
+ try {
211
+ await untilAborted(assertPublicHttpsUrl(rawUrl, opts.ssrf), controller.signal);
212
+ const res = await doFetch(rawUrl, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
213
+ // CIMD section 5: a redirect is never followed. Only this URL passed the
214
+ // issuer allowlist, and a document behind a redirect could claim its client_id.
215
+ if (res.status >= 300 && res.status < 400)
216
+ throw new SsrfBlockedError('CIMD redirect refused');
217
+ if (!res.ok)
218
+ throw new CimdHttpError(res.status);
219
+ const text = await readCapped(res, maxBytes, controller);
220
+ let parsed;
167
221
  try {
168
- const res = await doFetch(current, { redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' } });
169
- if (res.status >= 300 && res.status < 400) {
170
- const loc = res.headers.get('location');
171
- if (!loc)
172
- throw new SsrfBlockedError('redirect without a Location header');
173
- redirectTo = loc;
174
- break;
175
- }
176
- if (!res.ok)
177
- throw new SsrfBlockedError(`CIMD fetch failed: HTTP ${res.status}`);
178
- const text = await readCapped(res, maxBytes, controller);
179
- let parsed;
180
- try {
181
- parsed = JSON.parse(text);
182
- }
183
- catch {
184
- throw new SsrfBlockedError('CIMD document is not valid JSON');
185
- }
186
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
187
- throw new SsrfBlockedError('CIMD document is not a JSON object');
188
- }
189
- doc = parsed;
190
- break;
222
+ parsed = JSON.parse(text);
191
223
  }
192
- catch (e) {
193
- if (attempt < retries && isTransientFetchError(e)) {
194
- await sleep(backoffMs * (attempt + 1));
195
- continue;
196
- }
197
- throw e;
224
+ catch {
225
+ throw new SsrfBlockedError('CIMD document is not valid JSON');
198
226
  }
199
- finally {
200
- clearTimeout(timer);
227
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
228
+ throw new SsrfBlockedError('CIMD document is not a JSON object');
229
+ }
230
+ return parsed;
231
+ }
232
+ catch (e) {
233
+ // A refused status throws with the body unread, and only an abort hands
234
+ // that connection back; the controller is this attempt's alone.
235
+ controller.abort();
236
+ const wait = backoffMs * (attempt + 1);
237
+ // A timed-out attempt may leave a lookup hung on the threadpool, ours or
238
+ // fetch's own at connect time, and a retry would start another beside it.
239
+ if (!timedOut && !(e instanceof LookupBusyError) && attempt < retries && isTransientFetchError(e) && deadline - Date.now() > wait) {
240
+ await sleep(wait);
241
+ continue;
201
242
  }
243
+ throw e;
202
244
  }
203
- if (redirectTo !== null) {
204
- current = new URL(redirectTo, current).toString();
205
- continue;
245
+ finally {
246
+ clearTimeout(timer);
206
247
  }
207
- return doc;
208
248
  }
209
- throw new SsrfBlockedError(`too many redirects (> ${maxRedirects})`);
210
249
  }
211
250
  /** Read a response body, aborting the moment it exceeds maxBytes (never buffers
212
251
  * an oversized body). Falls back to a capped arrayBuffer when there is no
package/dist/trim.d.ts CHANGED
@@ -6,6 +6,11 @@ export interface CappedText {
6
6
  }
7
7
  export declare function capText(text: string, maxChars: number, offset?: number): CappedText;
8
8
  export declare function sliceClean(text: string, max: number): string;
9
+ /** An untrusted value made fit for one log line: never throws, at most `max`
10
+ * characters of it (then `...(len=N)`), each unsafe character as a literal
11
+ * `\uXXXX`. Backslash stays as is, so a value already through here comes back
12
+ * unchanged and a clean short value keeps its exact text. */
13
+ export declare function logSafe(value: unknown, max: number): string;
9
14
  /** Largest prefix whose JSON encoding (the wrapping quotes included) fits
10
15
  * `maxEncoded`. Slicing raw characters overshoots whenever the text contains
11
16
  * quotes or backslashes, which JSON.stringify doubles: the escape hatch's
package/dist/trim.js CHANGED
@@ -11,6 +11,26 @@ export function sliceClean(text, max) {
11
11
  const sliced = text.slice(0, max);
12
12
  return /[\uD800-\uDBFF]$/.test(sliced) ? sliced.slice(0, -1) : sliced;
13
13
  }
14
+ // C0, DEL and C1 (NEL, 8-bit CSI and OSC) can end a line or drive a terminal;
15
+ // the line and paragraph separators end a line for many readers; the bidi and
16
+ // invisible format controls make a line read differently from its bytes.
17
+ const LOG_UNSAFE = /[\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2028\u2029\u2066-\u2069]/gu;
18
+ /** An untrusted value made fit for one log line: never throws, at most `max`
19
+ * characters of it (then `...(len=N)`), each unsafe character as a literal
20
+ * `\uXXXX`. Backslash stays as is, so a value already through here comes back
21
+ * unchanged and a clean short value keeps its exact text. */
22
+ export function logSafe(value, max) {
23
+ let text;
24
+ try {
25
+ text = String(value);
26
+ }
27
+ catch {
28
+ return '<unprintable>';
29
+ }
30
+ const cut = text.length > max ? sliceClean(text, max) : text;
31
+ const escaped = cut.replace(LOG_UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
32
+ return cut === text ? escaped : `${escaped}...(len=${text.length})`;
33
+ }
14
34
  /** Largest prefix whose JSON encoding (the wrapping quotes included) fits
15
35
  * `maxEncoded`. Slicing raw characters overshoots whenever the text contains
16
36
  * quotes or backslashes, which JSON.stringify doubles: the escape hatch's
@@ -36,7 +36,7 @@ const ARG_KEY_RE = /^[A-Za-z][A-Za-z0-9_.]{0,63}$/;
36
36
  /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
37
37
  * by a set-equality grep test). Anything outside buckets to `other`. */
38
38
  export const KNOWN_ERROR_SLUGS = new Set([
39
- 'E_ALIAS_EXISTS', 'E_CIMD_INVALID', 'E_ENV_ACCOUNTS_MODE', 'E_MCP_TOKEN_INVALID',
39
+ 'E_ALIAS_EXISTS', 'E_CIMD_INVALID', 'E_CIMD_SSRF_BLOCKED', 'E_ENV_ACCOUNTS_MODE', 'E_MCP_TOKEN_INVALID',
40
40
  'E_NO_DEFAULT_ACCOUNT', 'E_UNKNOWN_BUNDLE', 'E_VALIDATION', 'ambiguous',
41
41
  'api_not_enabled', 'auth_required', 'bad_request', 'binary', 'binary_unsupported',
42
42
  'confirmation_declined', 'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.64",
3
+ "version": "6.0.0-alpha.66",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",