From 29c35ab2fc3662e6199cd28dec028cd7f5f3c0ec Mon Sep 17 00:00:00 2001 From: smith-vosburg Date: Mon, 18 May 2026 16:43:42 -0700 Subject: [PATCH] fix(webhook-server): default-bind to loopback instead of 0.0.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract resolveListenConfig() so the bind address is environment-driven and unit-testable. Default to 127.0.0.1 so the webhook port is not exposed to the LAN; set WEBHOOK_BIND=0.0.0.0 (or a specific interface IP) to opt back into external exposure. The existing webhook-server.test.ts imported resolveListenConfig from the production module, but the symbol had never been extracted — the test was effectively dead. This commit lands the missing refactor and extends the test with the empty-string fallback, specific-interface, and combined-env-var cases. Co-Authored-By: Claude Opus 4.7 --- src/webhook-server.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/webhook-server.ts | 21 ++++++++++++++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/webhook-server.test.ts diff --git a/src/webhook-server.test.ts b/src/webhook-server.test.ts new file mode 100644 index 00000000000..077252b7e5c --- /dev/null +++ b/src/webhook-server.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; + +import { resolveListenConfig } from './webhook-server.js'; + +describe('webhook-server resolveListenConfig', () => { + it('defaults to loopback so the port is not exposed to the LAN', () => { + expect(resolveListenConfig({})).toEqual({ port: 3000, bind: '127.0.0.1' }); + }); + + it('honors WEBHOOK_PORT when set', () => { + expect(resolveListenConfig({ WEBHOOK_PORT: '4500' })).toEqual({ port: 4500, bind: '127.0.0.1' }); + }); + + it('honors WEBHOOK_BIND for opt-in external exposure', () => { + expect(resolveListenConfig({ WEBHOOK_BIND: '0.0.0.0' })).toEqual({ port: 3000, bind: '0.0.0.0' }); + }); + + it('honors WEBHOOK_BIND for binding to a specific interface', () => { + expect(resolveListenConfig({ WEBHOOK_BIND: '10.0.0.5' })).toEqual({ + port: 3000, + bind: '10.0.0.5', + }); + }); + + it('treats an empty WEBHOOK_BIND as unset (falls back to loopback)', () => { + // An accidental `WEBHOOK_BIND=` in a dotenv file should not silently bind + // to all interfaces — empty string is falsy and should hit the safe default. + expect(resolveListenConfig({ WEBHOOK_BIND: '' })).toEqual({ port: 3000, bind: '127.0.0.1' }); + }); + + it('combines WEBHOOK_PORT and WEBHOOK_BIND independently', () => { + expect(resolveListenConfig({ WEBHOOK_PORT: '8080', WEBHOOK_BIND: '0.0.0.0' })).toEqual({ + port: 8080, + bind: '0.0.0.0', + }); + }); +}); diff --git a/src/webhook-server.ts b/src/webhook-server.ts index 6b26d111f1d..eaa8f48eb61 100644 --- a/src/webhook-server.ts +++ b/src/webhook-server.ts @@ -14,6 +14,7 @@ import type { Chat } from 'chat'; import { log } from './log.js'; const DEFAULT_PORT = 3000; +const DEFAULT_BIND = '127.0.0.1'; interface WebhookEntry { chat: Chat; @@ -23,6 +24,20 @@ interface WebhookEntry { const routes = new Map(); let server: http.Server | null = null; +/** + * Resolve the listen address from the environment. + * + * Defaults to loopback so the webhook port is not exposed to the LAN. Set + * `WEBHOOK_BIND=0.0.0.0` (or a specific interface IP) to opt into external + * exposure — typically you want a reverse proxy in front instead. + */ +export function resolveListenConfig(env: NodeJS.ProcessEnv): { port: number; bind: string } { + const portRaw = env.WEBHOOK_PORT; + const port = portRaw ? parseInt(portRaw, 10) : DEFAULT_PORT; + const bind = env.WEBHOOK_BIND || DEFAULT_BIND; + return { port, bind }; +} + /** Convert Node.js IncomingMessage to a Web API Request. */ async function toWebRequest(req: http.IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -79,7 +94,7 @@ export function registerWebhookAdapter(chat: Chat, adapterName: string): void { function ensureServer(): void { if (server) return; - const port = parseInt(process.env.WEBHOOK_PORT || String(DEFAULT_PORT), 10); + const { port, bind } = resolveListenConfig(process.env); server = http.createServer(async (req, res) => { const url = req.url || '/'; @@ -118,8 +133,8 @@ function ensureServer(): void { } }); - server.listen(port, '0.0.0.0', () => { - log.info('Webhook server started', { port, adapters: [...routes.keys()] }); + server.listen(port, bind, () => { + log.info('Webhook server started', { port, bind, adapters: [...routes.keys()] }); }); }