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
20 changes: 20 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ export async function run(): Promise<void> {
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 });
Expand Down
50 changes: 50 additions & 0 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down