diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d1b4f3e..da7647bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - OpenCode no longer indexes your home directory by mistake ([#1704](https://github.com/getpaseo/paseo/pull/1704) by [@rex-chang](https://github.com/rex-chang)) - ACP sessions load with the correct project folder and MCP server settings ([#1624](https://github.com/getpaseo/paseo/pull/1624) by [@theslava](https://github.com/theslava)) - Docker images serve the bundled web UI from global installs +- Docker web UI connections work when opened through either `localhost` or `127.0.0.1` - Docker images can extract downloaded local speech models ## 0.1.101 - 2026-06-26 diff --git a/packages/server/src/server/websocket-server.origin.test.ts b/packages/server/src/server/websocket-server.origin.test.ts new file mode 100644 index 000000000..c3f0c2851 --- /dev/null +++ b/packages/server/src/server/websocket-server.origin.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; + +import { isWebSocketSameOrigin } from "./websocket-server.js"; + +describe("isWebSocketSameOrigin", () => { + test("accepts exact same-origin websocket upgrades", () => { + expect(isWebSocketSameOrigin("http://localhost:6767", "localhost:6767")).toBe(true); + expect(isWebSocketSameOrigin("https://paseo.example.com", "paseo.example.com")).toBe(true); + }); + + test("accepts loopback aliases on the same port", () => { + expect(isWebSocketSameOrigin("http://127.0.0.1:32775", "localhost:32775")).toBe(true); + expect(isWebSocketSameOrigin("http://localhost:32775", "127.0.0.1:32775")).toBe(true); + expect(isWebSocketSameOrigin("http://[::1]:32775", "localhost:32775")).toBe(true); + }); + + test("rejects loopback aliases on different ports", () => { + expect(isWebSocketSameOrigin("http://127.0.0.1:32775", "localhost:6767")).toBe(false); + }); + + test("rejects non-loopback cross-origin upgrades", () => { + expect(isWebSocketSameOrigin("http://evil.example:32775", "localhost:32775")).toBe(false); + expect(isWebSocketSameOrigin("http://127.0.0.1:32775", "paseo.example.com:32775")).toBe(false); + }); +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index c2eb412b2..91f64db92 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -669,10 +669,7 @@ export class VoiceAssistantWebSocketServer { callback(false, 403, "Host not allowed"); return; } - const sameOrigin = - !!origin && - !!requestHost && - (origin === `http://${requestHost}` || origin === `https://${requestHost}`); + const sameOrigin = isWebSocketSameOrigin(origin, requestHost); if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) { callback(true); @@ -1961,6 +1958,106 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { }; } +interface HostAuthority { + hostname: string; + port: string | null; +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +function parseHostAuthority(host: string): HostAuthority | null { + const trimmed = host.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + if (end === -1) { + return null; + } + const hostname = stripIpv6Brackets(trimmed.slice(0, end + 1)).toLowerCase(); + const rest = trimmed.slice(end + 1); + if (!rest) { + return { hostname, port: null }; + } + if (!rest.startsWith(":")) { + return null; + } + const port = rest.slice(1); + return port ? { hostname, port } : null; + } + + const firstColon = trimmed.indexOf(":"); + if (firstColon === -1) { + return { hostname: trimmed.toLowerCase(), port: null }; + } + if (trimmed.indexOf(":", firstColon + 1) !== -1) { + return { hostname: trimmed.toLowerCase(), port: null }; + } + const hostname = trimmed.slice(0, firstColon).toLowerCase(); + const port = trimmed.slice(firstColon + 1); + return hostname && port ? { hostname, port } : null; +} + +function defaultPortForOriginProtocol(protocol: string): string | null { + if (protocol === "http:") { + return "80"; + } + if (protocol === "https:") { + return "443"; + } + return null; +} + +function isLoopbackAlias(hostname: string): boolean { + const normalized = stripIpv6Brackets(hostname).toLowerCase(); + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + if (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") { + return true; + } + return /^127(?:\.\d{1,3}){3}$/.test(normalized); +} + +export function isWebSocketSameOrigin( + origin: string | undefined, + requestHost: string | null, +): boolean { + if (!origin || !requestHost) { + return false; + } + + if (origin === `http://${requestHost}` || origin === `https://${requestHost}`) { + return true; + } + + let originUrl: URL; + try { + originUrl = new URL(origin); + } catch { + return false; + } + const originPort = originUrl.port || defaultPortForOriginProtocol(originUrl.protocol); + if (!originPort) { + return false; + } + + const requestAuthority = parseHostAuthority(requestHost); + if (!requestAuthority) { + return false; + } + const requestPort = requestAuthority.port || defaultPortForOriginProtocol(originUrl.protocol); + if (originPort !== requestPort) { + return false; + } + + return isLoopbackAlias(originUrl.hostname) && isLoopbackAlias(requestAuthority.hostname); +} + function selectWebSocketProtocol( protocols: Set, password: string | undefined,