diff --git a/src/main.ts b/src/main.ts index aab606c..41ec229 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,7 +40,13 @@ import { RetryPolicy, VpcConnectorEgressSettings, } from './client'; -import { formatEntry, parseEventTriggerFilters, parseSecrets, stringToInt } from './util'; +import { + formatEntry, + parseEventTriggerFilters, + parseSecrets, + stringToInt, + validateUniverse, +} from './util'; async function run() { try { @@ -49,6 +55,11 @@ async function run() { const region = presence(getInput('region')) || 'us-central1'; const universe = getInput('universe') || 'googleapis.com'; + // Validate universe before it is interpolated into the Cloud Functions + // endpoint URL, otherwise a value carrying URL syntax can route the + // credentialed request to an attacker-controlled host (SSRF). + validateUniverse(universe); + // top-level inputs const name = getInput('name', { required: true }); const description = presence(getInput('description')); diff --git a/src/util.ts b/src/util.ts index 9bc2e60..e4aab40 100644 --- a/src/util.ts +++ b/src/util.ts @@ -225,3 +225,33 @@ export function parseSecrets( return [secretEnvVars, secretVolumes]; } + +/** + * universePattern matches a well-formed DNS hostname (RFC 1123 labels, total + * length <= 253). It deliberately excludes scheme, path, port, userinfo, query, + * and fragment characters so the value can only ever be a host. + */ +const universePattern = + /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/; + +/** + * validateUniverse ensures the universe value is a bare DNS hostname before it + * is interpolated into the Cloud Functions API endpoint + * (`https://cloudfunctions.${universe}/v2`). A value carrying URL syntax can + * redirect the credentialed request — including the GCP access token in the + * Authorization header — to an attacker-controlled host (e.g. "attacker.com#" + * truncates the real host via the fragment delimiter). Validating hostname form + * (rather than allowlisting googleapis.com) still accepts every legitimate + * universe — googleapis.com, Trusted Partner Cloud, and Google Distributed + * Cloud domains — without breaking sovereign deployments. + * + * @param universe Universe value to validate. + */ +export function validateUniverse(universe: string): void { + if (!universePattern.test(universe)) { + throw new Error( + `Invalid universe "${universe}": must be a bare DNS hostname ` + + `(e.g. "googleapis.com"), with no scheme, path, port, or other URL characters.`, + ); + } +} diff --git a/tests/util.test.ts b/tests/util.test.ts index 442c0b6..edf7a58 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert'; import StreamZip from 'node-stream-zip'; import { assertMembers, randomFilepath } from '@google-github-actions/actions-utils'; -import { parseEventTriggerFilters, stringToInt, zipDir } from '../src/util'; +import { parseEventTriggerFilters, stringToInt, validateUniverse, zipDir } from '../src/util'; test('#zipDir', { concurrency: true }, async (suite) => { const cases = [ @@ -182,3 +182,30 @@ async function getFilesInZip(zipFilePath: string): Promise { } return filesInsideZip; } + +test('#validateUniverse', { concurrency: true }, async (suite) => { + await suite.test('accepts legitimate universes (public, TPC, GDC)', () => { + for (const universe of ['googleapis.com', 'us-central1.rep.googleapis.com', 'apis-tpc.goog']) { + assert.doesNotThrow(() => validateUniverse(universe), `expected "${universe}" to be valid`); + } + }); + + await suite.test('rejects values carrying URL syntax (SSRF guard)', () => { + for (const universe of [ + 'attacker.com#.googleapis.com', // fragment truncates the real host + 'attacker.com#', + 'attacker.com/path', + 'attacker.com:8080', + 'user@attacker.com', + 'https://attacker.com', + 'attacker .com', + '', + ]) { + assert.throws( + () => validateUniverse(universe), + /Invalid universe/, + `expected "${universe}" to be rejected`, + ); + } + }); +});