security(E3): gate GSTACK_SLUG on /welcome path traversal

The /welcome handler interpolates GSTACK_SLUG directly into the filesystem
path used to locate the project-local welcome page. Without validation, a
slug like "../../etc/passwd" would resolve to
~/.gstack/projects/../../etc/passwd/designs/welcome-page-20260331/finalized.html
— classic path traversal.

Not exploitable today: GSTACK_SLUG is set by the gstack CLI at daemon launch,
and an attacker would already need local env-var access to poison it. But
the gate is one regex (^[a-z0-9_-]+$), and a defense-in-depth pass costs us
nothing when the cost of being wrong is arbitrary file read via /welcome.

Fall back to the safe 'unknown' literal when the slug fails validation —
same fallback the code already uses when GSTACK_SLUG is unset. No behavior
change for legitimate slugs (they all match the regex).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-21 20:37:37 -07:00
parent 202b4308b1
commit 49263b3d10
2 changed files with 21 additions and 2 deletions
+7 -2
View File
@@ -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;
+14
View File
@@ -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'");
});
});