fix: allow Docker web UI loopback aliases

This commit is contained in:
Mohamed Boudra
2026-06-27 09:28:34 +00:00
parent ab3ed56513
commit 821d194fbe
3 changed files with 127 additions and 4 deletions

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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<string>,
password: string | undefined,