Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'));
Expand Down
30 changes: 30 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
}
}
29 changes: 28 additions & 1 deletion tests/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -182,3 +182,30 @@ async function getFilesInZip(zipFilePath: string): Promise<string[]> {
}
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`,
);
}
});
});