diff --git a/packages/web/README.md b/packages/web/README.md index 784b9a81..03e9e752 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -110,7 +110,7 @@ OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber | `OPENCODE_HOST` | Full base URL of external server (overrides `OPENCODE_PORT`) | | `OPENCODE_PORT` | Port of external server | | `OPENCODE_SKIP_START` | Skip starting embedded OpenCode server | -| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only) | +| `OPENCHAMBER_OPENCODE_HOSTNAME` | Bind hostname for managed OpenCode server (default: `127.0.0.1`, use `0.0.0.0` for LAN/remote access — trusted networks only). Invalid values are rejected with an error and fall back to loopback | | `OPENCHAMBER_HOST` | Bind hostname for the OpenChamber web server (default: `127.0.0.1`; use `0.0.0.0` for LAN/remote access — trusted networks only) | | `OPENCHAMBER_VERBOSE_REQUEST_LOGS` | Set to `true` to log every HTTP request; disabled by default to keep user logs small | | `OPENCHAMBER_SKIP_API_COMPRESSION` | Set to `true` to disable gzip compression for `/api/*` responses | diff --git a/packages/web/server/lib/opencode/env-config.js b/packages/web/server/lib/opencode/env-config.js index fc493f0b..42abd0a2 100644 --- a/packages/web/server/lib/opencode/env-config.js +++ b/packages/web/server/lib/opencode/env-config.js @@ -1,3 +1,26 @@ +import { isIP } from 'node:net'; + +const MAX_HOSTNAME_LENGTH = 253; +const HOSTNAME_LABEL_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/; +// All-numeric dotted values must be a real IPv4 address; otherwise typo'd IPs +// like "0.0.0.0.0" would slip through as (technically valid) hostnames. +const ALL_NUMERIC_DOTTED_RE = /^\d+(?:\.\d+)*$/; + +// Valid bind hostnames for the managed OpenCode server: IPv4, IPv6 (with or +// without brackets), or a DNS-style hostname. Everything else (URLs, ports, +// paths, whitespace, underscores) is rejected. +export const isValidOpenCodeHostname = (value) => { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_HOSTNAME_LENGTH) return false; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return isIP(trimmed.slice(1, -1)) === 6; + } + if (isIP(trimmed) !== 0) return true; + if (ALL_NUMERIC_DOTTED_RE.test(trimmed)) return false; + return trimmed.split('.').every((label) => HOSTNAME_LABEL_RE.test(label)); +}; + export const resolveOpenCodeEnvConfig = (options = {}) => { const env = options.env && typeof options.env === 'object' ? options.env : {}; const logger = options.logger ?? console; @@ -60,6 +83,14 @@ export const resolveOpenCodeEnvConfig = (options = {}) => { ); return '127.0.0.1'; } + if (!isValidOpenCodeHostname(trimmed)) { + logger.error( + `[config] Rejecting OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: ` + + 'must be a valid hostname or IP address (for example 127.0.0.1, 0.0.0.0, localhost, [::1]); ' + + 'falling back to 127.0.0.1 (loopback only)', + ); + return '127.0.0.1'; + } return trimmed; })(); diff --git a/packages/web/server/lib/opencode/env-config.test.js b/packages/web/server/lib/opencode/env-config.test.js new file mode 100644 index 00000000..4b5f8bb0 --- /dev/null +++ b/packages/web/server/lib/opencode/env-config.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isValidOpenCodeHostname, resolveOpenCodeEnvConfig } from './env-config.js'; + +describe('isValidOpenCodeHostname', () => { + it('accepts IPv4 addresses', () => { + expect(isValidOpenCodeHostname('127.0.0.1')).toBe(true); + expect(isValidOpenCodeHostname('0.0.0.0')).toBe(true); + expect(isValidOpenCodeHostname('192.168.1.10')).toBe(true); + }); + + it('accepts IPv6 addresses with and without brackets', () => { + expect(isValidOpenCodeHostname('::1')).toBe(true); + expect(isValidOpenCodeHostname('[::1]')).toBe(true); + expect(isValidOpenCodeHostname('::')).toBe(true); + expect(isValidOpenCodeHostname('[::]')).toBe(true); + }); + + it('accepts DNS-style hostnames', () => { + expect(isValidOpenCodeHostname('localhost')).toBe(true); + expect(isValidOpenCodeHostname('tailscale-host')).toBe(true); + expect(isValidOpenCodeHostname('my.host.example')).toBe(true); + }); + + it('rejects malformed values', () => { + const invalid = [ + '', + ' ', + 'http://localhost', + 'https://host:4096', + 'host:4096', + 'host/path', + 'bad host', + 'bad_host', + '0.0.0.0.0', + '999.999.999.999', + '[::1', + '::1]', + 'a'.repeat(254), + '1.2.3.4.5.6.7.8.9', + ]; + for (const value of invalid) { + expect(isValidOpenCodeHostname(value), JSON.stringify(value)).toBe(false); + } + }); + + it('rejects non-string values', () => { + expect(isValidOpenCodeHostname(undefined)).toBe(false); + expect(isValidOpenCodeHostname(null)).toBe(false); + expect(isValidOpenCodeHostname(42)).toBe(false); + }); +}); + +describe('resolveOpenCodeEnvConfig hostname', () => { + it('defaults to loopback when the env var is absent', () => { + expect(resolveOpenCodeEnvConfig({ env: {} }).configuredOpenCodeHostname).toBe('127.0.0.1'); + }); + + it('reads OPENCHAMBER_OPENCODE_HOSTNAME', () => { + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0' } }); + expect(result.configuredOpenCodeHostname).toBe('0.0.0.0'); + }); + + it('trims surrounding whitespace', () => { + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' tailscale-host ' } }); + expect(result.configuredOpenCodeHostname).toBe('tailscale-host'); + }); + + it('warns and falls back for an empty value', () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + const result = resolveOpenCodeEnvConfig({ env: { OPENCHAMBER_OPENCODE_HOSTNAME: ' ' }, logger }); + expect(result.configuredOpenCodeHostname).toBe('127.0.0.1'); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty after trimming')); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('rejects invalid values with a clear error and falls back to loopback', () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + const result = resolveOpenCodeEnvConfig({ + env: { OPENCHAMBER_OPENCODE_HOSTNAME: 'http://nope:4096' }, + logger, + }); + expect(result.configuredOpenCodeHostname).toBe('127.0.0.1'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Rejecting OPENCHAMBER_OPENCODE_HOSTNAME'), + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1')); + }); + + it('keeps other env config intact when the hostname is validated', () => { + const result = resolveOpenCodeEnvConfig({ + env: { OPENCHAMBER_OPENCODE_HOSTNAME: '0.0.0.0', OPENCODE_PORT: '4096' }, + }); + expect(result.configuredOpenCodeHostname).toBe('0.0.0.0'); + expect(result.configuredOpenCodePort).toBe(4096); + expect(result.effectivePort).toBe(4096); + }); +}); diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index fb57c3f2..ee820972 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -50,7 +50,7 @@ const createMockChild = () => { return child; }; -const createRuntime = (overrides = {}, stateOverrides = {}) => { +const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) => { const state = { openCodeWorkingDirectory: '/tmp/project', openCodeProcess: null, @@ -83,6 +83,7 @@ const createRuntime = (overrides = {}, stateOverrides = {}) => { ENV_EFFECTIVE_PORT: 3001, ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1', ENV_SKIP_OPENCODE_START: false, + ...envOverrides, }, syncToHmrState: vi.fn(), syncFromHmrState: vi.fn(), @@ -312,6 +313,27 @@ describe('OpenCode lifecycle', () => { expect(server.signalCode).toBe('SIGTERM'); }); + it('launches managed OpenCode on the configured bind hostname', async () => { + delete process.env.OPENCODE_BINARY; + const child = createMockChild(); + spawnMock.mockImplementationOnce(() => { + queueMicrotask(() => { + child.stdout.emit('data', 'opencode server listening on http://0.0.0.0:45678\n'); + }); + return child; + }); + + const runtime = createRuntime({}, {}, { ENV_CONFIGURED_OPENCODE_HOSTNAME: '0.0.0.0' }); + const server = await runtime.startOpenCode(); + const [binary, args] = spawnMock.mock.calls[0]; + + expect(binary).toBe('opencode'); + expect(args).toEqual(['serve', '--hostname', '0.0.0.0', '--port', '45678']); + + await server.close(); + expect(server.signalCode).toBe('SIGTERM'); + }); + it('strips AppImage ARGV0 from managed OpenCode launch env', async () => { delete process.env.OPENCODE_BINARY; const previousArgv0 = process.env.ARGV0;