From 2f88150bc9ac9c53e434fbfba16a50fc253a4b07 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 22:04:59 +0530 Subject: [PATCH] fix(dashboard): cap env name at 32 chars + strip underscores (BUG-DASH-001/002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-DASH-001 (P0): pre-fix the env-switcher's "+ new env…" input accepted custom env names of arbitrary length and persisted them to localStorage. A 67-char paste broke every subsequent /api/v1/* call: - GET /api/v1/vault/<67-char> → 400 invalid_env - GET /api/v1/resources?env=<67> → 200 with empty list (read-path gap, BUG-DASH-007) - GET /api/v1/deployments?env=<67> → 200 with empty list There was no UI affordance to delete the bad env — users had to open devtools and clear localStorage to recover. BUG-DASH-002: the JS regex was `[^a-z0-9_-]`, permitting underscores. The api regex is `^[a-z0-9-]{1,32}$` (no underscore), so a user typing `my_env_name` would persist locally and then 400 on every API call — same "client says yes, server says no" gap. Fix in addEnv() (src/hooks/useDashboardCtx.ts): 1. Drop underscore from the char-class strip → underscore-bearing input is silently scrubbed, matching the api regex. 2. .slice(0, 32) clips to the api's 32-char cap before any persist. 3. Final ENV_REGEX gate validates the post-strip-and-clip string. If it doesn't match, return early WITHOUT touching state — the live env stays on its previous valid value, so the user is never locked into a broken state. The existing UI plumbing in (AppShell.tsx) already gates addEnv behind a non-empty draft; no UI changes required. Coverage block (per CLAUDE.md rule 17): Symptom: /api/v1/vault/ → 400 invalid_env; user locked out Enumeration: rg -n 'addEnv\|ENV_REGEX' src/hooks/ (1 hit pre-fix) Sites found: 1 (addEnv export) Sites touched: 1 Coverage test: 3 new test cases in useDashboardCtx.test.ts: - addEnv clips an over-32-char input to the api cap - addEnv strips underscores (api regex forbids them) - addEnv leaves live env unchanged on all-invalid input Existing tests ("sanitises ... My Env!!", "ignores all-invalid") still pass — the contract is strictly tightened, not changed. Live verify: open /app, click env-switcher → "+ new env…", type 67 chars → input is clipped to 32, regex-validated, persisted only if it matches; reload and confirm localStorage["instanode.env"] is ≤32 chars + api-shape. Test results: 14 tests pass in useDashboardCtx.test.ts (was 11); full `npm run gate` passes (76 test files, 1072 tests, 0 failures). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useDashboardCtx.test.ts | 42 +++++++++++++++++++++++++++++++ src/hooks/useDashboardCtx.ts | 31 +++++++++++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDashboardCtx.test.ts b/src/hooks/useDashboardCtx.test.ts index 0444309..b4c4053 100644 --- a/src/hooks/useDashboardCtx.test.ts +++ b/src/hooks/useDashboardCtx.test.ts @@ -119,6 +119,48 @@ describe('useDashboardCtx', () => { expect(result.current.envs).toEqual(envsBefore) }) + // BUG-DASH-001 (P0): pre-fix a 67-char input persisted to + // localStorage and broke every subsequent API call. Now the input is + // clipped to the api cap (32 chars). The clipped string must still + // match the api regex `^[a-z0-9-]{1,32}$`. + it('addEnv clips an over-32-char input to the api cap', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + const longInput = 'a'.repeat(67) // 67 chars of valid char class + await act(async () => { mod.addEnv(longInput) }) + // The persisted env name must be ≤ 32 chars (api cap). + expect(result.current.env.length).toBeLessThanOrEqual(32) + expect(result.current.env).toMatch(/^[a-z0-9-]{1,32}$/) + }) + + // BUG-DASH-002: pre-fix the JS regex permitted underscores even + // though the api regex bans them. Underscore inputs would persist + // locally and then 400 on every API call. Now the underscore is + // stripped. + it('addEnv strips underscores (api regex forbids them)', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + await act(async () => { mod.addEnv('my_env_name') }) + // Underscore-stripped form is "myenvname" — the only valid api shape. + expect(result.current.envs.some((e: string) => e.includes('_'))).toBe(false) + expect(result.current.env).toMatch(/^[a-z0-9-]{1,32}$/) + }) + + // BUG-DASH-001 belt: a paste that's entirely punctuation must NOT + // change the live env. Pre-fix the empty-string fall-through worked + // because of `if (!clean)`; we keep that guard via the final regex + // gate. + it('addEnv leaves live env unchanged on all-invalid + over-cap input', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + const envBefore = result.current.env + await act(async () => { mod.addEnv('!!! !!! !!!') }) + expect(result.current.env).toBe(envBefore) + }) + it('resetBootstrap clears state and allows a fresh bootstrap', async () => { const mod = await load() const { result, rerender } = renderHook(() => mod.useDashboardCtx()) diff --git a/src/hooks/useDashboardCtx.ts b/src/hooks/useDashboardCtx.ts index 0b64723..0fb936d 100644 --- a/src/hooks/useDashboardCtx.ts +++ b/src/hooks/useDashboardCtx.ts @@ -88,9 +88,36 @@ export function setEnv(next: string) { refreshCounts() } +// BUG-DASH-001 (P0) + BUG-DASH-002: +// +// Pre-fix this function accepted env names of arbitrary length and +// stripped characters silently, then persisted the result to +// localStorage. A 67-char paste → every subsequent /api/v1/* call +// carried `?env=<67-char-name>` → vault 400 invalid_env, resources +// 200 with empty list, no UI affordance to delete the bad value +// (user had to clear localStorage from devtools). +// +// The api regex is `^[a-z0-9-]{1,32}$` (see api/internal/handlers/env.go +// + the `invalid_env` 400 branch). Underscores are NOT part of the +// api regex; the pre-fix JS regex `[^a-z0-9_-]` permitted them, +// producing names that the api would later reject — a different +// class of the same "client says yes, server says no" gap. +// +// Fix: align the JS regex with the api regex, enforce the 32-char +// cap up front, and return early if validation fails. The caller +// (EnvSwitcher) already gates `addEnv` behind a non-empty draft so +// no UI plumbing changes are required. +const ENV_REGEX = /^[a-z0-9-]{1,32}$/ +const ENV_MAX_LEN = 32 + export function addEnv(name: string) { - const clean = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '') - if (!clean) return + // Lowercase + strip api-invalid chars; underscores no longer survive + // (BUG-DASH-002). Clip to the api cap of 32 chars (BUG-DASH-001). + const clean = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, ENV_MAX_LEN) + // Final regex gate: empty / leading-dash-only / any drift from the + // api regex → bail out without persisting. setEnv is NOT called, so + // the live env stays on the previous valid value (no broken state). + if (!ENV_REGEX.test(clean)) return if (!state.envs.includes(clean)) { state = { ...state, envs: [...state.envs, clean] } emit()