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.
- package/dist/arg-normalize.js +4 -3
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/http-transport.d.ts +4 -1
- package/dist/http-transport.js +24 -8
- package/dist/mcp-token.d.ts +27 -6
- package/dist/mcp-token.js +82 -27
- package/dist/oauth-as.d.ts +3 -1
- package/dist/oauth-as.js +119 -64
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.js +1 -1
- package/package.json +1 -1
package/dist/arg-normalize.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
}
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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();
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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. */
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
271
293
|
}
|
|
272
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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())}`;
|
package/dist/ssrf-guard.d.ts
CHANGED
|
@@ -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,
|
|
16
|
-
*
|
|
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
|
|
24
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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)
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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>>;
|
package/dist/ssrf-guard.js
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
// 169.254/16, 0/8, multicast, ::1, fc00::/7, fe80::/10, and
|
|
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
|
-
|
|
69
|
-
|
|
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,
|
|
74
|
-
*
|
|
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
|
|
82
|
-
*
|
|
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)
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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 ??
|
|
193
|
+
const maxBytes = opts.maxBytes ?? 5120;
|
|
150
194
|
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
151
|
-
const
|
|
152
|
-
const retries = opts.retries ??
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
package/dist/usage-metrics.js
CHANGED
|
@@ -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.
|
|
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",
|