fix(auth): guarded config-value resolvers against case-insensitive env hijack

On Windows process.env/Bun.env lookups are case-insensitive, so the
"env var name, else literal" resolvers turned a literal /login key like
`public` (OpenCode Zen's free key) into the built-in PUBLIC=C:\Users\Public,
sending `Authorization: Bearer C:\Users\Public` and 401ing every request.

Added `$envExact` in pi-utils, which trusts an env lookup only when an
exact-case key is enumerated (the only case-preserving signal on Windows;
the getter and hasOwnProperty/getOwnPropertyDescriptor traps are all
case-insensitive there). Wired it into all three resolvers:
resolve-config-value.ts, model-registry.ts, and auth-storage.ts.

Fixes #7361
This commit is contained in:
roboomp
2026-08-02 06:53:18 +00:00
parent 06343fef42
commit a6521f07ed
6 changed files with 106 additions and 6 deletions
+29
View File
@@ -246,6 +246,35 @@ export function $pickenv(...keys: string[]): string | undefined {
return undefined;
}
/**
* Read an environment variable by its EXACT, case-sensitive name.
*
* `process.env` / `Bun.env` lookups are case-insensitive on Windows (Node backs
* them with `uv_os_getenv`, Bun with a `CaseInsensitiveASCIIStringArrayHashMap`),
* so a lowercase literal like `public` silently resolves to a differently-cased
* system variable — Windows ships `PUBLIC=C:\Users\Public`. Enumerated keys are
* the only signal that preserves the real casing, so this trusts the lookup only
* when a key with identical casing is actually present. On POSIX (case-sensitive
* env) it is equivalent to a direct lookup.
*
* Use this instead of `process.env[name] ?? literal` wherever `name` may be a
* user-supplied literal (e.g. a stored API key) rather than a genuine env-var
* reference — otherwise the literal gets hijacked by a same-named system var.
*
* @param name - Environment variable name to look up.
* @param env - Environment source; defaults to `process.env`.
*/
export function $envExact(name: string, env: Record<string, string | undefined> = process.env): string | undefined {
const value = env[name];
if (value === undefined) return undefined;
// Enumeration preserves real key casing on Windows, unlike the getter; the
// value is trusted only when an exact-case entry actually exists.
for (const key in env) {
if (key === name) return value;
}
return undefined;
}
/**
* Parses a positive decimal integer from `$env[name]`.
* Empty, invalid, NaN, zero, or negative values return `defaultValue`.
+67 -1
View File
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { filterProcessEnv, getDbBusyTimeoutMs, parseEnvFile, setInteractiveHost } from "@oh-my-pi/pi-utils/env";
import {
$envExact,
filterProcessEnv,
getDbBusyTimeoutMs,
parseEnvFile,
setInteractiveHost,
} from "@oh-my-pi/pi-utils/env";
const tempDirs: string[] = [];
const runtimeProbePath = path.join(import.meta.dir, "fixtures", "test-runtime-probe.ts");
@@ -188,3 +194,63 @@ describe("isBunTestRuntime", () => {
).toBe(true);
});
});
/**
* Faithful model of Windows `process.env`: case-insensitive reads, but
* enumeration (`ownKeys`) preserves the real key casing — exactly Node
* (`uv_os_getenv` + `uv_os_environ`) and Bun (`CaseInsensitiveASCIIStringArrayHashMap`).
*/
function windowsLikeEnv(backing: Record<string, string>): Record<string, string | undefined> {
return new Proxy(backing, {
get(target, prop) {
if (typeof prop !== "string") return Reflect.get(target, prop);
for (const key in target) {
if (key.toLowerCase() === prop.toLowerCase()) return target[key];
}
return undefined;
},
has(target, prop) {
if (typeof prop !== "string") return Reflect.has(target, prop);
for (const key in target) {
if (key.toLowerCase() === prop.toLowerCase()) return true;
}
return false;
},
}) as Record<string, string | undefined>;
}
describe("$envExact", () => {
it("returns the value for an exact-case key", () => {
const env = { OPENCODE_API_KEY: "sk-live", PATH: "/usr/bin" };
expect($envExact("OPENCODE_API_KEY", env)).toBe("sk-live");
});
it("returns undefined for an absent name", () => {
expect($envExact("MISSING_VAR", { PATH: "/usr/bin" })).toBeUndefined();
});
it("does not hijack a literal via a case-differing Windows system var", () => {
// Windows ships PUBLIC=C:\Users\Public and reads are case-insensitive, so
// a bare `env["public"]` returns it — the /login #7361 401 root cause.
const env = windowsLikeEnv({ PUBLIC: "C:\\Users\\Public" });
expect(env.public).toBe("C:\\Users\\Public");
expect($envExact("public", env)).toBeUndefined();
});
it("still resolves a genuine exact-case reference on a case-insensitive env", () => {
const env = windowsLikeEnv({ MY_KEY: "secret" });
expect($envExact("MY_KEY", env)).toBe("secret");
expect($envExact("my_key", env)).toBeUndefined();
});
it("reads process.env by default", () => {
const name = `PI_ENVEXACT_TEST_${Date.now()}`;
process.env[name] = "value";
try {
expect($envExact(name)).toBe("value");
} finally {
delete process.env[name];
}
expect($envExact(name)).toBeUndefined();
});
});