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:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user