From 1696ecdaa98adb792ed49dcc42478fc7005377ad Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:38:21 -0700 Subject: [PATCH] fix(security): add defense-in-depth username validation in GCP startup script (#2689) Add explicit username format validation (`/^[a-zA-Z0-9_-]+$/`) as defense-in-depth in `getStartupScript()` and `createInstance()`. While `resolveUsername()` currently returns a constant, this belt-and-suspenders check prevents shell injection if the function is ever changed to accept dynamic input. Fixes #2688 Agent: ux-engineer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/src/gcp/gcp.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cli/src/gcp/gcp.ts b/packages/cli/src/gcp/gcp.ts index 8651e161..d8ff6f75 100644 --- a/packages/cli/src/gcp/gcp.ts +++ b/packages/cli/src/gcp/gcp.ts @@ -647,10 +647,23 @@ async function ensureSshKey(): Promise { const GCP_SSH_USER = "root"; +/** Defense-in-depth: allowed username pattern (alphanumeric, underscore, hyphen). */ +const SAFE_USERNAME_RE = /^[a-zA-Z0-9_-]+$/; + function resolveUsername(): string { return GCP_SSH_USER; } +/** Assert username is safe for shell interpolation (defense-in-depth). */ +function assertSafeUsername(username: string): void { + if (!SAFE_USERNAME_RE.test(username)) { + throw new Error( + `Invalid GCP username '${username}': must match /^[a-zA-Z0-9_-]+$/. ` + + "This is a defense-in-depth check — the username should already be validated upstream.", + ); + } +} + // ─── Server Name ──────────────────────────────────────────────────────────── export async function getServerName(): Promise { @@ -664,6 +677,11 @@ export async function promptSpawnName(): Promise { // ─── Cloud Init Startup Script ────────────────────────────────────────────── function getStartupScript(tier: CloudInitTier = "full"): string { + // Defense-in-depth: validate username before any shell interpolation. + // resolveUsername() currently returns a constant, but if it ever changes + // to accept dynamic input, this prevents shell injection in the startup script. + assertSafeUsername(resolveUsername()); + const packages = getPackagesForTier(tier); const lines = [ "#!/bin/bash", @@ -705,6 +723,7 @@ export async function createInstance( tier?: CloudInitTier, ): Promise { const username = resolveUsername(); + assertSafeUsername(username); const pubKeys = await ensureSshKey(); // Build ssh-keys metadata: one "user:key" entry per line const sshKeysMetadata = pubKeys