mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
package/dist/services.d.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AccountSet } from './accounts.js';
|
|
2
|
+
import type { CuratedToolDeps } from './client.js';
|
|
3
|
+
import { type ToolRegistry } from './registry.js';
|
|
2
4
|
export interface ServiceEntry {
|
|
3
5
|
name: string;
|
|
4
|
-
register: (registry: ToolRegistry) => void;
|
|
5
|
-
|
|
6
|
+
register: (registry: ToolRegistry, deps?: CuratedToolDeps) => void;
|
|
7
|
+
/** Absent = always on. Gates evaluate against the PASSED account set (the
|
|
8
|
+
* calling tenant's view); omitted set = the global registry, today's
|
|
9
|
+
* behavior. */
|
|
10
|
+
enabled?: (set?: AccountSet) => boolean;
|
|
6
11
|
}
|
|
7
12
|
export declare const SERVICES: ServiceEntry[];
|
|
8
13
|
export declare const GENERATED_GATES: Record<string, {
|
|
9
|
-
enabled: () => boolean;
|
|
14
|
+
enabled: (set?: AccountSet) => boolean;
|
|
10
15
|
hint: string;
|
|
11
16
|
}>;
|
|
17
|
+
export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
|
|
12
18
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* is which: it composed the enabled-service list at boot.
|
|
19
|
+
* Gated services the registry's CURRENT accounts enable but that registered
|
|
20
|
+
* nothing: an account added mid-session unlocked them after buildRegistry
|
|
21
|
+
* decided the gates. Mirrors compose.ts gate for gate, so an ungated or
|
|
22
|
+
* toolset-excluded service is never reported.
|
|
18
23
|
*/
|
|
19
|
-
export declare function
|
|
24
|
+
export declare function servicesAwaitingRestart(registry: ToolRegistry): string[];
|
package/dist/services.js
CHANGED
|
@@ -13,8 +13,12 @@ import { registerChatTools } from './tools/chat.js';
|
|
|
13
13
|
import { registerAdminTools } from './tools/admin.js';
|
|
14
14
|
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
15
15
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
16
|
+
import { serviceOf } from './registry.js';
|
|
17
|
+
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
16
18
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
17
19
|
import { suggestKeys } from './arg-strict.js';
|
|
20
|
+
import { sliceClean } from './trim.js';
|
|
21
|
+
import { hints } from './hint-provider.js';
|
|
18
22
|
export const SERVICES = [
|
|
19
23
|
{ name: 'gmail', register: registerGmailTools },
|
|
20
24
|
{ name: 'drive', register: registerDriveTools },
|
|
@@ -25,16 +29,16 @@ export const SERVICES = [
|
|
|
25
29
|
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
26
30
|
{ name: 'tasks', register: registerTasksTools },
|
|
27
31
|
{ name: 'meet', register: registerMeetTools },
|
|
28
|
-
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
29
|
-
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
30
|
-
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
31
|
-
{ name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
|
|
32
|
-
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
32
|
+
{ name: 'slides', register: registerSlidesTools, enabled: (set) => new Set(getOptionalBundles(set)).has('slides') },
|
|
33
|
+
{ name: 'forms', register: registerFormsTools, enabled: (set) => new Set(getOptionalBundles(set)).has('forms') },
|
|
34
|
+
{ name: 'chat', register: registerChatTools, enabled: (set) => new Set(getOptionalBundles(set)).has('chat') },
|
|
35
|
+
{ name: 'analytics', register: registerAnalyticsTools, enabled: (set) => { const b = new Set(getOptionalBundles(set)); return b.has('analytics') || b.has('analytics_write'); } },
|
|
36
|
+
{ name: 'admin', register: registerAdminTools, enabled: (set) => getAdminAccounts(set).length > 0 },
|
|
33
37
|
];
|
|
34
38
|
// Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
|
|
35
39
|
// and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
|
|
36
40
|
const bundleGate = (name) => ({
|
|
37
|
-
enabled: () => new Set(getOptionalBundles()).has(name),
|
|
41
|
+
enabled: (set) => new Set(getOptionalBundles(set)).has(name),
|
|
38
42
|
hint: `add "${name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`,
|
|
39
43
|
});
|
|
40
44
|
export const GENERATED_GATES = {
|
|
@@ -60,19 +64,59 @@ export const GENERATED_GATES = {
|
|
|
60
64
|
* never heard of. Those need different next steps, and the server knows which
|
|
61
65
|
* is which: it composed the enabled-service list at boot.
|
|
62
66
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// A service that EXISTS in the build but registered nothing is
|
|
68
|
-
// misspelled
|
|
69
|
-
|
|
67
|
+
const MAX_TOOL_NAME_ECHO = 64;
|
|
68
|
+
export function unknownToolMessage(registry, rawName) {
|
|
69
|
+
// The name is the caller's, unvalidated: echo a bounded prefix only.
|
|
70
|
+
const name = rawName.length > MAX_TOOL_NAME_ECHO ? `${sliceClean(rawName, MAX_TOOL_NAME_ECHO)}...` : rawName;
|
|
71
|
+
// A service that EXISTS in the build but registered nothing is off, not
|
|
72
|
+
// misspelled. Checked before the did-you-mean, which would otherwise point
|
|
73
|
+
// chat_spaces_get at meet_spaces_get.
|
|
74
|
+
const service = serviceOf(name);
|
|
70
75
|
const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
|
|
71
76
|
if (known && !registry.services().includes(service)) {
|
|
72
|
-
const
|
|
77
|
+
const aliases = registry.accountSet().aliases;
|
|
78
|
+
if (!toolsetEnabled(getToolsets(), service)) {
|
|
79
|
+
const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: true, awaitingRestart: false });
|
|
80
|
+
return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is turned off in this deployment (GOOGLE_TOOLSETS).`;
|
|
81
|
+
}
|
|
82
|
+
if (servicesAwaitingRestart(registry).includes(service)) {
|
|
83
|
+
const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: false, awaitingRestart: true });
|
|
84
|
+
return rendered?.hint ?? `Tool ${name} not found: the "${service}" service was enabled after this server started, and tools register at startup. Restart the server to use them. Until then, google_api_call can reach the same API.`;
|
|
85
|
+
}
|
|
86
|
+
const isAdmin = service === 'admin';
|
|
87
|
+
const hint = isAdmin
|
|
73
88
|
? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
|
|
74
89
|
: (GENERATED_GATES[service]?.hint ?? `add "${service}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES), then re-auth`);
|
|
75
|
-
|
|
90
|
+
const rendered = hints()?.render({
|
|
91
|
+
kind: 'service_not_enabled',
|
|
92
|
+
tool: name,
|
|
93
|
+
service,
|
|
94
|
+
bundle: isAdmin ? undefined : service,
|
|
95
|
+
aliases,
|
|
96
|
+
admin: isAdmin,
|
|
97
|
+
toolsetOff: false,
|
|
98
|
+
awaitingRestart: false,
|
|
99
|
+
});
|
|
100
|
+
return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
|
|
76
101
|
}
|
|
102
|
+
const near = suggestKeys(rawName, registry.toolNames(), 3);
|
|
103
|
+
if (near.length > 0)
|
|
104
|
+
return `Tool ${name} not found. Did you mean: ${near.join(', ')}?`;
|
|
77
105
|
return `Tool ${name} not found. Call {service}_discover to list a service's tools, or google_api_search to find a method on any Google API.`;
|
|
78
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Gated services the registry's CURRENT accounts enable but that registered
|
|
109
|
+
* nothing: an account added mid-session unlocked them after buildRegistry
|
|
110
|
+
* decided the gates. Mirrors compose.ts gate for gate, so an ungated or
|
|
111
|
+
* toolset-excluded service is never reported.
|
|
112
|
+
*/
|
|
113
|
+
export function servicesAwaitingRestart(registry) {
|
|
114
|
+
const toolsets = getToolsets();
|
|
115
|
+
const registered = new Set(registry.services());
|
|
116
|
+
const live = registry.accountSet();
|
|
117
|
+
const names = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((g) => g.name)])];
|
|
118
|
+
return names.filter((n) => {
|
|
119
|
+
const gate = SERVICES.find((s) => s.name === n)?.enabled ?? GENERATED_GATES[n]?.enabled;
|
|
120
|
+
return gate !== undefined && toolsetEnabled(toolsets, n) && !registered.has(n) && gate(live);
|
|
121
|
+
});
|
|
122
|
+
}
|
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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TokenData } from './types.js';
|
|
2
|
+
/** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
|
|
3
|
+
* Google acknowledged; NEVER throws — a network failure must not block the
|
|
4
|
+
* local purge (the local copy is the thing we control). */
|
|
5
|
+
export declare function revokeGoogleToken(tokenData: {
|
|
6
|
+
refresh_token?: string;
|
|
7
|
+
access_token?: string;
|
|
8
|
+
}, fetchFn?: typeof fetch): Promise<boolean>;
|
|
9
|
+
export interface TenantTokenPurge {
|
|
10
|
+
/** Aliases whose .enc files were removed. */
|
|
11
|
+
removed: string[];
|
|
12
|
+
/** How many tokens Google acknowledged revoking (best-effort). */
|
|
13
|
+
revoked: number;
|
|
14
|
+
}
|
|
15
|
+
/** Remove every `.enc` under the tenant's token dir, optionally revoking each
|
|
16
|
+
* at Google first. Decrypt failures still purge the file — an unreadable
|
|
17
|
+
* token is exactly the thing to delete. Only THIS tenant's dir is touched. */
|
|
18
|
+
export declare function purgeTenantTokens(tenantId: string, opts?: {
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
revoke?: (t: TokenData) => Promise<boolean>;
|
|
21
|
+
resolveKey?: () => string;
|
|
22
|
+
}): Promise<TenantTokenPurge>;
|
|
23
|
+
/** Remove the whole tenants/<id> directory (config + tokens). The id is
|
|
24
|
+
* validated by the path helper, so a traversal-shaped id throws before any
|
|
25
|
+
* filesystem access. */
|
|
26
|
+
export declare function removeTenantDir(tenantId: string, env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// S1.21: tenant offboarding primitives. Core owns the reusable token-purge
|
|
2
|
+
// legs (generically useful, tenant-id-agnostic); the tenant_delete
|
|
3
|
+
// ORCHESTRATION (what to purge, in what order, from which admin surface)
|
|
4
|
+
// lives with the tenancy module that calls these.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { tenantConfigFilePath, tenantTokenDir } from './config-file.js';
|
|
8
|
+
import { createTokenStore } from './token-store.js';
|
|
9
|
+
/** Best-effort Google-side revocation (RFC 7009 endpoint). Returns whether
|
|
10
|
+
* Google acknowledged; NEVER throws — a network failure must not block the
|
|
11
|
+
* local purge (the local copy is the thing we control). */
|
|
12
|
+
export async function revokeGoogleToken(tokenData, fetchFn = fetch) {
|
|
13
|
+
const token = tokenData.refresh_token ?? tokenData.access_token;
|
|
14
|
+
if (!token)
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetchFn('https://oauth2.googleapis.com/revoke', {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
20
|
+
body: new URLSearchParams({ token }).toString(),
|
|
21
|
+
});
|
|
22
|
+
return res.ok;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Remove every `.enc` under the tenant's token dir, optionally revoking each
|
|
29
|
+
* at Google first. Decrypt failures still purge the file — an unreadable
|
|
30
|
+
* token is exactly the thing to delete. Only THIS tenant's dir is touched. */
|
|
31
|
+
export async function purgeTenantTokens(tenantId, opts = {}) {
|
|
32
|
+
const dir = tenantTokenDir(tenantId, opts.env);
|
|
33
|
+
const store = createTokenStore(dir, { tenantId, resolveKey: opts.resolveKey });
|
|
34
|
+
const removed = [];
|
|
35
|
+
let revoked = 0;
|
|
36
|
+
let names;
|
|
37
|
+
try {
|
|
38
|
+
names = fs.readdirSync(dir);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { removed, revoked }; // no token dir = nothing to purge
|
|
42
|
+
}
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (!name.endsWith('.enc'))
|
|
45
|
+
continue;
|
|
46
|
+
const alias = name.slice(0, -'.enc'.length);
|
|
47
|
+
if (opts.revoke) {
|
|
48
|
+
try {
|
|
49
|
+
const token = store.readToken(alias);
|
|
50
|
+
if (token && (await opts.revoke(token)))
|
|
51
|
+
revoked += 1;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// undecryptable or invalid-alias filename: still remove the file
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
58
|
+
removed.push(alias);
|
|
59
|
+
}
|
|
60
|
+
return { removed, revoked };
|
|
61
|
+
}
|
|
62
|
+
/** Remove the whole tenants/<id> directory (config + tokens). The id is
|
|
63
|
+
* validated by the path helper, so a traversal-shaped id throws before any
|
|
64
|
+
* filesystem access. */
|
|
65
|
+
export function removeTenantDir(tenantId, env) {
|
|
66
|
+
fs.rmSync(path.dirname(tenantConfigFilePath(tenantId, env)), { recursive: true, force: true });
|
|
67
|
+
}
|
package/dist/token-store.d.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import type { TokenData } from './types.js';
|
|
2
2
|
export declare function deriveKey(masterKey: string): Buffer;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function
|
|
3
|
+
export declare function deriveTenantKey(masterKey: string, tenantId: string): Buffer;
|
|
4
|
+
export declare function encryptToken(data: object, masterKey: string, tenantId?: string): string;
|
|
5
|
+
export declare function decryptToken(fileContents: string, masterKey: string, tenantId?: string): TokenData;
|
|
5
6
|
export declare function readToken(alias: string): TokenData | null;
|
|
6
7
|
export declare function writeToken(alias: string, data: object): void;
|
|
7
8
|
export declare function updateToken(alias: string, updates: object): void;
|
|
8
9
|
export declare function hasToken(alias: string): boolean;
|
|
10
|
+
export interface TokenStore {
|
|
11
|
+
readToken(alias: string): TokenData | null;
|
|
12
|
+
writeToken(alias: string, data: object): void;
|
|
13
|
+
updateToken(alias: string, updates: object): void;
|
|
14
|
+
hasToken(alias: string): boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Context-scoped token store: `.enc` files live under `dir`, ciphertext keyed
|
|
18
|
+
* by `tenantId` when present (HKDF v2 envelope; omitted = the single-owner v1
|
|
19
|
+
* envelope). Same lock + atomic-write + error contract as the module-level
|
|
20
|
+
* functions: readToken returns null ONLY when the file is missing and throws
|
|
21
|
+
* on a decrypt failure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createTokenStore(dir: string, opts?: {
|
|
24
|
+
tenantId?: string;
|
|
25
|
+
resolveKey?: () => string;
|
|
26
|
+
}): TokenStore;
|
package/dist/token-store.js
CHANGED
|
Binary file
|
package/dist/tools/_errors.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { Account } from '../accounts.js';
|
|
|
6
6
|
export interface ServiceHints {
|
|
7
7
|
scope?: string;
|
|
8
8
|
resource?: string;
|
|
9
|
+
/** The scope bundle the scope hint is about, when known; carried through to
|
|
10
|
+
* a host-supplied provider so it can name the bundle in-band. */
|
|
11
|
+
bundle?: string;
|
|
9
12
|
}
|
|
10
13
|
export interface ErrorEnvelope {
|
|
11
14
|
error: string;
|