[Research/Bug] Environment block crossing the OS ⇄ JS-object boundary lacks a validation contract — one class of "invalid env" failures (invalid keys, case semantics, NUL/=/undefined) reaching the utility-process boundary
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
## Summary
The environment block is **untrusted, ever-changing, global mutable state** that any local process can write. Node/Electron forwards it verbatim to child processes (`fork`/`spawn`) via **implicit inheritance** (`deepClone(process.env)` in VS Code). That is exactly the Zero Trust anti-pattern: trusting an implicit source without validating it at the boundary.
The "OS native block" and the "JS object" are two different **domains**. Their serialization/deserialization round-trip has **no contract**, so a single anomalous key can prevent an entire child process (e.g. the extension host) from starting. This is one *class* of "invalid env" failures, not one bug.
## Theoretic frame — why it should be seen as one class
| Zero Trust principle | This scenario | Current state |
|---|---|---|
| Don't trust implicit sources | parent `process.env` treated as untrusted | ❌ inherited verbatim (`deepClone(process.env)`) |
| Verify at the boundary | validate env keys before `fork`/`spawn` | ❌ only strips "dangerous" vars, never validates keys |
| Least privilege | forward only what the child needs | ❌ forwards everything |
## Evidence — the env block has 6 known semantic-break surfaces (all reproduced locally, Node 22)
**① Illegal key name — the crash-triggering case**
```js
const poisoned = { ...process.env, '1': 'x' }; // leading digit
// Allowed by the OS block; once objectified and passed to spawn(),
// Node validates the name -> TypeError: Invalid value for env
```
Locally: out of 154 env keys, **5 are illegal** (`1`, `clion_g++`, `CommonProgramFiles(x86)`, `IntelliJ IDEA`, `ProgramFiles(x86)`). Official Windows dev-tool vars violate Node naming rules **by default** — this is normal state, not an attacker's special case.
**② Case-semantics break (Windows)**
Windows env block is **case-insensitive** (one `Path`); `process.env` is a **case-sensitive** object. `{ ...process.env }` yields *both* `Path` and `PATH`, but Windows keeps only one when spawning — two keys visible in JS vs. one in the child.
**③ `undefined` round-trip mismatch**
```js
const env = { ...process.env, FOO: undefined, BAR: '' };
```
After spread, `FOO` still exists with `undefined`; `spawn` **drops** `undefined` keys but keeps empty strings → JS says "key exists", child says "no key".
**④ NUL truncation**
A value containing `\u0000` terminates the block at the NUL (`CreateEnvironmentBlock` uses NUL as row separator), silently dropping subsequent keys — including legitimate ones.
**⑤ Embedded `=` key mangling**
Hand-crafted `a=b=c` parses at the first `=`, so the key is mangled to `a` and the value becomes `b=c`. The key cannot be reliably represented.
**⑥ The OS native block ⇄ JS object round-trip is the essential entry point**
- Registry env blocks may contain numeric keys — **legal at OS level**;
- Once objectified via `{ ...process.env }` and passed back to `spawn`, Node's name validation crashes;
- **The crash is not in the OS, nor in the Node core — it is in the wrapper doing the two-domain round-trip** (VS Code `utilityProcess.ts`).
## Minimal repro (source-independent)
```js
// repro.js —— Node core still succeeds; the crash is in the upper wrapper
const { fork } = require('child_process');
const poisoned = { ...process.env, '1': 'C:\\poison' };
fork('child.js', [], { env: poisoned })
.on('spawn', () => { console.log('fork ok (Node core tolerates)'); process.exit(0); })
.on('error', e => console.log('fork error:', e.message));
// child.js: process.on('message',()=>{}); setTimeout(()=>process.exit(0),20);
```
## Source anchor
`src/vs/platform/utilityProcess/electron-main/utilityProcess.ts`
- `:278` `configuration.env ? { ...configuration.env } : { ...deepClone(process.env) }` — implicit inheritance
- `:295` `removeDangerousEnvVariables(env)` — never validates key names
- `:297-308` only stringifies values; no boundary contract on key names/semantics
## Suggested direction
Treat `createEnv()` as the **boundary**: before forking, apply a minimal contract:
1. Key name `^[A-Za-z_][A-Za-z0-9_]*$` (drop illegal keys);
2. Value not `undefined`, no NUL;
3. No `=` in the key;
4. (Optional) Windows `Path`/`PATH` case de-duplication.
I have locally extracted ① into a unit-testable `isValidEnvVariableKey` (`src/vs/platform/utilityProcess/common/envKey.ts`, ~16 LOC, 25/25 cases pass) as a starting point for a subsequent PR.
## Related
- Precedent for boundary filtering at the env boundary (already merged): PR `Strip NODE_OPTIONS in removeDangerousEnvVariables` — https://github.com/microsoft/vscode/pull/314847 (filters dangerous/variable values in `removeDangerousEnvVariables`; the proposal here is complementary — value-level filtering there, key-name contract here).
- https://github.com/anthropics/claude-code/issues/78139
- https://github.com/microsoft/vscode/issues/327815 — Open: `Error when launching the VSCode via Code.exe`
- https://github.com/microsoft/vscode/issues/327498 — Closed: Git fails to load on standard startup, restored by `--disable-extensions`/`--user-data-dir` (matches the extension host failing to start from a poisoned env block)
## Environment
VS Code 1.135.0 · Windows 10 Pro · Node 22/24
## Suggested out-of-scope note
This is a correctness/robustness gap (child process fails to start), not a proposed memory-safety or sandbox fix. Treating the parent env block as untrusted at the boundary is consistent with existing VS Code hardening (e.g. `removeDangerousEnvVariables`); the proposal only adds a key-name contract on top.
### Repro tool
Executable probe script is available on request.
## Security disclosure channel
The env-block failure can take down every child process (extension host, auth provider). If the VS Code team considers this a security boundary concern rather than a robustness fix, should it be filed through the Microsoft Security Response Center (https://msrc.microsoft.com) instead? I'm happy to follow your preferred channel — public issue or private MSRC report — whichever you recommend.
Contributor guide
Assessment
This issue has not been assessed yet.