diff --git a/browse/src/server.ts b/browse/src/server.ts index 3941d826..c2bd07f8 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1581,8 +1581,13 @@ async function start() { // Welcome page — served when GStack Browser launches in headed mode if (url.pathname === '/welcome') { const welcomePath = (() => { - // Check project-local designs first, then global - const slug = process.env.GSTACK_SLUG || 'unknown'; + // Gate GSTACK_SLUG on a strict regex BEFORE interpolating it into + // the filesystem path. Without this, a slug like "../../etc/passwd" + // would resolve to ~/.gstack/projects/../../etc/passwd/... — path + // traversal. Not exploitable today (attacker needs local env-var + // access), but the gate is one regex and buys us defense-in-depth. + const rawSlug = process.env.GSTACK_SLUG || 'unknown'; + const slug = /^[a-z0-9_-]+$/.test(rawSlug) ? rawSlug : 'unknown'; const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`; if (fs.existsSync(projectWelcome)) return projectWelcome; diff --git a/browse/test/dual-listener.test.ts b/browse/test/dual-listener.test.ts index aa9ab118..c14966bb 100644 --- a/browse/test/dual-listener.test.ts +++ b/browse/test/dual-listener.test.ts @@ -280,3 +280,17 @@ describe('Rate limit + denial log wiring', () => { expect(registrySrc).not.toMatch(/CONNECT_RATE_LIMIT\s*=\s*3\s*;/); }); }); + +describe('E3: /welcome GSTACK_SLUG path traversal gate', () => { + test('/welcome validates GSTACK_SLUG against ^[a-z0-9_-]+$ before interpolating into path', () => { + const welcomeBlock = sliceBetween( + SERVER_SRC, + "url.pathname === '/welcome'", + 'if (fs.existsSync(projectWelcome)) return projectWelcome;' + ); + // Must validate the slug before using it in a path + expect(welcomeBlock).toMatch(/\/\^\[a-z0-9_-\]\+\$\/\.test\(rawSlug\)/); + // Must fall back to a safe default when the slug fails validation + expect(welcomeBlock).toContain("'unknown'"); + }); +});