fix(node): bind dashboard to loopback by default (HOST to opt into all-interfaces)

The Node proxy called server.listen(port) with no host, so it bound all
interfaces (0.0.0.0/::) while the startup log printed http://127.0.0.1.
The dashboard is unauthenticated and serves captured request context
(/api/image-source returns the source text rendered into images) plus a
compression kill switch, with access-control-allow-origin: *. On a shared
network that context was readable by anyone who could reach the port.

Bind to 127.0.0.1 by default; add a HOST env to explicitly opt into
all-interfaces exposure (container/host-access use cases), with a startup
warning when bound off-loopback. IPv6 handled: ::1/localhost count as
loopback (no warning) and the startup URL is bracket-formatted. The one
README client example that used localhost is pinned to 127.0.0.1 to match
the IPv4 loopback default on IPv6-first hosts.

Verified: default refuses off-host connections; HOST=0.0.0.0 restores the
previous behavior + warns; HOST=::1 binds IPv6 loopback with a valid
bracketed URL and no warning. typecheck + build pass.
This commit is contained in:
Sutha Kamal
2026-07-03 20:11:57 +00:00
committed by Steven
parent ea7d526cad
commit 75263d64c3
2 changed files with 24 additions and 3 deletions
+23 -2
View File
@@ -37,6 +37,11 @@ import {
* control. No CLI flags beyond --help/--version. */
interface RuntimeConfig {
port: number;
/** Interface to bind. Defaults to 127.0.0.1 (loopback only) — the dashboard
* is unauthenticated and serves captured request context, so it must not be
* exposed to the LAN by default. Set HOST=0.0.0.0 to opt into all interfaces
* (e.g. reaching the dashboard from another device / the host of a container). */
host: string;
upstream: string;
openAIUpstream: string;
openAIApiKey?: string;
@@ -101,6 +106,8 @@ function parseCli(argv: string[]): RuntimeConfig {
const sharedUpstream = process.env.PXPIPE_UPSTREAM;
return {
port: Number(process.env.PORT ?? 47821),
// Loopback by default; opt into all-interfaces exposure explicitly via HOST.
host: process.env.HOST?.trim() || '127.0.0.1',
upstream: process.env.ANTHROPIC_UPSTREAM ?? sharedUpstream ?? 'https://api.anthropic.com',
openAIUpstream: process.env.OPENAI_UPSTREAM ?? sharedUpstream ?? 'https://api.openai.com',
openAIApiKey: process.env.OPENAI_API_KEY,
@@ -140,6 +147,9 @@ Flags:
Environment:
PORT listen port (default 47821)
HOST interface to bind (default 127.0.0.1, loopback only).
Set 0.0.0.0 to expose the dashboard off-host — note it
is unauthenticated and serves captured request context.
PXPIPE_UPSTREAM upstream API base for every API family
ANTHROPIC_UPSTREAM Anthropic API base; overrides PXPIPE_UPSTREAM
(default https://api.anthropic.com)
@@ -1055,8 +1065,19 @@ async function main(): Promise<void> {
});
});
server.listen(opts.port, () => {
console.log(`[pxpipe] listening on http://127.0.0.1:${opts.port}`);
// IPv6 literals need bracket notation to form a valid URL (http://[::1]:47821).
const displayHost = opts.host.includes(':') ? `[${opts.host}]` : opts.host;
const isLoopbackHost =
opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1';
server.listen(opts.port, opts.host, () => {
console.log(`[pxpipe] listening on http://${displayHost}:${opts.port}`);
if (!isLoopbackHost) {
console.warn(
`[pxpipe] WARNING: bound to ${opts.host} — the unauthenticated dashboard ` +
`(captured request context + kill switch) is reachable off-host. ` +
`Unset HOST to restrict to loopback.`,
);
}
const routes = resolveUpstreams(config);
console.log(`[pxpipe] anthropic upstream → ${routes.anthropic}`);
console.log(`[pxpipe] openai upstream → ${routes.openai}`);