fix: avoid wildcard websocket hosts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 01:51:03 +00:00
1 parent 3463456bee
commit 996c0e95e0
7 files changed
+104 -8

No files matched your search

+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveViteHmrPort } from "../app.ts";
import { resolveViteHmrHost, resolveViteHmrPort } from "../app.ts";
describe("resolveViteHmrPort", () => {
it("uses serverPort + 10000 when the result stays in range", () => {
@@ -17,3 +17,15 @@ describe("resolveViteHmrPort", () => {
expect(resolveViteHmrPort(9_000)).toBe(19_000);
});
});
describe("resolveViteHmrHost", () => {
it("omits wildcard bind hosts so Vite uses the browser hostname", () => {
expect(resolveViteHmrHost("0.0.0.0")).toBeUndefined();
expect(resolveViteHmrHost("::")).toBeUndefined();
});
it("keeps concrete bind hosts", () => {
expect(resolveViteHmrHost("127.0.0.1")).toBe("127.0.0.1");
expect(resolveViteHmrHost("paperclip-dev")).toBe("paperclip-dev");
});
});
+8 -1
View File
@@ -96,6 +96,12 @@ export function resolveViteHmrPort(serverPort: number): number {
return Math.max(1_024, serverPort - 10_000);
}
export function resolveViteHmrHost(bindHost: string): string | undefined {
const normalized = bindHost.trim().toLowerCase();
if (normalized === "0.0.0.0" || normalized === "::") return undefined;
return bindHost;
}
export function shouldServeViteDevHtml(req: ExpressRequest): boolean {
const pathname = req.path;
if (VITE_DEV_STATIC_PATHS.has(pathname)) return false;
@@ -373,6 +379,7 @@ export async function createApp(
const uiRoot = path.resolve(__dirname, "../../ui");
const publicUiRoot = path.resolve(uiRoot, "public");
const hmrPort = resolveViteHmrPort(opts.serverPort);
const hmrHost = resolveViteHmrHost(opts.bindHost);
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: uiRoot,
@@ -380,7 +387,7 @@ export async function createApp(
server: {
middlewareMode: true,
hmr: {
host: opts.bindHost,
...(hmrHost ? { host: hmrHost } : {}),
port: hmrPort,
clientPort: hmrPort,
},
@@ -6,6 +6,7 @@ import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
import { buildSameOriginWebSocketUrl } from "../../lib/websocket-url";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -279,8 +280,9 @@ export function useLiveRunTranscripts({
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onmessage = (message) => {
+4 -2
View File
@@ -14,6 +14,7 @@ import { clearIssueExecutionRun, removeLiveRunById } from "../lib/optimistic-iss
import { queryKeys } from "../lib/queryKeys";
import { toCompanyRelativePath } from "../lib/company-routes";
import { useLocation } from "../lib/router";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
const TOAST_COOLDOWN_MAX = 3;
@@ -979,8 +980,9 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`,
);
const nextSocket = new WebSocket(url);
socket = nextSocket;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { browserReachableHost, buildSameOriginWebSocketUrl } from "./websocket-url";
describe("browserReachableHost", () => {
it("keeps concrete browser hosts unchanged", () => {
expect(browserReachableHost({
protocol: "http:",
hostname: "paperclip-dev",
host: "paperclip-dev:46259",
port: "46259",
})).toBe("paperclip-dev:46259");
});
it("rewrites wildcard IPv4 bind hosts to localhost", () => {
expect(browserReachableHost({
protocol: "http:",
hostname: "0.0.0.0",
host: "0.0.0.0:46259",
port: "46259",
})).toBe("localhost:46259");
});
it("rewrites wildcard IPv6 bind hosts to localhost", () => {
expect(browserReachableHost({
protocol: "http:",
hostname: "::",
host: "[::]:46259",
port: "46259",
})).toBe("localhost:46259");
});
});
describe("buildSameOriginWebSocketUrl", () => {
it("uses wss for https pages", () => {
expect(buildSameOriginWebSocketUrl("/api/events/ws", {
protocol: "https:",
hostname: "example.com",
host: "example.com",
port: "",
})).toBe("wss://example.com/api/events/ws");
});
it("does not emit 0.0.0.0 websocket URLs", () => {
expect(buildSameOriginWebSocketUrl("api/events/ws", {
protocol: "http:",
hostname: "0.0.0.0",
host: "0.0.0.0:46259",
port: "46259",
})).toBe("ws://localhost:46259/api/events/ws");
});
});
+20
View File
@@ -0,0 +1,20 @@
type BrowserLocationLike = Pick<Location, "host" | "hostname" | "port" | "protocol">;
function isWildcardHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]";
}
export function browserReachableHost(location: BrowserLocationLike = window.location): string {
if (!isWildcardHost(location.hostname)) return location.host;
return location.port ? `localhost:${location.port}` : "localhost";
}
export function buildSameOriginWebSocketUrl(
path: string,
location: BrowserLocationLike = window.location,
): string {
const protocol = location.protocol === "https:" ? "wss" : "ws";
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${protocol}://${browserReachableHost(location)}${normalizedPath}`;
}
+4 -2
View File
@@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
@@ -3865,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onopen = () => {