From 0d78cba05f8d1501b10acf7a81be47d0b1d3c784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Burak=20=C5=9EEN?= Date: Tue, 28 Apr 2026 20:06:07 +0300 Subject: [PATCH] fix: validate universe is a bare hostname to prevent SSRF credential leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `universe` input is interpolated directly into the storage API endpoint (`https://storage.${universe}/...`). 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). Validate that `universe` is a well-formed DNS hostname (no scheme, path, port, userinfo, query, or fragment) before it is used. This blocks URL-injection payloads while still accepting any legitimate universe — googleapis.com, Trusted Partner Cloud, and Google Distributed Cloud domains — without an allowlist that would break sovereign deployments. --- src/main.ts | 20 +++++++++++++++++++ tests/main.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/main.ts b/src/main.ts index 780975b..6a731d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,6 +59,26 @@ export async function run(): Promise { const projectID = core.getInput('project_id'); const universe = core.getInput('universe') || 'googleapis.com'; + // The universe value is interpolated directly into the storage API endpoint + // (https://storage.${universe}/...), so a value carrying URL syntax can + // redirect the request — and the GCP access token in the Authorization + // header — to an attacker-controlled host. For example "attacker.com#" + // truncates the real host via the fragment delimiter. Require a well-formed + // DNS hostname (no scheme, path, port, userinfo, query, or fragment) so the + // value can only ever be a host. This still accepts any legitimate universe + // — googleapis.com, Trusted Partner Cloud, and Google Distributed Cloud + // domains — without an allowlist that would break sovereign deployments. + if ( + !/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.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.`, + ); + } + // GCS inputs const root = core.getInput('path', { required: true }); const destination = core.getInput('destination', { required: true }); diff --git a/tests/main.test.ts b/tests/main.test.ts index 20d1c2a..2a6b7ad 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -71,6 +71,56 @@ test('#run', { concurrency: true }, async (suite) => { }); }); + await suite.test('rejects universe carrying URL syntax (SSRF guard)', async (t) => { + // run() surfaces failures via core.setFailed rather than throwing, so spy + // on it instead of asserting a rejection. + const failed = t.mock.method(core, 'setFailed', () => {}); + + 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', // embedded whitespace (leading/trailing is trimmed by getInput) + ]) { + setInputs({ path: './testdata', destination: 'my-bucket', universe }); + await run(); + const last = String(failed.mock.calls.at(-1)?.arguments?.[0] ?? ''); + assert.match(last, /invalid universe/i, `expected rejection for "${universe}"`); + } + }); + + await suite.test('accepts non-googleapis.com universes (TPC / GDC)', async (t) => { + const uploadMock = t.mock.method(Bucket.prototype, 'upload', mockUpload); + const failed = t.mock.method(core, 'setFailed', () => {}); + + for (const universe of ['googleapis.com', 'us-central1.rep.googleapis.com', 'apis-tpc.goog']) { + setInputs({ + path: './testdata', + destination: 'my-bucket', + universe, + process_gcloudignore: 'false', + }); + + await run(); + + // Hostname validation must not reject a legitimate universe. The upload + // itself may still fail without real credentials, so only assert that no + // universe-validation failure was reported. + for (const call of failed.mock.calls) { + assert.doesNotMatch( + String(call.arguments?.[0] ?? ''), + /invalid universe/i, + `unexpected universe validation failure for "${universe}"`, + ); + } + } + + uploadMock.mock.restore(); + }); + await suite.test('uploads all files', async (t) => { const uploadMock = t.mock.method(Bucket.prototype, 'upload', mockUpload);