From 4d84cac98c8f79cd87ef0cfc8b80cee618e91a17 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:28:56 -0400 Subject: [PATCH] fix: reject cross-origin websocket upgrades before session lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что: - Добавлена общая проверка Origin для всех WebSocket-серверов панели (apps/dokploy/server/wss/origin.ts), подключена через verifyClient в terminal, docker-container-terminal, docker-container-logs, docker-stats, listen-deployment и drawer-logs. - Upgrade принимается, если Origin отсутствует (не браузерные клиенты с x-api-key), совпадает с Host запроса (host:port без учета схемы) или входит в trustedOrigins better-auth (resolveTrustedOriginsForAuthRequest: https://<домен панели>, http://:3000, dev-origins). Иначе ответ 403 до события connection. - Путь Next.js HMR не изменен. Зачем: - Cookie сессии SameSite=Lax, поэтому браузер прикладывает ее к WebSocket-рукопожатию со страниц на соседних поддоменах домена панели. Приложение, развернутое на таком поддомене, могло открыть терминал или поток логов от имени вошедшего пользователя. Риски: - Если прокси перед панелью переписывает Host и домен панели не указан в настройках веб-сервера, браузерные WebSocket получат 403; нужно указать домен панели в настройках. - Wildcard и tenant trustedOrigins (только для /sso/register) не учитываются. Проверки: - Команды и результаты: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/wss/origin.test.ts - 29 passed; то же для __test__/wss, cluster-router-security и domain-validation - 12 файлов, 127 passed; bun run typecheck - exit 0; bunx biome check по измененным файлам - 0 ошибок, 5 старых предупреждений useOptionalChain не в измененных строках. Новые handler-тесты на старых обработчиках из origin/AgentHits-Dev - 6 failed (upgrade принимался и шел до проверки сессии). - Ограничения: не проверялось вручную в браузере и за реальным Traefik; полный набор тестов не запускался. What: - Added a shared Origin check for every panel WebSocket server (apps/dokploy/server/wss/origin.ts), wired through verifyClient in terminal, docker-container-terminal, docker-container-logs, docker-stats, listen-deployment and drawer-logs. - The upgrade is accepted when Origin is absent (non-browser x-api-key clients), matches the request Host (scheme-insensitive host:port) or is one of better-auth's trustedOrigins (resolveTrustedOriginsForAuthRequest: https://, http://:3000, dev origins). Otherwise it gets a 403 before the connection event. - The Next.js HMR path is unchanged. Why: - The session cookie is SameSite=Lax, so browsers attach it to WebSocket handshakes from pages on sibling subdomains of the panel domain. An app deployed on such a subdomain could open a terminal or log stream as the logged-in user. Risks: - If a proxy in front of the panel rewrites Host and the panel domain is not set in the web server settings, browser WebSockets get 403; the panel domain must be configured there. - Wildcard and tenant trustedOrigins (used only for /sso/register) are not honored. Checks: - Commands and results: CI=true bunx vitest run --config __test__/vitest.config.ts __test__/wss/origin.test.ts - 29 passed; same for __test__/wss, cluster-router-security and domain-validation - 12 files, 127 passed; bun run typecheck - exit 0; bunx biome check on changed files - 0 errors, 5 pre-existing useOptionalChain warnings outside changed lines. New handler tests against the old handlers from origin/AgentHits-Dev - 6 failed (upgrade accepted and reached the session check). - Limitations: not checked manually in a browser or behind a real Traefik; full test suite not run. Co-Authored-By: Claude Opus 5.5 --- apps/dokploy/__test__/wss/origin.test.ts | 276 ++++++++++++++++++ .../server/wss/docker-container-logs.ts | 2 + .../server/wss/docker-container-terminal.ts | 2 + apps/dokploy/server/wss/docker-stats.ts | 2 + apps/dokploy/server/wss/drawer-logs.ts | 2 + apps/dokploy/server/wss/listen-deployment.ts | 2 + apps/dokploy/server/wss/origin.ts | 54 ++++ apps/dokploy/server/wss/terminal.ts | 2 + 8 files changed, 342 insertions(+) create mode 100644 apps/dokploy/__test__/wss/origin.test.ts create mode 100644 apps/dokploy/server/wss/origin.ts diff --git a/apps/dokploy/__test__/wss/origin.test.ts b/apps/dokploy/__test__/wss/origin.test.ts new file mode 100644 index 00000000000..6d7c883cb64 --- /dev/null +++ b/apps/dokploy/__test__/wss/origin.test.ts @@ -0,0 +1,276 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; + +const mocks = vi.hoisted(() => ({ + applyWSSHandler: vi.fn(), + resolveTrustedOriginsForAuthRequest: vi.fn(), + validateRequest: vi.fn(), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + resolveTrustedOriginsForAuthRequest: + mocks.resolveTrustedOriginsForAuthRequest, + validateRequest: mocks.validateRequest, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: mocks.validateRequest, +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(), + checkServiceAccess: vi.fn(), + checkServicePermissionAndAccess: vi.fn(), + findMemberByUserId: vi.fn(), + hasPermission: vi.fn(), +})); + +vi.mock("@trpc/server/adapters/ws", () => ({ + applyWSSHandler: mocks.applyWSSHandler, +})); + +vi.mock("../../server/api/root", () => ({ appRouter: {} })); +vi.mock("../../server/api/trpc", () => ({ createTRPCContext: vi.fn() })); + +vi.mock("node-pty", () => ({ spawn: vi.fn() })); + +vi.mock("ssh2", () => ({ + Client: vi.fn(() => ({ + once: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + connect: vi.fn().mockReturnThis(), + end: vi.fn(), + })), +})); + +const { isWebSocketOriginAllowed, verifyWebSocketOrigin } = await import( + "../../server/wss/origin" +); +const { setupTerminalWebSocketServer } = await import( + "../../server/wss/terminal" +); +const { setupDockerContainerTerminalWebSocketServer } = await import( + "../../server/wss/docker-container-terminal" +); +const { setupDockerContainerLogsWebSocketServer } = await import( + "../../server/wss/docker-container-logs" +); +const { setupDockerStatsMonitoringSocketServer } = await import( + "../../server/wss/docker-stats" +); +const { setupDeploymentLogsWebSocketServer } = await import( + "../../server/wss/listen-deployment" +); +const { setupDrawerLogsWebSocketServer } = await import( + "../../server/wss/drawer-logs" +); + +const PANEL_ORIGIN = "https://panel.example.com"; + +describe("isWebSocketOriginAllowed", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveTrustedOriginsForAuthRequest.mockResolvedValue([ + PANEL_ORIGIN, + "http://203.0.113.10:3000", + ]); + }); + + it("allows handshakes without Origin (non-browser clients)", async () => { + await expect( + isWebSocketOriginAllowed(undefined, "panel.example.com"), + ).resolves.toBe(true); + expect(mocks.resolveTrustedOriginsForAuthRequest).not.toHaveBeenCalled(); + }); + + it.each([ + ["https://panel.example.com", "panel.example.com"], + ["http://panel.example.com", "panel.example.com"], + ["https://panel.example.com", "panel.example.com:443"], + ["https://PANEL.example.com", "panel.EXAMPLE.com"], + ["http://localhost:3000", "localhost:3000"], + ["https://localhost:3000", "localhost:3000"], + ])( + "allows origin %s on the same host %s without a settings lookup", + async (origin, host) => { + await expect(isWebSocketOriginAllowed(origin, host)).resolves.toBe(true); + expect(mocks.resolveTrustedOriginsForAuthRequest).not.toHaveBeenCalled(); + }, + ); + + it("allows a configured trusted origin when the proxy rewrites Host", async () => { + await expect( + isWebSocketOriginAllowed(PANEL_ORIGIN, "dokploy:3000"), + ).resolves.toBe(true); + await expect( + isWebSocketOriginAllowed("http://203.0.113.10:3000", "dokploy:3000"), + ).resolves.toBe(true); + }); + + it("rejects a sibling subdomain of the panel domain", async () => { + await expect( + isWebSocketOriginAllowed("https://app.example.com", "panel.example.com"), + ).resolves.toBe(false); + }); + + it("rejects a foreign origin", async () => { + await expect( + isWebSocketOriginAllowed("https://attacker.test", "panel.example.com"), + ).resolves.toBe(false); + }); + + it("rejects another port on the same host", async () => { + await expect( + isWebSocketOriginAllowed("http://203.0.113.10:8080", "203.0.113.10:3000"), + ).resolves.toBe(false); + }); + + it.each(["null", "", "file://", "chrome-extension://abcdef"])( + "rejects the opaque or non-http origin %j", + async (origin) => { + await expect( + isWebSocketOriginAllowed(origin, "panel.example.com"), + ).resolves.toBe(false); + }, + ); + + it("rejects a lookalike host that only starts with the panel domain", async () => { + await expect( + isWebSocketOriginAllowed( + "https://panel.example.com.attacker.test", + "panel.example.com", + ), + ).resolves.toBe(false); + }); + + it("fails closed with 403 when the trusted origin lookup throws", async () => { + mocks.resolveTrustedOriginsForAuthRequest.mockRejectedValue( + new Error("settings unavailable"), + ); + const callback = vi.fn(); + + verifyWebSocketOrigin( + { + origin: "https://app.example.com", + req: { + headers: { host: "panel.example.com" }, + } as http.IncomingMessage, + }, + callback, + ); + + await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(false, 403)); + }); +}); + +const listen = async (server: http.Server) => + new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as AddressInfo).port); + }); + }); + +const closeServer = async (server: http.Server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + +const openWithOrigin = async (port: number, path: string, origin: string) => + new Promise<{ closeCode?: number; error?: string }>((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("WebSocket timeout")); + }, 2000); + const ws = new WebSocket(`ws://127.0.0.1:${port}${path}`, { + origin, + headers: { host: "panel.example.com" }, + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ error: error.message }); + }); + ws.on("close", (code) => { + clearTimeout(timeout); + resolve({ closeCode: code }); + }); + }); + +const handlers = [ + ["terminal", setupTerminalWebSocketServer, "/terminal?serverId=local"], + [ + "docker container terminal", + setupDockerContainerTerminalWebSocketServer, + "/docker-container-terminal?containerId=abc123def456", + ], + [ + "docker container logs", + setupDockerContainerLogsWebSocketServer, + "/docker-container-logs?containerId=abc123def456", + ], + [ + "docker stats", + setupDockerStatsMonitoringSocketServer, + "/listen-docker-stats-monitoring?appName=app", + ], + [ + "deployment logs", + setupDeploymentLogsWebSocketServer, + "/listen-deployment?deploymentId=deployment-1", + ], + ["drawer logs", setupDrawerLogsWebSocketServer, "/drawer-logs"], +] as const; + +describe("WebSocket upgrade origin gate", () => { + let server: http.Server | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveTrustedOriginsForAuthRequest.mockResolvedValue([PANEL_ORIGIN]); + mocks.validateRequest.mockResolvedValue({ user: null, session: null }); + }); + + afterEach(async () => { + if (server?.listening) { + await closeServer(server); + } + server = undefined; + }); + + it.each(handlers)( + "%s rejects a sibling-subdomain origin with 403 before any session lookup", + async (_name, setup, path) => { + server = http.createServer(); + setup(server); + const port = await listen(server); + + await expect( + openWithOrigin(port, path, "https://app.example.com"), + ).resolves.toEqual({ error: "Unexpected server response: 403" }); + + expect(mocks.validateRequest).not.toHaveBeenCalled(); + }, + ); + + it.each(handlers)( + "%s still reaches the session check for the panel origin", + async (_name, setup, path) => { + server = http.createServer(); + setup(server); + const port = await listen(server); + + const result = await openWithOrigin(port, path, PANEL_ORIGIN); + + expect(result.error).toBeUndefined(); + expect(result.closeCode).toBeDefined(); + expect(mocks.validateRequest).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index 2cfbc1dde8f..38ae6a8b2b1 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -6,6 +6,7 @@ import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerLogsWebSocket } from "./docker-permission"; +import { verifyWebSocketOrigin } from "./origin"; import { getShell, isValidContainerId, @@ -19,6 +20,7 @@ export const setupDockerContainerLogsWebSocketServer = ( ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/docker-container-logs", }); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index cbccfa1cc99..54d317f5de0 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -6,6 +6,7 @@ import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerTerminalWebSocket } from "./docker-permission"; +import { verifyWebSocketOrigin } from "./origin"; import { isValidContainerId, isValidShell, @@ -18,6 +19,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/docker-container-terminal", }); diff --git a/apps/dokploy/server/wss/docker-stats.ts b/apps/dokploy/server/wss/docker-stats.ts index c16fb6a651b..8bd4f2cec83 100644 --- a/apps/dokploy/server/wss/docker-stats.ts +++ b/apps/dokploy/server/wss/docker-stats.ts @@ -12,6 +12,7 @@ import { quote } from "shell-quote"; import { WebSocketServer } from "ws"; import { assertContainerMetricsServiceAccess } from "@/server/api/utils/monitoring-access"; import { canAccessDockerOverWss } from "./authorize"; +import { verifyWebSocketOrigin } from "./origin"; import { canAccessMonitoringWebSocket } from "./server-permission"; type AppType = "application" | "stack" | "docker-compose"; @@ -66,6 +67,7 @@ export const setupDockerStatsMonitoringSocketServer = ( ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/listen-docker-stats-monitoring", }); diff --git a/apps/dokploy/server/wss/drawer-logs.ts b/apps/dokploy/server/wss/drawer-logs.ts index e68c2fedc69..ed76e24770d 100644 --- a/apps/dokploy/server/wss/drawer-logs.ts +++ b/apps/dokploy/server/wss/drawer-logs.ts @@ -4,12 +4,14 @@ import { applyWSSHandler } from "@trpc/server/adapters/ws"; import { WebSocketServer } from "ws"; import { appRouter } from "../api/root"; import { createTRPCContext } from "../api/trpc"; +import { verifyWebSocketOrigin } from "./origin"; export const setupDrawerLogsWebSocketServer = ( server: http.Server, ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/drawer-logs", }); diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index c96f89abe7d..4e2a09d0190 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -18,6 +18,7 @@ import { } from "@dokploy/server/wss/utils"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; +import { verifyWebSocketOrigin } from "./origin"; const getDeploymentLogPathRoot = ( deployment: Awaited>, @@ -181,6 +182,7 @@ export const setupDeploymentLogsWebSocketServer = ( ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/listen-deployment", }); diff --git a/apps/dokploy/server/wss/origin.ts b/apps/dokploy/server/wss/origin.ts new file mode 100644 index 00000000000..3ffffe70f0d --- /dev/null +++ b/apps/dokploy/server/wss/origin.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage } from "node:http"; +import { resolveTrustedOriginsForAuthRequest } from "@dokploy/server"; + +const parseHttpOrigin = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + } catch { + return null; + } +}; + +// Host carries no scheme and the panel often sits behind a TLS-terminating +// proxy, so Host is parsed with the origin's own scheme: this compares +// host:port only and still normalizes default ports (":443" vs none). +const matchesRequestHost = (origin: URL, host: string | undefined) => { + if (!host) return false; + try { + return new URL(`${origin.protocol}//${host}`).host === origin.host; + } catch { + return false; + } +}; + +export const isWebSocketOriginAllowed = async ( + origin: string | undefined, + host: string | undefined, +) => { + // Browsers always send Origin on WebSocket handshakes, so only non-browser + // clients (x-api-key scripts, CLI tools) omit it, and those cannot ride a + // browser's ambient session cookie. + if (origin === undefined) return true; + + const originUrl = parseHttpOrigin(origin); + if (!originUrl) return false; + if (matchesRequestHost(originUrl, host)) return true; + + const trustedOrigins = await resolveTrustedOriginsForAuthRequest(); + return trustedOrigins.some( + (trusted) => parseHttpOrigin(trusted)?.origin === originUrl.origin, + ); +}; + +// Runs inside ws.handleUpgrade, before the "connection" event, so a rejected +// cross-site handshake gets a 403 without any session lookup. +export const verifyWebSocketOrigin = ( + info: { origin?: string; req: IncomingMessage }, + callback: (verified: boolean, code?: number) => void, +) => { + isWebSocketOriginAllowed(info.origin, info.req.headers.host).then( + (allowed) => (allowed ? callback(true) : callback(false, 403)), + () => callback(false, 403), + ); +}; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 5c3683039ee..4ce4ed87a5b 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -11,6 +11,7 @@ import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; import { canAccessTerminalOverWss } from "./authorize"; +import { verifyWebSocketOrigin } from "./origin"; import { canAccessServerTerminalWebSocket } from "./server-permission"; import { parseResizeMessage, @@ -75,6 +76,7 @@ export const setupTerminalWebSocketServer = ( ) => { const wssTerm = new WebSocketServer({ noServer: true, + verifyClient: verifyWebSocketOrigin, path: "/terminal", });