Skip to content

Commit 04c31f4

Browse files
authored
fix: reject oversized credential files in remaining readers (#109260)
* fix(security): bound remaining credential file reads * fix(anthropic-vertex): keep ADC helper type private
1 parent 1bdebb8 commit 04c31f4

10 files changed

Lines changed: 222 additions & 46 deletions

File tree

extensions/anthropic-vertex/region.adc.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ vi.mock("node:fs", async () => {
2828
};
2929
});
3030

31+
vi.mock("openclaw/plugin-sdk/secret-file-runtime", () => ({
32+
tryReadSecretFileSync: (pathname: string) => readFileSyncMock(pathname, "utf8"),
33+
}));
34+
3135
import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js";
3236

3337
describe("anthropic-vertex ADC reads", () => {

extensions/anthropic-vertex/region.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22
* Anthropic Vertex region, project, and ADC auth detection helpers. They keep
33
* credential probing local to the provider plugin.
44
*/
5-
import { readFileSync } from "node:fs";
65
import { homedir, platform } from "node:os";
76
import { join } from "node:path";
7+
import type { GoogleAuthOptions } from "google-auth-library";
88
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
9+
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
910
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
1011

1112
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
1213
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
1314
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
15+
const ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024;
1416

15-
type AdcProjectFile = {
17+
type AnthropicVertexAdcCredentials = NonNullable<GoogleAuthOptions["credentials"]> & {
1618
project_id?: unknown;
1719
quota_project_id?: unknown;
1820
};
@@ -107,14 +109,27 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate(
107109
return resolveAnthropicVertexDefaultAdcPath(env);
108110
}
109111

110-
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
112+
export function resolveAnthropicVertexAdcCredentials(
113+
env: NodeJS.ProcessEnv = process.env,
114+
): AnthropicVertexAdcCredentials | undefined {
111115
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
112-
if (!credentialsPath) {
113-
return false;
116+
const text = tryReadSecretFileSync(credentialsPath, "Anthropic Vertex ADC credentials", {
117+
maxBytes: ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES,
118+
rejectHardlinks: false,
119+
});
120+
if (!text) {
121+
return undefined;
122+
}
123+
const parsed = JSON.parse(text) as unknown;
124+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
125+
throw new Error(`Anthropic Vertex ADC credentials must be a JSON object: ${credentialsPath}`);
114126
}
127+
return parsed as AnthropicVertexAdcCredentials;
128+
}
129+
130+
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
115131
try {
116-
readFileSync(credentialsPath, "utf8");
117-
return true;
132+
return resolveAnthropicVertexAdcCredentials(env) !== undefined;
118133
} catch {
119134
return false;
120135
}
@@ -123,12 +138,11 @@ function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolea
123138
function resolveAnthropicVertexProjectIdFromAdc(
124139
env: NodeJS.ProcessEnv = process.env,
125140
): string | undefined {
126-
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
127-
if (!credentialsPath) {
128-
return undefined;
129-
}
130141
try {
131-
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
142+
const parsed = resolveAnthropicVertexAdcCredentials(env);
143+
if (!parsed) {
144+
return undefined;
145+
}
132146
return (
133147
normalizeOptionalSecretInput(parsed.project_id) ||
134148
normalizeOptionalSecretInput(parsed.quota_project_id)

extensions/anthropic-vertex/stream-runtime.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Anthropic Vertex tests cover stream runtime plugin behavior.
22
import { once } from "node:events";
3+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
34
import { createServer } from "node:http";
5+
import os from "node:os";
6+
import path from "node:path";
47
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
58
import { beforeAll, describe, expect, it, vi } from "vitest";
69
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
@@ -173,6 +176,44 @@ describe("createAnthropicVertexStreamFn", () => {
173176
});
174177
});
175178

179+
it("passes bounded ADC credentials to google-auth-library", () => {
180+
const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-anthropic-vertex-stream-adc-"));
181+
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
182+
const credentials = {
183+
type: "service_account",
184+
project_id: "vertex-project",
185+
};
186+
const json = JSON.stringify(credentials);
187+
const env = { GOOGLE_APPLICATION_CREDENTIALS: credentialsPath } as NodeJS.ProcessEnv;
188+
const { deps, googleAuthCtorMock } = createStreamDeps();
189+
try {
190+
writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`);
191+
createAnthropicVertexStreamFnForModel({}, env, deps);
192+
expect(googleAuthCtorMock).toHaveBeenCalledWith({
193+
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
194+
credentials,
195+
clientOptions: {
196+
transporterOptions: { fetchImplementation: expect.any(Function) },
197+
},
198+
});
199+
200+
writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`);
201+
let readError: unknown;
202+
try {
203+
createAnthropicVertexStreamFnForModel({}, env, deps);
204+
} catch (error) {
205+
readError = error;
206+
}
207+
expect(readError).toMatchObject({
208+
name: "FsSafeError",
209+
code: "too-large",
210+
message: `Anthropic Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
211+
});
212+
} finally {
213+
rmSync(tempDir, { recursive: true, force: true });
214+
}
215+
});
216+
176217
it("uses provider-local proxy-aware fetch without mutating the global window", async () => {
177218
const { deps, anthropicVertexCtorMock, googleAuthCtorMock, googleAuthClient } =
178219
createStreamDeps();

extensions/anthropic-vertex/stream-runtime.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import {
2323
supportsClaudeNativeXhighEffort,
2424
} from "openclaw/plugin-sdk/provider-model-shared";
2525
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
26-
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
26+
import {
27+
resolveAnthropicVertexAdcCredentials,
28+
resolveAnthropicVertexClientRegion,
29+
resolveAnthropicVertexProjectId,
30+
} from "./region.js";
2731

2832
const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
2933

@@ -156,11 +160,14 @@ export function createAnthropicVertexStreamFn(
156160
region: string,
157161
baseURL?: string,
158162
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
163+
env: NodeJS.ProcessEnv = process.env,
159164
): StreamFn {
160165
// GoogleAuth carries clientOptions into file-backed ADC clients. Keep the
161166
// proxy-aware transport provider-local; a window shim changes detection globally.
167+
const adcConfig = resolveAnthropicVertexAdcCredentials(env);
162168
const googleAuth = new deps.GoogleAuth({
163169
scopes: [GOOGLE_CLOUD_PLATFORM_SCOPE],
170+
...(adcConfig ? { credentials: adcConfig } : {}),
164171
clientOptions: {
165172
transporterOptions: { fetchImplementation: googleAuthFetch },
166173
},
@@ -291,5 +298,6 @@ export function createAnthropicVertexStreamFnForModel(
291298
}),
292299
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
293300
deps,
301+
env,
294302
);
295303
}

extensions/google/transport-stream.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,36 @@ describe("google transport stream", () => {
11111111
expect(tokenFetchMock).not.toHaveBeenCalled();
11121112
});
11131113

1114+
it("bounds Google Vertex ADC files before google-auth-library reads them", async () => {
1115+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-file-"));
1116+
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
1117+
const credentials = {
1118+
type: "service_account",
1119+
client_email: "[email protected]",
1120+
};
1121+
const json = JSON.stringify(credentials);
1122+
await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`, "utf8");
1123+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
1124+
googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.file-token");
1125+
1126+
await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).resolves.toEqual({
1127+
Authorization: "Bearer ya29.file-token",
1128+
});
1129+
expect(googleAuthMock).toHaveBeenCalledWith({
1130+
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
1131+
credentials,
1132+
clientOptions: { transporterOptions: { timeout: 30_000 } },
1133+
});
1134+
1135+
resetGoogleVertexAdcState();
1136+
await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`, "utf8");
1137+
await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).rejects.toMatchObject({
1138+
name: "FsSafeError",
1139+
code: "too-large",
1140+
message: `Google Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
1141+
});
1142+
});
1143+
11141144
it("bounds google-auth-library ADC token resolution at the Vertex owner", async () => {
11151145
const tempDir = await mkdtemp(
11161146
path.join(os.tmpdir(), "openclaw-google-vertex-authlib-timeout-"),

extensions/google/vertex-adc.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
// Google plugin module implements vertex adc behavior.
2-
import { existsSync, readFileSync } from "node:fs";
3-
import { readFile } from "node:fs/promises";
2+
import { existsSync } from "node:fs";
43
import os from "node:os";
54
import path from "node:path";
65
import { gunzipSync } from "node:zlib";
6+
import type { GoogleAuthOptions } from "google-auth-library";
77
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
88
import {
99
asDateTimestampMs,
1010
resolveExpiresAtMsFromDurationMs,
1111
resolveExpiresAtMsFromDurationSeconds,
1212
} from "openclaw/plugin-sdk/number-runtime";
1313
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
14+
import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
1415
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1516
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
1617

@@ -149,19 +150,25 @@ function resolveGoogleApplicationCredentialsPath(
149150
return existsSync(appDataFallback) ? appDataFallback : undefined;
150151
}
151152

152-
async function readGoogleAuthorizedUserCredentials(
153-
credentialsPath: string,
154-
): Promise<GoogleAuthorizedUserCredentials | undefined> {
155-
let parsed: unknown;
156-
try {
157-
parsed = JSON.parse(await readFile(credentialsPath, "utf8")) as unknown;
158-
} catch {
159-
return undefined;
160-
}
153+
type GoogleAdcConfig = NonNullable<GoogleAuthOptions["credentials"]>;
154+
const GOOGLE_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024;
155+
156+
function readGoogleAdcCredentials(adcPath: string): GoogleAdcConfig {
157+
const text = readSecretFileSync(adcPath, "Google Vertex ADC credentials", {
158+
maxBytes: GOOGLE_VERTEX_ADC_FILE_MAX_BYTES,
159+
rejectHardlinks: false,
160+
});
161+
const parsed = JSON.parse(text) as unknown;
161162
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
162-
return undefined;
163+
throw new Error(`Google Vertex ADC credentials must be a JSON object: ${adcPath}`);
163164
}
164-
const record = parsed as Record<string, unknown>;
165+
return parsed as GoogleAdcConfig;
166+
}
167+
168+
function resolveGoogleAuthorizedUserCredentials(
169+
adcConfig: GoogleAdcConfig,
170+
): GoogleAuthorizedUserCredentials | undefined {
171+
const record = adcConfig as Record<string, unknown>;
165172
if (record.type !== "authorized_user") {
166173
return undefined;
167174
}
@@ -175,11 +182,7 @@ async function readGoogleAuthorizedUserCredentials(
175182

176183
function readGoogleAdcCredentialsTypeSync(credentialsPath: string): string | undefined {
177184
try {
178-
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as unknown;
179-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
180-
return undefined;
181-
}
182-
const type = (parsed as { type?: unknown }).type;
185+
const type = (readGoogleAdcCredentials(credentialsPath) as { type?: unknown }).type;
183186
return typeof type === "string" ? type : undefined;
184187
} catch {
185188
return undefined;
@@ -353,7 +356,9 @@ function shouldGunzipGoogleOauthTokenResponse(
353356
.includes("gzip");
354357
}
355358

356-
async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
359+
async function resolveGoogleVertexAccessTokenViaGoogleAuth(
360+
adcConfig?: GoogleAdcConfig,
361+
): Promise<string> {
357362
// Lazy-import + cache so we don't pay the google-auth-library load cost on
358363
// gateway startup; only when we actually need a non-authorized_user token.
359364
if (!cachedGoogleAuthClient) {
@@ -367,6 +372,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
367372
// It also caches tokens internally and refreshes before expiry.
368373
return new GoogleAuth({
369374
scopes: [GOOGLE_VERTEX_OAUTH_SCOPE],
375+
...(adcConfig ? { credentials: adcConfig } : {}),
370376
// Best-effort cancellation for clients that use the shared transporter.
371377
// WIF STS and GCE metadata need the owner-level deadline below.
372378
clientOptions: {
@@ -442,13 +448,15 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
442448
export async function resolveGoogleVertexAuthorizedUserHeaders(
443449
fetchImpl?: typeof fetch,
444450
): Promise<Record<string, string>> {
445-
const credentialsPath = resolveGoogleApplicationCredentialsPath();
446-
if (credentialsPath) {
447-
const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath);
448-
if (credentials) {
451+
const adcPath = resolveGoogleApplicationCredentialsPath();
452+
let adcConfig: GoogleAdcConfig | undefined;
453+
if (adcPath) {
454+
adcConfig = readGoogleAdcCredentials(adcPath);
455+
const userAdc = resolveGoogleAuthorizedUserCredentials(adcConfig);
456+
if (userAdc) {
449457
const token = await refreshGoogleVertexAuthorizedUserAccessToken({
450-
credentialsPath,
451-
credentials,
458+
credentialsPath: adcPath,
459+
credentials: userAdc,
452460
fetchImpl,
453461
});
454462
return { Authorization: `Bearer ${token}` };
@@ -457,6 +465,6 @@ export async function resolveGoogleVertexAuthorizedUserHeaders(
457465
// No file-based authorized_user ADC. Fall back to google-auth-library which
458466
// handles GKE Workload Identity (metadata server), Workload Identity
459467
// Federation (external_account), and service-account keys.
460-
const token = await resolveGoogleVertexAccessTokenViaGoogleAuth();
468+
const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(adcConfig);
461469
return { Authorization: `Bearer ${token}` };
462470
}

extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,40 @@ describe("mantis discord smoke runtime", () => {
129129
expect(await fs.readFile(result.reportPath, "utf8")).not.toContain("test-token");
130130
});
131131

132+
it("bounds Mantis Discord token files", async () => {
133+
await fs.writeFile(tokenFile, "x".repeat(4 * 1024), "utf8");
134+
const boundaryResult = await runMantisDiscordSmoke({
135+
repoRoot,
136+
outputDir: ".artifacts/qa-e2e/mantis/token-boundary",
137+
tokenFile,
138+
skipPost: true,
139+
env: {
140+
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
141+
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
142+
},
143+
});
144+
expect(boundaryResult.status).toBe("pass");
145+
const fetchCallsAtBoundary = fetchWithSsrFGuard.mock.calls.length;
146+
147+
await fs.writeFile(tokenFile, "x".repeat(4 * 1024 + 1), "utf8");
148+
const oversizedResult = await runMantisDiscordSmoke({
149+
repoRoot,
150+
outputDir: ".artifacts/qa-e2e/mantis/token-oversized",
151+
tokenFile,
152+
skipPost: true,
153+
env: {
154+
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
155+
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
156+
},
157+
});
158+
159+
expect(oversizedResult.status).toBe("fail");
160+
expect(fetchWithSsrFGuard.mock.calls).toHaveLength(fetchCallsAtBoundary);
161+
expect(await fs.readFile(path.join(oversizedResult.outputDir, "error.txt"), "utf8")).toContain(
162+
`Mantis Discord token file at ${tokenFile} exceeds 4096 bytes.`,
163+
);
164+
});
165+
132166
it("supports visibility-only smoke runs", async () => {
133167
const result = await runMantisDiscordSmoke({
134168
repoRoot,

0 commit comments

Comments
 (0)