Skip to content

test(security): defensive guard against client-bundle secret leaks (#3704)#3786

Merged
koala73 merged 3 commits into
mainfrom
chore/3704-defensive-bundle-secret-guard
May 18, 2026
Merged

test(security): defensive guard against client-bundle secret leaks (#3704)#3786
koala73 merged 3 commits into
mainfrom
chore/3704-defensive-bundle-secret-guard

Conversation

@koala73

@koala73 koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner

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

  1. No platform-only secret listed in any `RUNTIME_FEATURES.requiredSecrets` array — that's what `seedSecretsFromEnvironment()` iterates.
  2. No platform-only secret inlined via `vite.config.ts`'s `define:` block — define would bypass envPrefix entirely.
  3. `vite.config.ts` does not set a custom `envPrefix` that drops the `VITE_`/`PUBLIC_` convention — the default prefix is what keeps unprefixed env vars out of the browser bundle.

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

  • `npx tsx --test tests/browser-bundle-secret-guard.test.mts` — 4/4 pass
  • `npm run typecheck` — clean

Resolves #3704

…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
@vercel

vercel Bot commented May 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview May 18, 2026 8:07am

Request Review

@greptile-apps

greptile-apps Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a new test file (tests/browser-bundle-secret-guard.test.mts) that statically inspects runtime-config.ts and vite.config.ts to ensure WORLDMONITOR_API_KEY cannot be seeded into the browser bundle, plus a runtime snapshot assertion. The guards for invariants 1 and 3 are logically sound, but invariant 2 contains a false-failure condition and the runtime smoke test is effectively a no-op in the Node.js test environment.

  • Invariant 2 (define: block check): assert.ok(defineMatch, ...) causes a CI failure when the define: block is absent — which is actually a safer state — misidentifying a valid refactor as a security violation.
  • Invariant 4 (runtime smoke test): In Node.js/tsx, import.meta.env is undefined, so readEnvSecret() always returns empty; the snapshot will always be clean regardless of what is in requiredSecrets, making this test pass vacuously and providing no real defense beyond invariant 1.

Confidence Score: 3/5

The 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 define:), which could mislead a future developer into reverting a safe refactor. The runtime smoke test does not exercise the invariant it documents because import.meta.env is always empty in Node.js, so it cannot catch the regression it was written to guard against.

tests/browser-bundle-secret-guard.test.mts — specifically the define: block assertion (lines 71–79) and the runtime snapshot test (lines 88–120).

Important Files Changed

Filename Overview
tests/browser-bundle-secret-guard.test.mts New test file adding three static-analysis guards and one runtime smoke test against client-bundle secret leakage; the define-block guard has a false-failure condition when the block is absent, and the runtime smoke test is ineffective in the Node.js/tsx environment.

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"]
Loading

Reviews (1): Last reviewed commit: "test(security): defensive guard against ..." | Re-trigger Greptile

Comment thread tests/browser-bundle-secret-guard.test.mts
Comment thread tests/browser-bundle-secret-guard.test.mts
Comment thread tests/browser-bundle-secret-guard.test.mts Outdated
koala73 added 2 commits May 18, 2026 07:47
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.
@koala73

koala73 commented May 18, 2026

Copy link
Copy Markdown
Owner Author

Good catch — applied exactly your suggested shape in commit 5f84600:

```ts
const defineMatch = source.match(/define:\s*{[\s\S]{0,2000}?\n\s*},/);
if (!defineMatch) return; // absence is the safest state
// ... only scan contents when block present
```

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.

@koala73
koala73 merged commit 15767e2 into main May 18, 2026
11 checks passed
@koala73
koala73 deleted the chore/3704-defensive-bundle-secret-guard branch May 18, 2026 16:43
koala73 added a commit that referenced this pull request May 18, 2026
 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Browser runtime seeds server-side secrets from import.meta.env into client-readable config

1 participant