Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/webhook-server.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
21 changes: 18 additions & 3 deletions src/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,20 @@ interface WebhookEntry {
const routes = new Map<string, WebhookEntry>();
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<Request> {
const chunks: Buffer[] = [];
Expand Down Expand Up @@ -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 || '/';
Expand Down Expand Up @@ -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()] });
});
}

Expand Down
Loading