test(security): defensive guard against client-bundle secret leaks (#3704)#3786
Conversation
…3704) A security report (#3704) suggested that the browser runtime seeded `WORLDMONITOR_API_KEY` from `import.meta.env` into client-readable state. Investigation confirmed the architecture is actually safe today: 1. Vite's default `envPrefix: 'VITE_'` blocks any unprefixed env var from being inlined into the browser bundle. `WORLDMONITOR_API_KEY` has no prefix → invisible to `readEnvSecret()` in web builds. 2. No entry in `RUNTIME_FEATURES.requiredSecrets` references `WORLDMONITOR_API_KEY`, so `seedSecretsFromEnvironment()` never iterates over it. 3. `vite.config.ts` does not pass `WORLDMONITOR_API_KEY` through its `define:` block (which would inline a literal value into the bundle regardless of envPrefix). But "safe today" is only as durable as the next contributor's PR. This test locks down all three invariants so a future change to any of them gets a CI failure with a pointer back to issue #3704. New platform-only secrets are added by extending the `PLATFORM_ONLY_SECRETS` constant in the test file. Closes #3704
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds a new test file (
Confidence Score: 3/5The existing runtime architecture remains safe; the new test adds documentation-level protection for invariants 1 and 3, but the define-block guard would incorrectly break CI on a safe refactor, and the runtime smoke test is a no-op in the test environment. The define-block check actively misfires on a valid and safer configuration change (removing tests/browser-bundle-secret-guard.test.mts — specifically the Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[browser-bundle-secret-guard.test.mts] --> T1 & T2 & T3 & T4
T1["Test 1: requiredSecrets scan"] -->|key found?| R1{Match?}
R1 -->|Yes| F1[FAIL - correct]
R1 -->|No| P1[PASS]
T2["Test 2: define block scan"] -->|block exists?| R2{defineMatch?}
R2 -->|No - block removed| F2["FAIL - FALSE POSITIVE\nAbsent define: is safer"]
R2 -->|Yes| R3{Secret in block?}
R3 -->|Yes| F3[FAIL - correct]
R3 -->|No| P3[PASS]
T3["Test 3: envPrefix check"] -->|prefix set?| R4{envPrefix?}
R4 -->|No| P4[PASS - Vite default]
R4 -->|Yes, safe| P5[PASS]
R4 -->|Yes, unsafe| F5[FAIL - correct]
T4["Test 4: runtime snapshot"] --> R6["import.meta.env = undefined in Node.js"]
R6 --> R7[seedSecretsFromEnvironment seeds nothing]
R7 --> P6["PASS - vacuous even if key in requiredSecrets"]
Reviews (1): Last reviewed commit: "test(security): defensive guard against ..." | Re-trigger Greptile |
Review of #3786 spotted three gaps: 1. PLATFORM_ONLY_SECRETS only covered WORLDMONITOR_API_KEY. Real platform secrets the codebase also treats as server-only: - WORLDMONITOR_VALID_KEYS (enterprise key allowlist) - WM_SESSION_SECRET (anonymous session token signing) - MCP_PRO_GRANT_HMAC_SECRET (Pro MCP grant signing) All four now flow through every guard. 2. The envPrefix check only handled the string form. Vite supports string | string[], and an array-form `envPrefix: ['VITE_', '']` silently exposed everything because the substring check on the array's string representation matched 'VITE_'. Now we parse the captured literal (string or bracketed array) as JSON and assert every entry starts with a safe prefix (VITE_/PUBLIC_). 3. Added a comment on the requiredSecrets regex noting it assumes a flat string array; if the shape ever becomes `requiredSecrets: [{ key: 'X' }]`, the lazy `[^\]]*` would stop at the first inner `]` and the test would miss content. All 4 tests pass; typecheck clean.
#3704 review) Reviewer caught a false positive in the define-block guard: const defineMatch = source.match(/define:\s*\{[\s\S]{0,2000}?\n\s*\},/); assert.ok(defineMatch, 'expected to find a define: block in vite.config.ts'); A future refactor that removes the `define:` block entirely is strictly safer (nothing to accidentally inline) — but this assertion would reject that refactor as a regression. Switch to "absence is the safest state": skip the secret-scan when the block is missing, only validate its contents when present. The other invariants (requiredSecrets scan, envPrefix check) and the runtime smoke test are unchanged. All 4 tests pass.
|
Good catch — applied exactly your suggested shape in commit 5f84600: ```ts The other three checks unchanged. All 4 tests pass locally; CI re-running. Generalised the principle ("absence is the safest state for a defensive guard's precondition — don't reject a strictly-safer refactor") and appended it to the source-grep regression-test recipe in ~/.claude/skills/test-ci-gotchas/reference/ so this trap is documented for the next time someone writes a guard like this. |
follow-up) (#3819) Greptile flagged on the now-merged PR #3786 review that the 4th test in the secret-guard suite was passing vacuously: it imported runtime-config.ts and asserted `getRuntimeConfigSnapshot().secrets` contained no platform-only secret, but in node:test `import.meta.env` is undefined, so `readEnvSecret()` returns '' for every key regardless of process.env or requiredSecrets contents. The snapshot is always empty and the test always passes — even if a contributor adds WORLDMONITOR_API_KEY to a requiredSecrets array (the exact regression test #1 catches via source-grep). This commit was originally made on the #3786 branch but missed the merge window. Re-applying against new main as a follow-up PR. The 3 remaining static-analysis tests catch every realistic regression path. The honest runtime check is a bundle-grep at deploy time: npm run build && grep -r "WORLDMONITOR_API_KEY" dist/ Pattern documented in ~/.claude/skills/test-ci-gotchas/reference/vacuous-test-when-runtime-injected-context-absent.md (extracted this session).
Summary
A security report (#3704) suggested the browser runtime seeded `WORLDMONITOR_API_KEY` from `import.meta.env` into client-readable state. Investigation confirmed the architecture is safe today through two independent layers (full analysis posted on the issue), but "safe today" is only as durable as the next contributor's PR. This adds a CI guard that locks the invariants down.
Three invariants enforced
Plus a runtime smoke test that loads `runtime-config.ts` and asserts no platform-only secret appears in the snapshot.
Extending the guard to cover new platform-only secrets is one constant edit — add the name to `PLATFORM_ONLY_SECRETS` in the test file.
Test plan
Resolves #3704