From d61469edcbdd162d9fce04b0aefe6c0506568974 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 10:59:49 -0400 Subject: [PATCH] fix(config): bind dashboard to loopback by default --- docs/dashboard-auth-cli.md | 4 +- src/commands/config-command-options.ts | 6 +- src/commands/config-command.ts | 2 +- src/commands/config-dashboard-host.ts | 2 + src/web-server/index.ts | 63 +++++++++++++------ .../commands/config-command-options.test.ts | 4 +- tests/unit/commands/config-command.test.ts | 21 +++---- .../unit/web-server/start-server-host.test.ts | 3 +- 8 files changed, 66 insertions(+), 39 deletions(-) diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index af5f8b52..891a42ee 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -6,9 +6,9 @@ CLI commands for managing CCS dashboard authentication. ## Overview -The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, including when the runtime's default bind is network-accessible or when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`. +The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`. -Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it. +Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices. CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine. diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index f219136d..0f4a6149 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -1,5 +1,6 @@ import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor'; import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime'; +import { DEFAULT_DASHBOARD_HOST } from './config-dashboard-host'; const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const; @@ -22,6 +23,7 @@ function formatUnexpectedArgsError(tokens: string[]): string { export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult { const options: ConfigCommandOptions = { + host: DEFAULT_DASHBOARD_HOST, hostProvided: false, dev: false, }; @@ -121,7 +123,7 @@ export function showConfigCommandHelp(): void { console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); - console.log(' --host, -H HOST Bind dashboard server host (default: system default)'); + console.log(' --host, -H HOST Bind dashboard server host (default: localhost)'); console.log(' --dev Development mode with Vite HMR'); console.log(' --help, -h Show this help message'); console.log(''); @@ -129,7 +131,7 @@ export function showConfigCommandHelp(): void { console.log(' ccs config Auto-detect available port'); console.log(' ccs config --port 3000 Use specific port'); console.log(' ccs config --host 0.0.0.0 Force all-interface binding for remote devices'); - console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine'); + console.log(' ccs config --host localhost Restrict dashboard to this machine'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); console.log(' ccs config channels Show Official Channels status'); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1e156462..91aa4ed8 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -176,7 +176,7 @@ export async function handleConfigCommand( port, dev: options.dev, }; - if (options.hostProvided && options.host) { + if (options.host) { serverOptions.host = normalizeDashboardHost(options.host); } diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts index c0e10fb7..09aac96d 100644 --- a/src/commands/config-dashboard-host.ts +++ b/src/commands/config-dashboard-host.ts @@ -1,5 +1,7 @@ import * as os from 'os'; +export const DEFAULT_DASHBOARD_HOST = 'localhost'; + const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']); diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 18058771..21a0aaa2 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -8,6 +8,7 @@ import express from 'express'; import http from 'http'; +import type { AddressInfo } from 'net'; import path from 'path'; import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; @@ -23,6 +24,7 @@ import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; +import { DEFAULT_DASHBOARD_HOST, isLoopbackHost } from '../commands/config-dashboard-host'; export interface ServerOptions { port: number; @@ -37,6 +39,10 @@ export interface ServerInstance { cleanup: () => void; } +function getListenHost(options: ServerOptions): string { + return options.host || DEFAULT_DASHBOARD_HOST; +} + const logger = createLogger('web-server'); /** @@ -183,11 +189,12 @@ export async function startServer(options: ServerOptions): Promise((resolve, reject) => { + const listenHost = getListenHost(options); const onError = (error: NodeJS.ErrnoException) => { logger.error('server.listen_failed', 'Dashboard server failed to start', { code: error.code || 'unknown', message: error.message, - host: options.host || null, + host: listenHost, port: options.port, }); cleanup(); @@ -198,8 +205,18 @@ export async function startServer(options: ServerOptions): Promise { server.off('error', onError); + try { + assertSafeDashboardBind(options, server.address()); + } catch (error) { + cleanup(); + server.close(() => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + return; + } + logger.info('server.listening', 'Dashboard server listening', { - host: options.host || '0.0.0.0', + host: listenHost, port: options.port, dev: Boolean(options.dev), }); @@ -209,12 +226,7 @@ export async function startServer(options: ServerOptions): Promise { - it('defaults to system bind behavior without explicit host', () => { + it('defaults to a localhost-only bind without explicit host', () => { const result = parseConfigCommandArgs([]); expect(result.help).toBe(false); expect(result.error).toBeUndefined(); - expect(result.options.host).toBeUndefined(); + expect(result.options.host).toBe('localhost'); expect(result.options.hostProvided).toBe(false); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index bd80a4f0..f7af52f0 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -10,7 +10,7 @@ let logLines: string[] = []; let errorLines: string[] = []; let dashboardAuthEnabled = false; let startServerError: Error | null = null; -let mockServerBindHost = '::'; +let mockServerBindHost = '::1'; let originalConsoleLog: typeof console.log; let originalConsoleError: typeof console.error; let originalProcessExit: typeof process.exit; @@ -79,7 +79,7 @@ beforeEach(() => { errorLines = []; dashboardAuthEnabled = false; startServerError = null; - mockServerBindHost = '::'; + mockServerBindHost = '::1'; originalConsoleLog = console.log; originalConsoleError = console.error; @@ -140,19 +140,16 @@ describe('config command dashboard startup', () => { expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); - it('keeps the default startup path free of an explicit host override', async () => { + it('binds the default startup path to localhost only', async () => { await handleConfigCommand([], createTestDeps()); expect(startServerCalls).toHaveLength(1); - expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); + expect(startServerCalls[0]).toEqual({ port: 3000, dev: false, host: 'localhost' }); const rendered = logLines.join('\n'); - expect(rendered).toContain('Dashboard: http://localhost:3000'); - expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain( - 'Dashboard may be reachable from other devices that can connect to this machine.' - ); - expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); + expect(rendered).toContain('Dashboard: http://[::1]:3000'); + expect(rendered).not.toContain('Dashboard may be reachable from other devices'); + expect(rendered).not.toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); @@ -205,8 +202,8 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); const rendered = logLines.join('\n'); - expect(rendered).toContain('Dashboard: http://localhost:3000'); - expect(rendered).toContain('Bind host: ::'); + expect(rendered).toContain('Dashboard: http://[::1]:3000'); + expect(rendered).not.toContain('Bind host:'); expect(errorLines).toHaveLength(0); } finally { networkInterfacesSpy.mockRestore(); diff --git a/tests/unit/web-server/start-server-host.test.ts b/tests/unit/web-server/start-server-host.test.ts index 0d4313a6..bcfa5b69 100644 --- a/tests/unit/web-server/start-server-host.test.ts +++ b/tests/unit/web-server/start-server-host.test.ts @@ -47,12 +47,13 @@ afterEach(async () => { }); describe('startServer host binding', () => { - it('binds with system-default host when no host is provided', async () => { + it('binds to localhost by default when no host is provided', async () => { const instance = await startServer({ port: 0 }); instances.push(instance); const address = instance.server.address() as AddressInfo; expect(address.port).toBeGreaterThan(0); + expect(['127.0.0.1', '::1']).toContain(address.address); }); it('binds to an explicit loopback host', async () => {