mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix: reject oversized credential files in remaining readers (#109260)
* fix(security): bound remaining credential file reads * fix(anthropic-vertex): keep ADC helper type private
This commit is contained in:
@@ -28,6 +28,10 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/secret-file-runtime", () => ({
|
||||
tryReadSecretFileSync: (pathname: string) => readFileSyncMock(pathname, "utf8"),
|
||||
}));
|
||||
|
||||
import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js";
|
||||
|
||||
describe("anthropic-vertex ADC reads", () => {
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
* Anthropic Vertex region, project, and ADC auth detection helpers. They keep
|
||||
* credential probing local to the provider plugin.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { GoogleAuthOptions } from "google-auth-library";
|
||||
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
|
||||
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
|
||||
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
const ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
type AdcProjectFile = {
|
||||
type AnthropicVertexAdcCredentials = NonNullable<GoogleAuthOptions["credentials"]> & {
|
||||
project_id?: unknown;
|
||||
quota_project_id?: unknown;
|
||||
};
|
||||
@@ -107,14 +109,27 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate(
|
||||
return resolveAnthropicVertexDefaultAdcPath(env);
|
||||
}
|
||||
|
||||
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
export function resolveAnthropicVertexAdcCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AnthropicVertexAdcCredentials | undefined {
|
||||
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
|
||||
if (!credentialsPath) {
|
||||
return false;
|
||||
const text = tryReadSecretFileSync(credentialsPath, "Anthropic Vertex ADC credentials", {
|
||||
maxBytes: ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES,
|
||||
rejectHardlinks: false,
|
||||
});
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`Anthropic Vertex ADC credentials must be a JSON object: ${credentialsPath}`);
|
||||
}
|
||||
return parsed as AnthropicVertexAdcCredentials;
|
||||
}
|
||||
|
||||
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
try {
|
||||
readFileSync(credentialsPath, "utf8");
|
||||
return true;
|
||||
return resolveAnthropicVertexAdcCredentials(env) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -123,12 +138,11 @@ function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolea
|
||||
function resolveAnthropicVertexProjectIdFromAdc(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
|
||||
if (!credentialsPath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
|
||||
const parsed = resolveAnthropicVertexAdcCredentials(env);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
normalizeOptionalSecretInput(parsed.project_id) ||
|
||||
normalizeOptionalSecretInput(parsed.quota_project_id)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Anthropic Vertex tests cover stream runtime plugin behavior.
|
||||
import { once } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
@@ -173,6 +176,44 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes bounded ADC credentials to google-auth-library", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-anthropic-vertex-stream-adc-"));
|
||||
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
|
||||
const credentials = {
|
||||
type: "service_account",
|
||||
project_id: "vertex-project",
|
||||
};
|
||||
const json = JSON.stringify(credentials);
|
||||
const env = { GOOGLE_APPLICATION_CREDENTIALS: credentialsPath } as NodeJS.ProcessEnv;
|
||||
const { deps, googleAuthCtorMock } = createStreamDeps();
|
||||
try {
|
||||
writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`);
|
||||
createAnthropicVertexStreamFnForModel({}, env, deps);
|
||||
expect(googleAuthCtorMock).toHaveBeenCalledWith({
|
||||
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
credentials,
|
||||
clientOptions: {
|
||||
transporterOptions: { fetchImplementation: expect.any(Function) },
|
||||
},
|
||||
});
|
||||
|
||||
writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`);
|
||||
let readError: unknown;
|
||||
try {
|
||||
createAnthropicVertexStreamFnForModel({}, env, deps);
|
||||
} catch (error) {
|
||||
readError = error;
|
||||
}
|
||||
expect(readError).toMatchObject({
|
||||
name: "FsSafeError",
|
||||
code: "too-large",
|
||||
message: `Anthropic Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses provider-local proxy-aware fetch without mutating the global window", async () => {
|
||||
const { deps, anthropicVertexCtorMock, googleAuthCtorMock, googleAuthClient } =
|
||||
createStreamDeps();
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
supportsClaudeNativeXhighEffort,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
|
||||
import {
|
||||
resolveAnthropicVertexAdcCredentials,
|
||||
resolveAnthropicVertexClientRegion,
|
||||
resolveAnthropicVertexProjectId,
|
||||
} from "./region.js";
|
||||
|
||||
const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
||||
|
||||
@@ -156,11 +160,14 @@ export function createAnthropicVertexStreamFn(
|
||||
region: string,
|
||||
baseURL?: string,
|
||||
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): StreamFn {
|
||||
// GoogleAuth carries clientOptions into file-backed ADC clients. Keep the
|
||||
// proxy-aware transport provider-local; a window shim changes detection globally.
|
||||
const adcConfig = resolveAnthropicVertexAdcCredentials(env);
|
||||
const googleAuth = new deps.GoogleAuth({
|
||||
scopes: [GOOGLE_CLOUD_PLATFORM_SCOPE],
|
||||
...(adcConfig ? { credentials: adcConfig } : {}),
|
||||
clientOptions: {
|
||||
transporterOptions: { fetchImplementation: googleAuthFetch },
|
||||
},
|
||||
@@ -291,5 +298,6 @@ export function createAnthropicVertexStreamFnForModel(
|
||||
}),
|
||||
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
|
||||
deps,
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1111,6 +1111,36 @@ describe("google transport stream", () => {
|
||||
expect(tokenFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds Google Vertex ADC files before google-auth-library reads them", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-file-"));
|
||||
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
|
||||
const credentials = {
|
||||
type: "service_account",
|
||||
client_email: "vertex@example.iam.gserviceaccount.com",
|
||||
};
|
||||
const json = JSON.stringify(credentials);
|
||||
await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`, "utf8");
|
||||
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
|
||||
googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.file-token");
|
||||
|
||||
await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).resolves.toEqual({
|
||||
Authorization: "Bearer ya29.file-token",
|
||||
});
|
||||
expect(googleAuthMock).toHaveBeenCalledWith({
|
||||
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
credentials,
|
||||
clientOptions: { transporterOptions: { timeout: 30_000 } },
|
||||
});
|
||||
|
||||
resetGoogleVertexAdcState();
|
||||
await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`, "utf8");
|
||||
await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).rejects.toMatchObject({
|
||||
name: "FsSafeError",
|
||||
code: "too-large",
|
||||
message: `Google Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds google-auth-library ADC token resolution at the Vertex owner", async () => {
|
||||
const tempDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-google-vertex-authlib-timeout-"),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Google plugin module implements vertex adc behavior.
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import type { GoogleAuthOptions } from "google-auth-library";
|
||||
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
@@ -149,19 +150,25 @@ function resolveGoogleApplicationCredentialsPath(
|
||||
return existsSync(appDataFallback) ? appDataFallback : undefined;
|
||||
}
|
||||
|
||||
async function readGoogleAuthorizedUserCredentials(
|
||||
credentialsPath: string,
|
||||
): Promise<GoogleAuthorizedUserCredentials | undefined> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(credentialsPath, "utf8")) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
type GoogleAdcConfig = NonNullable<GoogleAuthOptions["credentials"]>;
|
||||
const GOOGLE_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
function readGoogleAdcCredentials(adcPath: string): GoogleAdcConfig {
|
||||
const text = readSecretFileSync(adcPath, "Google Vertex ADC credentials", {
|
||||
maxBytes: GOOGLE_VERTEX_ADC_FILE_MAX_BYTES,
|
||||
rejectHardlinks: false,
|
||||
});
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
throw new Error(`Google Vertex ADC credentials must be a JSON object: ${adcPath}`);
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
return parsed as GoogleAdcConfig;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthorizedUserCredentials(
|
||||
adcConfig: GoogleAdcConfig,
|
||||
): GoogleAuthorizedUserCredentials | undefined {
|
||||
const record = adcConfig as Record<string, unknown>;
|
||||
if (record.type !== "authorized_user") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -175,11 +182,7 @@ async function readGoogleAuthorizedUserCredentials(
|
||||
|
||||
function readGoogleAdcCredentialsTypeSync(credentialsPath: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const type = (parsed as { type?: unknown }).type;
|
||||
const type = (readGoogleAdcCredentials(credentialsPath) as { type?: unknown }).type;
|
||||
return typeof type === "string" ? type : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -353,7 +356,9 @@ function shouldGunzipGoogleOauthTokenResponse(
|
||||
.includes("gzip");
|
||||
}
|
||||
|
||||
async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
async function resolveGoogleVertexAccessTokenViaGoogleAuth(
|
||||
adcConfig?: GoogleAdcConfig,
|
||||
): Promise<string> {
|
||||
// Lazy-import + cache so we don't pay the google-auth-library load cost on
|
||||
// gateway startup; only when we actually need a non-authorized_user token.
|
||||
if (!cachedGoogleAuthClient) {
|
||||
@@ -367,6 +372,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
// It also caches tokens internally and refreshes before expiry.
|
||||
return new GoogleAuth({
|
||||
scopes: [GOOGLE_VERTEX_OAUTH_SCOPE],
|
||||
...(adcConfig ? { credentials: adcConfig } : {}),
|
||||
// Best-effort cancellation for clients that use the shared transporter.
|
||||
// WIF STS and GCE metadata need the owner-level deadline below.
|
||||
clientOptions: {
|
||||
@@ -442,13 +448,15 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
export async function resolveGoogleVertexAuthorizedUserHeaders(
|
||||
fetchImpl?: typeof fetch,
|
||||
): Promise<Record<string, string>> {
|
||||
const credentialsPath = resolveGoogleApplicationCredentialsPath();
|
||||
if (credentialsPath) {
|
||||
const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath);
|
||||
if (credentials) {
|
||||
const adcPath = resolveGoogleApplicationCredentialsPath();
|
||||
let adcConfig: GoogleAdcConfig | undefined;
|
||||
if (adcPath) {
|
||||
adcConfig = readGoogleAdcCredentials(adcPath);
|
||||
const userAdc = resolveGoogleAuthorizedUserCredentials(adcConfig);
|
||||
if (userAdc) {
|
||||
const token = await refreshGoogleVertexAuthorizedUserAccessToken({
|
||||
credentialsPath,
|
||||
credentials,
|
||||
credentialsPath: adcPath,
|
||||
credentials: userAdc,
|
||||
fetchImpl,
|
||||
});
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
@@ -457,6 +465,6 @@ export async function resolveGoogleVertexAuthorizedUserHeaders(
|
||||
// No file-based authorized_user ADC. Fall back to google-auth-library which
|
||||
// handles GKE Workload Identity (metadata server), Workload Identity
|
||||
// Federation (external_account), and service-account keys.
|
||||
const token = await resolveGoogleVertexAccessTokenViaGoogleAuth();
|
||||
const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(adcConfig);
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
@@ -129,6 +129,40 @@ describe("mantis discord smoke runtime", () => {
|
||||
expect(await fs.readFile(result.reportPath, "utf8")).not.toContain("test-token");
|
||||
});
|
||||
|
||||
it("bounds Mantis Discord token files", async () => {
|
||||
await fs.writeFile(tokenFile, "x".repeat(4 * 1024), "utf8");
|
||||
const boundaryResult = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/token-boundary",
|
||||
tokenFile,
|
||||
skipPost: true,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
expect(boundaryResult.status).toBe("pass");
|
||||
const fetchCallsAtBoundary = fetchWithSsrFGuard.mock.calls.length;
|
||||
|
||||
await fs.writeFile(tokenFile, "x".repeat(4 * 1024 + 1), "utf8");
|
||||
const oversizedResult = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/token-oversized",
|
||||
tokenFile,
|
||||
skipPost: true,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
|
||||
expect(oversizedResult.status).toBe("fail");
|
||||
expect(fetchWithSsrFGuard.mock.calls).toHaveLength(fetchCallsAtBoundary);
|
||||
expect(await fs.readFile(path.join(oversizedResult.outputDir, "error.txt"), "utf8")).toContain(
|
||||
`Mantis Discord token file at ${tokenFile} exceeds 4096 bytes.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("supports visibility-only smoke runs", async () => {
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js";
|
||||
@@ -102,6 +103,7 @@ const DEFAULT_GUILD_ID_ENV = "OPENCLAW_QA_DISCORD_GUILD_ID";
|
||||
const DEFAULT_CHANNEL_ID_ENV = "OPENCLAW_QA_DISCORD_CHANNEL_ID";
|
||||
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
|
||||
const DISCORD_API_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const MANTIS_DISCORD_TOKEN_FILE_MAX_BYTES = 4 * 1024;
|
||||
|
||||
function assertDiscordSnowflake(value: string, label: string) {
|
||||
if (!/^\d{17,20}$/u.test(value)) {
|
||||
@@ -110,11 +112,10 @@ function assertDiscordSnowflake(value: string, label: string) {
|
||||
}
|
||||
|
||||
async function readTokenFile(filePath: string) {
|
||||
const token = trimToValue(await fs.readFile(filePath, "utf8"));
|
||||
if (!token) {
|
||||
throw new Error(`Mantis Discord token file is empty: ${filePath}`);
|
||||
}
|
||||
return token;
|
||||
return readSecretFileSync(filePath, "Mantis Discord token", {
|
||||
maxBytes: MANTIS_DISCORD_TOKEN_FILE_MAX_BYTES,
|
||||
rejectHardlinks: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveMantisDiscordToken(opts: MantisDiscordSmokeOptions) {
|
||||
|
||||
+10
-2
@@ -1,8 +1,8 @@
|
||||
// Gh Read script supports OpenClaw repository automation.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createPrivateKey, createSign } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readSecretFileSync } from "@openclaw/fs-safe/secret";
|
||||
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
||||
import { readBoundedResponseText } from "./lib/bounded-response.ts";
|
||||
import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts";
|
||||
@@ -21,6 +21,7 @@ const API_VERSION = "2022-11-28";
|
||||
const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000;
|
||||
const GITHUB_ERROR_BODY_MAX_CHARS = 4096;
|
||||
const GITHUB_JSON_BODY_MAX_BYTES = 1024 * 1024;
|
||||
const GITHUB_APP_PRIVATE_KEY_MAX_BYTES = 64 * 1024;
|
||||
const DEFAULT_READ_PERMISSION_KEYS = [
|
||||
"actions",
|
||||
"checks",
|
||||
@@ -352,6 +353,13 @@ async function createInstallationToken(
|
||||
return tokenResponse.token;
|
||||
}
|
||||
|
||||
export function readGitHubAppPrivateKey(filePath: string): string {
|
||||
return readSecretFileSync(filePath, "GitHub App private key", {
|
||||
maxBytes: GITHUB_APP_PRIVATE_KEY_MAX_BYTES,
|
||||
rejectHardlinks: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.length <= 2) {
|
||||
fail(
|
||||
@@ -362,7 +370,7 @@ async function main() {
|
||||
const ghArgs = process.argv.slice(2);
|
||||
const appId = readRequiredEnv(APP_ID_ENV);
|
||||
const privateKeyPath = readRequiredEnv(KEY_FILE_ENV);
|
||||
const privateKeyPem = readFileSync(privateKeyPath, "utf8");
|
||||
const privateKeyPem = readGitHubAppPrivateKey(privateKeyPath);
|
||||
const repo = resolveRepo(ghArgs);
|
||||
const appJwt = createAppJwt(appId, privateKeyPem);
|
||||
const installation = await resolveInstallation(appJwt, repo);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Gh Read tests cover gh read script behavior.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildReadPermissions,
|
||||
@@ -7,6 +10,7 @@ import {
|
||||
normalizeRepo,
|
||||
parsePermissionKeys,
|
||||
parseRepoArg,
|
||||
readGitHubAppPrivateKey,
|
||||
readBoundedGitHubErrorText,
|
||||
readBoundedGitHubJson,
|
||||
resolveGitHubFetchTimeoutMs,
|
||||
@@ -78,6 +82,30 @@ describe("gh-read helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds GitHub App private key files", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-gh-read-key-"));
|
||||
const privateKeyPath = path.join(tempDir, "app.pem");
|
||||
try {
|
||||
writeFileSync(privateKeyPath, "x".repeat(64 * 1024));
|
||||
expect(readGitHubAppPrivateKey(privateKeyPath)).toHaveLength(64 * 1024);
|
||||
|
||||
writeFileSync(privateKeyPath, "x".repeat(64 * 1024 + 1));
|
||||
let readError: unknown;
|
||||
try {
|
||||
readGitHubAppPrivateKey(privateKeyPath);
|
||||
} catch (error) {
|
||||
readError = error;
|
||||
}
|
||||
expect(readError).toMatchObject({
|
||||
name: "FsSafeError",
|
||||
code: "too-large",
|
||||
message: `GitHub App private key file at ${privateKeyPath} exceeds 65536 bytes.`,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts stalled GitHub API fetches at the request timeout", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
let markFetchStarted!: () => void;
|
||||
|
||||
Reference in New Issue
Block a user