mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix: allow Docker web UI loopback aliases
This commit is contained in:
@@ -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
|
||||
|
||||
25
packages/server/src/server/websocket-server.origin.test.ts
Normal file
25
packages/server/src/server/websocket-server.origin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user