fix(server): validate proxy settings without blocking startup

Normalize scheme-less proxies and share HTTP-only validation across fetch and WebSockets. Record ignored variables without credentials in the daemon log. Separate built-in and directory plugin regressions through a typed fixture.
This commit is contained in:
Mohamed Boudra
2026-10-29 06:43:07 +01:00
parent 808024c95f
commit 32ddc6215a
9 changed files with 264 additions and 105 deletions
+2
View File
@@ -498,6 +498,8 @@ Worker restart retains supervisor arguments/environment and rereads the configur
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay/Hub WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are ignored, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
+6 -2
View File
@@ -1,6 +1,6 @@
import { initializeEnvironmentProxy } from "./network/index.js";
import { appendFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { initializeEnvironmentProxy } from "./network/index.js";
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -132,8 +132,12 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
}
async function main() {
initializeEnvironmentProxy();
const { paseoHome, logger, config } = bootstrapFromEnvironment();
for (const variable of initializeEnvironmentProxy()) {
writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", {
variable,
});
}
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
@@ -1,6 +1,6 @@
import { webSocketProxyAgent } from "../network/index.js";
import { WebSocket } from "ws";
import { z } from "zod";
import { webSocketProxyAgent } from "../network/index.js";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
+63 -11
View File
@@ -1,5 +1,5 @@
import type { Agent } from "node:http";
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { Agent as DirectAgent, Dispatcher, EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { HttpsProxyAgent } from "https-proxy-agent";
interface ProxyEnvironment {
@@ -8,14 +8,38 @@ interface ProxyEnvironment {
noProxy: string;
}
let environment = readEnvironment(process.env);
let environment = readEnvironment(process.env).environment;
function readEnvironment(env: NodeJS.ProcessEnv): ProxyEnvironment {
const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? "";
function readEnvironment(env: NodeJS.ProcessEnv): {
environment: ProxyEnvironment;
ignored: string[];
} {
const ignored: string[] = [];
function proxy(lower: string, upper: string): string | undefined {
const variable = env[lower] !== undefined ? lower : upper;
const value = env[variable];
if (value === undefined) return undefined;
if (!value.trim()) return "";
try {
const url = new URL(value.includes("://") ? value : `http://${value}`);
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error("Unsupported proxy protocol");
decodeURIComponent(url.username);
decodeURIComponent(url.password);
return url.href;
} catch {
ignored.push(variable);
return "";
}
}
const httpProxy = proxy("http_proxy", "HTTP_PROXY") ?? "";
return {
httpProxy,
httpsProxy: env.https_proxy ?? env.HTTPS_PROXY ?? httpProxy,
noProxy: env.no_proxy ?? env.NO_PROXY ?? "",
environment: {
httpProxy,
httpsProxy: proxy("https_proxy", "HTTPS_PROXY") ?? httpProxy,
noProxy: env.no_proxy ?? env.NO_PROXY ?? "",
},
ignored,
};
}
@@ -23,16 +47,44 @@ function bypassList(noProxy: string): string {
return noProxy.trim() === "*" ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
}
/** Install once at process startup, without changing environments inherited by agents. */
export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): void {
environment = readEnvironment(env);
// undici otherwise falls back to HTTP_PROXY when an explicitly ignored HTTPS proxy is empty.
class EnvironmentProxyAgent extends Dispatcher {
private readonly proxy: EnvHttpProxyAgent;
private readonly direct = new DirectAgent();
private readonly options: ProxyEnvironment;
constructor(options: ProxyEnvironment) {
super();
this.options = options;
this.proxy = new EnvHttpProxyAgent(options);
}
override dispatch(
options: Parameters<EnvHttpProxyAgent["dispatch"]>[0],
handler: Parameters<EnvHttpProxyAgent["dispatch"]>[1],
): boolean {
if (!this.options.httpsProxy && String(options.origin).startsWith("https:"))
return this.direct.dispatch(options, handler);
return this.proxy.dispatch(options, handler);
}
override async close(): Promise<void> {
await Promise.all([this.proxy.close(), this.direct.close()]);
}
override async destroy(): Promise<void> {
await Promise.all([this.proxy.destroy(), this.direct.destroy()]);
}
}
/** Install once at startup; return ignored variable names for logging without exposing credentials. */
export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): string[] {
const resolved = readEnvironment(env);
environment = resolved.environment;
setGlobalDispatcher(
new EnvHttpProxyAgent({
new EnvironmentProxyAgent({
httpProxy: environment.httpProxy,
httpsProxy: environment.httpsProxy,
noProxy: bypassList(environment.noProxy),
}),
);
return resolved.ignored;
}
/** ws uses Node HTTP agents rather than the fetch dispatcher. */
@@ -1,3 +1,8 @@
import { fork } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer, type Server } from "node:http";
import { once } from "node:events";
import { afterEach, expect, test } from "vitest";
@@ -9,7 +14,7 @@ const servers: Server[] = [];
afterEach(async () => {
const installed = getGlobalDispatcher();
setGlobalDispatcher(original);
await installed.close();
if (installed !== original) await installed.close();
await Promise.all(
servers.splice(0).map(
(server) =>
@@ -90,3 +95,92 @@ test("NO_PROXY and absent variables keep matching WebSockets direct", async () =
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
expect(recorder.tunnels).toEqual([]);
});
test.each(["127.0.0.1", "localhost"])(
"scheme-less %s proxies work for fetch and WebSockets",
async (host) => {
const recorder = await proxy();
const address = new URL(recorder.url);
initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` });
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toContain("vendor.invalid:443");
},
);
test.each([
"http://[",
"http://user%zz:secret@proxy.corp",
"socks5://127.0.0.1:7890",
"ftp://proxy.corp:8080",
])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => {
const recorder = await proxy();
expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([
"https_proxy",
]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(recorder.tunnels).toEqual([]);
});
test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-"));
const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], {
execArgv: ["--import", "tsx"],
silent: true,
env: {
...process.env,
PASEO_HOME: home,
HOME: home,
USERPROFILE: home,
PASEO_LISTEN: "127.0.0.1:0",
PASEO_RELAY_ENABLED: "0",
https_proxy: "socks5://private:secret@127.0.0.1:7890",
},
});
worker.stdout?.resume();
let stderr = "";
worker.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
try {
await new Promise<void>((resolve, reject) => {
worker.on("message", (message) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "paseo:ready"
)
resolve();
});
worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`)));
});
const log = await readFile(path.join(home, "daemon.log"), "utf8");
const warnings = log
.split("\n")
.filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable"));
expect(warnings).toHaveLength(1);
expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy");
expect(warnings[0]).not.toContain("secret");
} finally {
if (worker.exitCode === null && worker.signalCode === null) {
const exited = once(worker, "exit");
worker.kill("SIGTERM");
await exited;
}
await rm(home, { recursive: true, force: true });
}
}, 60_000);
test("accepts a scheme-less corporate proxy hostname", () => {
expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
});
@@ -1,4 +1,3 @@
import { initializeEnvironmentProxy } from "../network/index.js";
import { PluginAgentTools } from "./agent-tools.js";
import { PluginHookHandlers } from "./lifecycle/index.js";
import { evaluateBundle } from "./bundle-evaluator.js";
@@ -25,6 +24,7 @@ import { createPluginClientId } from "./plugin-session-identity.js";
import { PluginSettingsStore } from "./settings/index.js";
import { readPluginProviderIcon } from "./provider-icon.js";
import { initializeEnvironmentProxy } from "../network/index.js";
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -0,0 +1,31 @@
import path from "node:path";
import { initializeEnvironmentProxy } from "../../network/index.js";
import { DaemonClient } from "../../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js";
const mode = process.argv[2];
if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory");
const sources = ["claude-usage-source", "codex-usage-source"];
// Built-ins run inside the daemon; directory plugins initialize their own subprocess network.
if (mode === "builtin") initializeEnvironmentProxy();
const daemon = await createTestPaseoDaemon({
pluginsEnabled: mode === "directory",
builtinPlugins: new BuiltinPluginLoader(
resolveBuiltinPluginsRoot(),
mode === "builtin" ? sources : [],
),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
if (mode === "directory") {
for (const source of sources) {
await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source);
}
}
await client.listUsageReports();
} finally {
await client.close();
await daemon.close();
}
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { cp, mkdir, mkdtemp, readFile as readTextFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -340,96 +340,72 @@ export default function contribute(server) {
}
}, 60_000);
test("built-in usage plugins reach the environment proxy", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-"));
const tunnels: string[] = [];
const proxy = createServer();
proxy.on("connect", (request, socket) => {
tunnels.push(request.url!);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
const address = proxy.address();
if (!address || typeof address === "string") throw new Error("Missing proxy address");
const guard = path.join(root, "network-guard.cjs");
// Prevent vendor DNS/network access even when proxy support is broken.
await writeFile(
guard,
`const dns = require('node:dns');
test.each(["builtin", "directory"])(
"%s usage plugins reach the environment proxy",
async (mode) => {
const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-"));
const tunnels: string[] = [];
const proxy = createServer();
proxy.on("connect", (request, socket) => {
tunnels.push(request.url!);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
const address = proxy.address();
if (!address || typeof address === "string") throw new Error("Missing proxy address");
const guard = path.join(root, "network-guard.cjs");
// Prevent vendor DNS/network access even when proxy support is broken.
await writeFile(
guard,
`const dns = require('node:dns');
const lookup = dns.lookup;
dns.lookup = function(host, ...args) {
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args);
args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' }));
};`,
);
await mkdir(path.join(root, ".claude"));
await mkdir(path.join(root, ".codex"));
await writeFile(
path.join(root, ".claude", ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }),
);
await writeFile(
path.join(root, ".codex", "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }),
);
const external = path.join(root, "external-codex");
await cp(path.join(resolveBuiltinPluginsRoot(), "codex-usage-source"), external, {
recursive: true,
});
const entry = path.join(external, "index.server.ts");
await writeFile(
entry,
(await readTextFile(entry, "utf8")).replace('id: "codex"', 'id: "external-codex"'),
);
try {
const directory = path.dirname(fileURLToPath(import.meta.url));
const moduleUrl = (file: string) =>
JSON.stringify(pathToFileURL(path.resolve(directory, file)).href);
await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
`
import { initializeEnvironmentProxy } from ${moduleUrl("../network/index.ts")};
import { DaemonClient } from ${moduleUrl("../test-utils/daemon-client.ts")};
import { createTestPaseoDaemon } from ${moduleUrl("../test-utils/paseo-daemon.ts")};
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from ${moduleUrl("./builtin/index.ts")};
initializeEnvironmentProxy();
const daemon = await createTestPaseoDaemon({ pluginsEnabled: true, builtinPlugins: new BuiltinPluginLoader(resolveBuiltinPluginsRoot(), ["claude-usage-source", "codex-usage-source"]) });
const client = new DaemonClient({ url: 'ws://127.0.0.1:' + daemon.port + '/ws' });
try {
await client.connect();
await client.listUsageReports();
await client.installDirectoryPlugin(${JSON.stringify(external)}, 'external-codex');
const result = await client.listUsageReports({ forceRefresh: true });
if (!result.reports.some(report => report.sourceId === "external-codex")) throw new Error("Installed usage source missing");
} finally { await client.close(); await daemon.close(); }
`,
],
{
timeout: 45_000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
HOME: root,
CODEX_HOME: path.join(root, ".codex"),
NODE_OPTIONS: `--require=${guard}`,
https_proxy: `http://127.0.0.1:${address.port}`,
no_proxy: "",
NO_PROXY: "",
},
},
);
expect(tunnels).toContain("api.anthropic.com:443");
expect(tunnels).toContain("chatgpt.com:443");
expect(tunnels.filter((host) => host === "chatgpt.com:443").length).toBeGreaterThanOrEqual(3);
} finally {
proxy.closeAllConnections();
await new Promise<void>((resolve) => proxy.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
}, 60_000);
await mkdir(path.join(root, ".claude"));
await mkdir(path.join(root, ".codex"));
await writeFile(
path.join(root, ".claude", ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }),
);
await writeFile(
path.join(root, ".codex", "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }),
);
try {
await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)),
mode,
],
{
timeout: 45_000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
HOME: root,
USERPROFILE: root,
CODEX_HOME: path.join(root, ".codex"),
NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`,
https_proxy: `http://127.0.0.1:${address.port}`,
no_proxy: "",
NO_PROXY: "",
},
},
);
expect(tunnels).toContain("api.anthropic.com:443");
expect(tunnels).toContain("chatgpt.com:443");
} finally {
proxy.closeAllConnections();
await new Promise<void>((resolve) => proxy.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -1,8 +1,8 @@
/// <reference lib="dom" />
import { webSocketProxyAgent } from "./network/index.js";
import { WebSocket } from "ws";
import type pino from "pino";
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import { webSocketProxyAgent } from "./network/index.js";
import type { ExternalSocketMetadata } from "./websocket-server.js";
export interface RelayTransportOptions {