mcp-google-multi 6.0.0-alpha.64 → 6.0.0-alpha.65
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/oauth-as.js +44 -41
- 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 < 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/oauth-as.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { isIPv4 } from 'node:net';
|
|
10
10
|
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
|
|
11
|
+
import { fetchCimdDocument } from './ssrf-guard.js';
|
|
12
|
+
import { CimdClientCache } from './cimd-cache.js';
|
|
13
|
+
import { logSafe } from './trim.js';
|
|
12
14
|
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
15
|
// DCR (/register) is public + unauthenticated, so its in-memory store must be
|
|
14
16
|
// bounded like every sibling store (the ReplayGuard cap) or a request loop
|
|
@@ -61,6 +63,10 @@ class SubjectCheckUnavailable extends Error {
|
|
|
61
63
|
function subjectUnavailable(res) {
|
|
62
64
|
return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
63
65
|
}
|
|
66
|
+
// Not invalid_client: clients and people read that as a permanent misconfiguration.
|
|
67
|
+
function clientUnavailable(res) {
|
|
68
|
+
return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
|
|
69
|
+
}
|
|
64
70
|
function redirect(res, location, extraHeaders = {}) {
|
|
65
71
|
res.writeHead(302, { Location: location, ...extraHeaders });
|
|
66
72
|
res.end();
|
|
@@ -210,8 +216,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
210
216
|
authorization_response_iss_parameter_supported: true,
|
|
211
217
|
scopes_supported: ['mcp:use'],
|
|
212
218
|
};
|
|
213
|
-
const
|
|
214
|
-
const CIMD_CACHE_TTL_MS = 5 * 60_000;
|
|
219
|
+
const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
|
|
215
220
|
async function validateClient(clientId) {
|
|
216
221
|
const reg = registered.get(clientId);
|
|
217
222
|
if (reg)
|
|
@@ -229,40 +234,27 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
229
234
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
230
235
|
}
|
|
231
236
|
if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
|
|
232
|
-
|
|
237
|
+
// The issuer and a length, never the path: an attacker picks the path freely.
|
|
238
|
+
log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
|
|
233
239
|
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
234
240
|
}
|
|
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 {
|
|
241
|
+
// #15: cached only once it validates; the cache logs a failed fetch.
|
|
242
|
+
const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
|
|
243
|
+
if ('busy' in got)
|
|
244
|
+
return got;
|
|
245
|
+
// #13: never echo internal fetch details to the caller (blind-SSRF oracle).
|
|
246
|
+
if ('error' in got)
|
|
247
|
+
return { error: got.error, message: 'invalid client' };
|
|
248
|
+
const uris = got.doc.redirect_uris;
|
|
249
|
+
// C6 #14: warn on a localhost-only redirect set (loopback impersonation),
|
|
250
|
+
// once per fetch rather than on every sign-in the cache serves.
|
|
251
|
+
if (got.fresh && uris.every((u) => { try {
|
|
260
252
|
return isLoopbackHostname(new URL(u).hostname);
|
|
261
253
|
}
|
|
262
254
|
catch {
|
|
263
255
|
return false;
|
|
264
256
|
} })) {
|
|
265
|
-
log(`client ${clientId} advertises only loopback redirect_uris`);
|
|
257
|
+
log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
|
|
266
258
|
}
|
|
267
259
|
return { redirect_uris: uris, dcr: false };
|
|
268
260
|
}
|
|
@@ -366,6 +358,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
366
358
|
if (!clientId)
|
|
367
359
|
return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
|
|
368
360
|
const client = await validateClient(clientId);
|
|
361
|
+
if ('busy' in client)
|
|
362
|
+
return clientUnavailable(res);
|
|
369
363
|
if ('error' in client)
|
|
370
364
|
return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
|
|
371
365
|
// (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
|
|
@@ -446,7 +440,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
446
440
|
catch (e) {
|
|
447
441
|
// The detail can name proxies and hosts; it goes to the log, not the browser.
|
|
448
442
|
const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
|
|
449
|
-
log(`callback exchange failed flow=${st.flow}: ${detail
|
|
443
|
+
log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
|
|
450
444
|
return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
|
|
451
445
|
}
|
|
452
446
|
if (st.flow === 'alias_add') {
|
|
@@ -464,17 +458,17 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
464
458
|
}
|
|
465
459
|
catch (e) {
|
|
466
460
|
// 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'}`);
|
|
461
|
+
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
462
|
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
463
|
}
|
|
470
464
|
if (refusal) {
|
|
471
465
|
// The slug lands in a text/plain body; keep it a bare identifier.
|
|
472
466
|
const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
|
|
473
467
|
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}`);
|
|
468
|
+
log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
|
|
475
469
|
return errorPage(res, 403, slug, message);
|
|
476
470
|
}
|
|
477
|
-
log(`callback ok flow=alias_add alias=${st.alias}`);
|
|
471
|
+
log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
|
|
478
472
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
479
473
|
res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
|
|
480
474
|
return true;
|
|
@@ -490,7 +484,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
490
484
|
const gotEmail = (exchanged.email ?? '').toLowerCase();
|
|
491
485
|
const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
|
|
492
486
|
if (!expected || !gotEmail || gotEmail !== expected) {
|
|
493
|
-
log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
|
|
487
|
+
log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
|
|
494
488
|
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
495
489
|
}
|
|
496
490
|
// A completion without a refresh token (a Google URL built without
|
|
@@ -498,7 +492,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
498
492
|
// dies within the hour. Keep the stored token instead.
|
|
499
493
|
const refreshToken = exchanged.tokens.refresh_token;
|
|
500
494
|
if (typeof refreshToken !== 'string' || refreshToken === '') {
|
|
501
|
-
log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
|
|
495
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
|
|
502
496
|
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
497
|
}
|
|
504
498
|
const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
|
|
@@ -508,13 +502,13 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
508
502
|
// for an hour and the Google URL's scope is not signed, so a narrower
|
|
509
503
|
// grant is not necessarily the account holder's choice.
|
|
510
504
|
if (missing.length > 0 && deps.hasToken?.(st.alias)) {
|
|
511
|
-
log(`alias_reauth for "${st.alias}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
505
|
+
log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
|
|
512
506
|
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
513
507
|
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
508
|
return true;
|
|
515
509
|
}
|
|
516
510
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
517
|
-
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
511
|
+
log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
|
|
518
512
|
// With no stored token a partial grant still beats none; say what is missing.
|
|
519
513
|
const gap = missing.length
|
|
520
514
|
? `<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>`
|
|
@@ -577,7 +571,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
577
571
|
active = deps.subjectActive(code.sub);
|
|
578
572
|
}
|
|
579
573
|
catch (e) {
|
|
580
|
-
log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? e.message : 'non-Error throw'}`);
|
|
574
|
+
log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
|
|
581
575
|
return subjectUnavailable(res);
|
|
582
576
|
}
|
|
583
577
|
if (!active) {
|
|
@@ -612,7 +606,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
612
606
|
catch (e) {
|
|
613
607
|
if (!(e instanceof SubjectCheckUnavailable))
|
|
614
608
|
throw e;
|
|
615
|
-
log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? e.cause.message : 'non-Error throw'}`);
|
|
609
|
+
log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
|
|
616
610
|
return subjectUnavailable(res);
|
|
617
611
|
}
|
|
618
612
|
if (!next)
|
|
@@ -633,9 +627,18 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
633
627
|
catch {
|
|
634
628
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
635
629
|
}
|
|
636
|
-
|
|
630
|
+
// Shape first: a throw here is an unauthenticated 500 per request, and a
|
|
631
|
+
// `null` body has no properties while String() throws on {"toString":1}.
|
|
632
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
633
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
634
|
+
}
|
|
635
|
+
const raw = parsed.redirect_uris;
|
|
636
|
+
const redirectUris = Array.isArray(raw) ? raw : [];
|
|
637
637
|
if (redirectUris.length === 0)
|
|
638
638
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
|
|
639
|
+
if (!redirectUris.every((u) => typeof u === 'string')) {
|
|
640
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
|
|
641
|
+
}
|
|
639
642
|
if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
|
|
640
643
|
return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
|
|
641
644
|
}
|
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.65",
|
|
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",
|