Files
openclaw/extensions/googlechat/src/google-auth.runtime.ts
Vincent Koc 6d6845ea9d fix(googlechat): harden google auth transport (#69812)
* fix(googlechat): localize google auth gaxios compat

* fix(googlechat): declare undici for staged runtime deps

* fix(googlechat): harden google auth transport

* fix(googlechat): narrow credential file reads

* fix(googlechat): preserve auth proxy transport

* fix(googlechat): allow symlinked auth files

* fix(googlechat): atomically load auth files

* fix(googlechat): eagerly buffer auth responses

* fix(googlechat): cap auth response buffering

* fix(googlechat): pin staged auth runtime deps

* fix(googlechat): buffer auth responses as array buffers

* Update CHANGELOG.md

* fix(googlechat): reject unstreamed auth responses

* fix(googlechat): use ambient fetch for auth transport

* fix(googlechat): keep guarded auth fetch on runtime path

* fix(googlechat): align staged zod range

* chore(lockfile): sync googlechat zod spec
2026-04-21 22:40:57 -07:00

540 lines
16 KiB
TypeScript

import fs from "node:fs/promises";
import type { ConnectionOptions } from "node:tls";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
import {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
fetchWithSsrFGuard,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
type ProxyRule = RegExp | URL | string;
type TlsCert = ConnectionOptions["cert"];
type TlsKey = ConnectionOptions["key"];
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type GoogleAuthModule = typeof import("google-auth-library");
type GaxiosModule = typeof import("gaxios");
type GoogleAuthRuntime = {
Gaxios: GaxiosModule["Gaxios"];
GoogleAuth: GoogleAuthModule["GoogleAuth"];
OAuth2Client: GoogleAuthModule["OAuth2Client"];
};
type GoogleAuthTransport = InstanceType<GaxiosModule["Gaxios"]>;
type GuardedGoogleAuthRequestInit = RequestInit & {
agent?: unknown;
cert?: unknown;
dispatcher?: unknown;
fetchImplementation?: unknown;
key?: unknown;
noProxy?: unknown;
proxy?: unknown;
};
type TlsOptions = {
cert?: TlsCert;
key?: TlsKey;
};
type ProxyAgentLike = {
connectOpts?: TlsOptions;
proxy: URL;
};
type TlsAgentLike = {
options?: TlsOptions;
};
type GoogleChatServiceAccountCredentials = Record<string, unknown> & {
auth_provider_x509_cert_url?: string;
auth_uri?: string;
client_email: string;
client_x509_cert_url?: string;
private_key: string;
token_uri?: string;
type?: string;
universe_domain?: string;
};
const GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES = ["accounts.google.com", "googleapis.com"];
const GOOGLE_AUTH_POLICY = buildHostnameAllowlistPolicyFromSuffixAllowlist(
GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES,
);
const GOOGLE_AUTH_AUDIT_CONTEXT = "googlechat.auth.google-auth";
const GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth";
const GOOGLE_AUTH_PROVIDER_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs";
const GOOGLE_AUTH_TOKEN_URI = "https://oauth2.googleapis.com/token";
const GOOGLE_AUTH_UNIVERSE_DOMAIN = "googleapis.com";
const GOOGLE_CLIENT_CERTS_URL_PREFIX = "https://www.googleapis.com/robot/v1/metadata/x509/";
const MAX_GOOGLE_AUTH_RESPONSE_BYTES = 1024 * 1024;
const MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES = 64 * 1024;
let googleAuthRuntimePromise: Promise<GoogleAuthRuntime> | null = null;
let googleAuthTransportPromise: Promise<GoogleAuthTransport> | null = null;
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function hasProxyAgentShape(value: unknown): value is ProxyAgentLike {
const record = asNullableObjectRecord(value);
return record !== null && record.proxy instanceof URL;
}
function hasTlsAgentShape(value: unknown): value is TlsAgentLike {
const record = asNullableObjectRecord(value);
return record !== null && asNullableObjectRecord(record.options) !== null;
}
function resolveGoogleAuthAgent(init: GuardedGoogleAuthRequestInit, url: URL): unknown {
return typeof init.agent === "function" ? init.agent(url) : init.agent;
}
function hasTlsOptions(options: TlsOptions): boolean {
return options.cert !== undefined || options.key !== undefined;
}
function resolveGoogleAuthTlsOptions(init: GuardedGoogleAuthRequestInit, url: URL): TlsOptions {
const explicit = {
cert: init.cert as TlsCert | undefined,
key: init.key as TlsKey | undefined,
};
if (hasTlsOptions(explicit)) {
return explicit;
}
const agent = resolveGoogleAuthAgent(init, url);
if (hasProxyAgentShape(agent)) {
return {
cert: agent.connectOpts?.cert,
key: agent.connectOpts?.key,
};
}
if (hasTlsAgentShape(agent)) {
return {
cert: agent.options?.cert,
key: agent.options?.key,
};
}
return {};
}
function normalizeGoogleAuthProxyEnvValue(value: string | undefined): string | null | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveGoogleAuthEnvProxyUrl(protocol: "http" | "https"): string | undefined {
const httpProxy =
normalizeGoogleAuthProxyEnvValue(process.env.HTTP_PROXY) ??
normalizeGoogleAuthProxyEnvValue(process.env.http_proxy);
const httpsProxy =
normalizeGoogleAuthProxyEnvValue(process.env.HTTPS_PROXY) ??
normalizeGoogleAuthProxyEnvValue(process.env.https_proxy);
if (protocol === "https") {
return httpsProxy ?? httpProxy ?? undefined;
}
return httpProxy ?? undefined;
}
function collectGoogleAuthNoProxyRules(noProxy: ProxyRule[] = []): ProxyRule[] {
const rules = [...noProxy];
const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? [];
for (const rule of envRules) {
const trimmed = rule.trim();
if (trimmed.length > 0) {
rules.push(trimmed);
}
}
return rules;
}
function shouldBypassGoogleAuthProxy(url: URL, noProxy: ProxyRule[] = []): boolean {
for (const rule of collectGoogleAuthNoProxyRules(noProxy)) {
if (rule instanceof RegExp) {
if (rule.test(url.toString())) {
return true;
}
continue;
}
if (rule instanceof URL) {
if (rule.origin === url.origin) {
return true;
}
continue;
}
if (rule.startsWith("*.") || rule.startsWith(".")) {
const cleanedRule = rule.replace(/^\*\./, ".");
if (url.hostname.endsWith(cleanedRule)) {
return true;
}
continue;
}
if (rule === url.origin || rule === url.hostname || rule === url.href) {
return true;
}
}
return false;
}
function readGoogleAuthProxyUrl(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (value instanceof URL) {
return value.toString();
}
return undefined;
}
function readOptionalTrimmedString(
record: Record<string, unknown>,
fieldName: string,
): string | undefined {
const value = record[fieldName];
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`Google Chat service account field "${fieldName}" must be a string`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`Google Chat service account field "${fieldName}" cannot be empty`);
}
return trimmed;
}
function readRequiredTrimmedString(record: Record<string, unknown>, fieldName: string): string {
return (
readOptionalTrimmedString(record, fieldName) ??
(() => {
throw new Error(`Google Chat service account is missing "${fieldName}"`);
})()
);
}
function assertExactUrlField(
record: Record<string, unknown>,
fieldName: string,
expectedUrl: string,
): void {
const value = readOptionalTrimmedString(record, fieldName);
if (!value) {
return;
}
if (value !== expectedUrl) {
throw new Error(
`Google Chat service account field "${fieldName}" must be ${expectedUrl}, got ${value}`,
);
}
}
function assertUrlPrefixField(
record: Record<string, unknown>,
fieldName: string,
expectedPrefix: string,
): void {
const value = readOptionalTrimmedString(record, fieldName);
if (!value) {
return;
}
if (!value.startsWith(expectedPrefix)) {
throw new Error(
`Google Chat service account field "${fieldName}" must start with ${expectedPrefix}, got ${value}`,
);
}
}
function validateGoogleChatServiceAccountCredentials(
credentials: Record<string, unknown>,
): GoogleChatServiceAccountCredentials {
const type = readOptionalTrimmedString(credentials, "type");
if (type && type !== "service_account") {
throw new Error(`Google Chat credentials must use service_account auth, got "${type}" instead`);
}
readRequiredTrimmedString(credentials, "client_email");
readRequiredTrimmedString(credentials, "private_key");
const universeDomain = readOptionalTrimmedString(credentials, "universe_domain");
if (universeDomain && universeDomain !== GOOGLE_AUTH_UNIVERSE_DOMAIN) {
throw new Error(
`Google Chat service account field "universe_domain" must be ${GOOGLE_AUTH_UNIVERSE_DOMAIN}, got ${universeDomain}`,
);
}
assertExactUrlField(credentials, "auth_uri", GOOGLE_AUTH_URI);
assertExactUrlField(credentials, "auth_provider_x509_cert_url", GOOGLE_AUTH_PROVIDER_CERTS_URL);
assertExactUrlField(credentials, "token_uri", GOOGLE_AUTH_TOKEN_URI);
assertUrlPrefixField(credentials, "client_x509_cert_url", GOOGLE_CLIENT_CERTS_URL_PREFIX);
return credentials as GoogleChatServiceAccountCredentials;
}
async function readCredentialsFile(filePath: string): Promise<Record<string, unknown>> {
const resolvedPath = resolveUserPath(filePath);
if (!resolvedPath) {
throw new Error("Google Chat service account file path is empty");
}
let handle: Awaited<ReturnType<typeof fs.open>> | null = null;
try {
handle = await fs.open(resolvedPath, "r");
} catch {
throw new Error("Failed to load Google Chat service account file.");
}
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error("Google Chat service account file must be a regular file.");
}
if (stat.size > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) {
throw new Error(
`Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`,
);
}
let raw: string;
try {
raw = await handle.readFile({ encoding: "utf8" });
} catch {
throw new Error("Failed to load Google Chat service account file.");
}
if (Buffer.byteLength(raw, "utf8") > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) {
throw new Error(
`Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Invalid Google Chat service account JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Google Chat service account file must contain a JSON object.");
}
return parsed as Record<string, unknown>;
} finally {
await handle.close().catch(() => {});
}
}
function sanitizeGoogleAuthInit(init?: RequestInit): RequestInit | undefined {
if (!init) {
return undefined;
}
const nextInit = { ...(init as GuardedGoogleAuthRequestInit) };
delete nextInit.agent;
delete nextInit.cert;
delete nextInit.dispatcher;
delete nextInit.fetchImplementation;
delete nextInit.key;
delete nextInit.noProxy;
delete nextInit.proxy;
return nextInit;
}
function resolveGoogleAuthDispatcherPolicy(
input: RequestInfo | URL,
init?: RequestInit,
): {
dispatcherPolicy?: PinnedDispatcherPolicy;
init?: RequestInit;
} {
const requestUrl =
input instanceof Request
? new URL(input.url)
: new URL(typeof input === "string" ? input : input.toString());
const nextInit = sanitizeGoogleAuthInit(init);
const googleAuthInit = (init ?? {}) as GuardedGoogleAuthRequestInit;
const tlsOptions = resolveGoogleAuthTlsOptions(googleAuthInit, requestUrl);
const proxyBypassed = shouldBypassGoogleAuthProxy(
requestUrl,
Array.isArray(googleAuthInit.noProxy) ? (googleAuthInit.noProxy as ProxyRule[]) : [],
);
const agent = resolveGoogleAuthAgent(googleAuthInit, requestUrl);
const explicitProxy =
readGoogleAuthProxyUrl(googleAuthInit.proxy) ??
(hasProxyAgentShape(agent) ? agent.proxy.toString() : undefined);
if (!proxyBypassed && explicitProxy) {
return {
dispatcherPolicy: {
allowPrivateProxy: true,
mode: "explicit-proxy",
...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}),
proxyUrl: explicitProxy,
},
init: nextInit,
};
}
const envProxyUrl = proxyBypassed
? undefined
: resolveGoogleAuthEnvProxyUrl(requestUrl.protocol === "http:" ? "http" : "https");
if (envProxyUrl) {
return {
dispatcherPolicy: {
mode: "env-proxy",
...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}),
},
init: nextInit,
};
}
if (hasTlsOptions(tlsOptions)) {
return {
dispatcherPolicy: {
connect: { ...tlsOptions },
mode: "direct",
},
init: nextInit,
};
}
return { init: nextInit };
}
export function createGoogleAuthFetch(baseFetch?: FetchLike): FetchLike {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof Request ? input.url : String(input);
const guardedOptions = resolveGoogleAuthDispatcherPolicy(input, init);
const { response, release } = await fetchWithSsrFGuard({
auditContext: GOOGLE_AUTH_AUDIT_CONTEXT,
dispatcherPolicy: guardedOptions.dispatcherPolicy,
init: guardedOptions.init,
policy: GOOGLE_AUTH_POLICY,
url,
...(baseFetch ? { fetchImpl: baseFetch } : {}),
});
try {
const body = await readGoogleAuthResponseBytes(response);
const bufferedBody = Uint8Array.from(body);
return new Response(bufferedBody.buffer, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
} finally {
await release();
}
};
}
async function readGoogleAuthResponseBytes(response: Response): Promise<Uint8Array> {
const contentLengthHeader = response.headers.get("content-length");
if (contentLengthHeader) {
const contentLength = Number(contentLengthHeader);
if (Number.isFinite(contentLength) && contentLength > MAX_GOOGLE_AUTH_RESPONSE_BYTES) {
throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`);
}
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error(
"Google auth response body stream unavailable; refusing to buffer unbounded response.",
);
}
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
total += value.byteLength;
if (total > MAX_GOOGLE_AUTH_RESPONSE_BYTES) {
try {
await reader.cancel("Google auth response exceeded buffer limit");
} catch {
// Ignore cancellation errors; the caller still releases the dispatcher.
}
throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`);
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
export async function loadGoogleAuthRuntime(): Promise<GoogleAuthRuntime> {
if (!googleAuthRuntimePromise) {
googleAuthRuntimePromise = (async () => {
try {
const [googleAuthModule, gaxiosModule] = await Promise.all([
import("google-auth-library"),
import("gaxios"),
]);
return {
Gaxios: gaxiosModule.Gaxios,
GoogleAuth: googleAuthModule.GoogleAuth,
OAuth2Client: googleAuthModule.OAuth2Client,
};
} catch (error) {
googleAuthRuntimePromise = null;
throw error;
}
})();
}
return await googleAuthRuntimePromise;
}
export async function getGoogleAuthTransport(): Promise<GoogleAuthTransport> {
if (!googleAuthTransportPromise) {
googleAuthTransportPromise = (async () => {
try {
const { Gaxios } = await loadGoogleAuthRuntime();
return new Gaxios({
fetchImplementation: createGoogleAuthFetch(),
});
} catch (error) {
googleAuthTransportPromise = null;
throw error;
}
})();
}
return await googleAuthTransportPromise;
}
export async function resolveValidatedGoogleChatCredentials(
account: ResolvedGoogleChatAccount,
): Promise<GoogleChatServiceAccountCredentials | null> {
if (account.credentials) {
return validateGoogleChatServiceAccountCredentials(account.credentials);
}
if (account.credentialsFile) {
const fileCredentials = await readCredentialsFile(account.credentialsFile);
return validateGoogleChatServiceAccountCredentials(fileCredentials);
}
return null;
}
export const __testing = {
resetGoogleAuthRuntimeForTests(): void {
googleAuthRuntimePromise = null;
googleAuthTransportPromise = null;
},
resolveGoogleAuthEnvProxyUrl,
validateGoogleChatServiceAccountCredentials,
};