From 49dda48a4fbc51f8b4277cdbc427b86434503284 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Mar 2026 12:49:38 -0700 Subject: [PATCH 1/2] Verify WebSocket origins and enforce allowed host checks - Add `--allowed-hosts` / `T3CODE_ALLOWED_HOSTS` config parsing and validation - Reject HTTP and WebSocket requests from disallowed hosts with 403 - Enforce production WebSocket origin matching (including proxy headers), with dev-url bypass - Expand CLI and server tests for host filtering and origin verification --- apps/server/src/config.ts | 2 + apps/server/src/main.test.ts | 28 ++++++ apps/server/src/main.ts | 86 +++++++++++++++++- apps/server/src/wsServer.test.ts | 144 +++++++++++++++++++++++++++++-- apps/server/src/wsServer.ts | 100 ++++++++++++++++++++- 5 files changed, 353 insertions(+), 7 deletions(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d..53fcd3232e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -26,6 +26,7 @@ export interface ServerConfigShape { readonly devUrl: URL | undefined; readonly noBrowser: boolean; readonly authToken: string | undefined; + readonly allowedHosts: readonly string[]; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -50,6 +51,7 @@ export class ServerConfig extends ServiceMap.Service { "--no-browser", "--auth-token", "auth-secret", + "--allowed-hosts", + "app.example, ADMIN.example:8443", ]); assert.equal(start.mock.calls.length, 1); @@ -110,6 +112,7 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "auth-secret"); + assert.deepEqual(resolvedConfig?.allowedHosts, ["app.example", "admin.example:8443"]); assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); assert.equal(resolvedConfig?.logWebSocketEvents, true); assert.equal(stop.mock.calls.length, 1); @@ -135,6 +138,7 @@ it.layer(testLayer)("server CLI command", (it) => { VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", + T3CODE_ALLOWED_HOSTS: "app.example, admin.example:8443", }); assert.equal(start.mock.calls.length, 1); @@ -145,6 +149,7 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); + assert.deepEqual(resolvedConfig?.allowedHosts, ["app.example", "admin.example:8443"]); assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); assert.equal(resolvedConfig?.logWebSocketEvents, true); assert.equal(findAvailablePort.mock.calls.length, 0); @@ -233,6 +238,18 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("prefers --allowed-hosts over T3CODE_ALLOWED_HOSTS", () => + Effect.gen(function* () { + yield* runCli(["--allowed-hosts", "cli.example:4321"], { + T3CODE_ALLOWED_HOSTS: "env.example", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(start.mock.calls.length, 1); + assert.deepEqual(resolvedConfig?.allowedHosts, ["cli.example:4321"]); + }), + ); + it.effect("records a startup heartbeat with thread/project counts", () => Effect.gen(function* () { const recordTelemetry = vi.fn( @@ -288,6 +305,17 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("does not start server for invalid --allowed-hosts values", () => + Effect.gen(function* () { + yield* runCli(["--allowed-hosts", "https://app.example/path"]).pipe( + Effect.catch(() => Effect.void), + ); + + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); + }), + ); + it.effect("does not start server for out-of-range --port values", () => Effect.gen(function* () { yield* runCli(["--port", "70000"]); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..e914da361b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,7 +6,19 @@ * * @module CliConfig */ -import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + Option, + Path, + Schema, + SchemaIssue, + SchemaTransformation, + ServiceMap, +} from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; import { @@ -32,6 +44,63 @@ export class StartupError extends Data.TaggedError("StartupError")<{ readonly cause?: unknown; }> {} +const AllowedHost = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (input) => { + const candidate = input.trim(); + const invalidHostIssue = new SchemaIssue.InvalidValue(Option.some(input), { + message: `Invalid host "${input}". Expected bare host[:port], for example "app.example" or "app.example:443".`, + }); + + if (candidate.length === 0 || candidate.includes("://")) { + return Effect.fail(invalidHostIssue); + } + + const candidateUrl = `http://${candidate}`; + if (!URL.canParse(candidateUrl)) { + return Effect.fail(invalidHostIssue); + } + + const parsed = new URL(candidateUrl); + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + return Effect.fail(invalidHostIssue); + } + + return Effect.succeed(parsed.host.toLowerCase()); + }, + encode: (input) => Effect.succeed(input), + }), + ), +); + +const AllowedHostsCsv = Schema.String.pipe( + Schema.decodeTo( + Schema.Array(AllowedHost), + SchemaTransformation.transformOrFail({ + decode: (input) => + Effect.succeed( + Array.from( + new Set( + input + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), + ), + encode: (input: readonly string[]) => Effect.succeed(input.join(", ")), + }), + ), +); + interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; @@ -40,6 +109,7 @@ interface CliInput { readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; + readonly allowedHosts: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } @@ -112,6 +182,10 @@ const CliEnvConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + allowedHosts: Config.schema(AllowedHostsCsv, "T3CODE_ALLOWED_HOSTS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -158,6 +232,7 @@ const ServerConfigLive = (input: CliInput) => const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; + const allowedHosts = Option.getOrUndefined(input.allowedHosts) ?? env.allowedHosts ?? []; const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, env.autoBootstrapProjectFromCwd ?? mode === "web", @@ -185,6 +260,7 @@ const ServerConfigLive = (input: CliInput) => devUrl, noBrowser, authToken, + allowedHosts, autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; @@ -317,6 +393,13 @@ const authTokenFlag = Flag.string("auth-token").pipe( Flag.withAlias("token"), Flag.optional, ); +const allowedHostsFlag = Flag.string("allowed-hosts").pipe( + Flag.withSchema(AllowedHostsCsv), + Flag.withDescription( + "Comma-separated host[:port] values allowed for inbound HTTP and WebSocket traffic (equivalent to T3CODE_ALLOWED_HOSTS).", + ), + Flag.optional, +); const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", @@ -339,6 +422,7 @@ export const t3Cli = Command.make("t3", { devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, + allowedHosts: allowedHostsFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, }).pipe( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..dac5f02fe7 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -280,10 +280,16 @@ function asWebSocketResponse(message: unknown): WebSocketResponse | null { return message as WebSocketResponse; } -function connectWsOnce(port: number, token?: string): Promise { +function connectWsOnce( + port: number, + token?: string, + headers?: Record, +): Promise { return new Promise((resolve, reject) => { const query = token ? `?token=${encodeURIComponent(token)}` : ""; - const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); + const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`, { + headers: headers ?? { origin: `http://127.0.0.1:${port}` }, + }); const channels: SocketChannels = { push: { queue: [], waiters: [] }, response: { queue: [], waiters: [] }, @@ -307,12 +313,17 @@ function connectWsOnce(port: number, token?: string): Promise { }); } -async function connectWs(port: number, token?: string, attempts = 5): Promise { +async function connectWs( + port: number, + token?: string, + attempts = 5, + headers?: Record, +): Promise { let lastError: unknown = new Error("WebSocket connection failed"); for (let attempt = 0; attempt < attempts; attempt += 1) { try { - return await connectWsOnce(port, token); + return await connectWsOnce(port, token, headers); } catch (error) { lastError = error; if (attempt < attempts - 1) { @@ -328,8 +339,9 @@ async function connectWs(port: number, token?: string, attempts = 5): Promise, ): Promise<[WebSocket, WsPushMessage]> { - const ws = await connectWs(port, token); + const ws = await connectWs(port, token, 5, headers); const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); return [ws, welcome]; } @@ -401,6 +413,7 @@ async function rewriteKeybindingsAndWaitForPush( async function requestPath( port: number, requestPath: string, + headers?: Record, ): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { const req = Http.request( @@ -409,6 +422,7 @@ async function requestPath( port, path: requestPath, method: "GET", + headers, }, (res) => { const chunks: Buffer[] = []; @@ -474,6 +488,7 @@ describe("WebSocket Server", () => { logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; + allowedHosts?: readonly string[]; stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; @@ -508,6 +523,7 @@ describe("WebSocket Server", () => { devUrl: options.devUrl ? new URL(options.devUrl) : undefined, noBrowser: true, authToken: options.authToken, + allowedHosts: options.allowedHosts ?? [], autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), } satisfies ServerConfigShape); @@ -648,6 +664,44 @@ describe("WebSocket Server", () => { expect(await response.text()).toContain("static-root"); }); + it("rejects HTTP requests when the host is not allowed", async () => { + const stateDir = makeTempDir("t3code-state-host-guard-"); + const staticDir = makeTempDir("t3code-static-host-guard-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

host-guard

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + allowedHosts: ["app.example"], + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/"); + expect(response.statusCode).toBe(403); + expect(response.body).toBe("Forbidden host"); + }); + + it("accepts HTTP requests when the host is allowed", async () => { + const stateDir = makeTempDir("t3code-state-host-allow-"); + const staticDir = makeTempDir("t3code-static-host-allow-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

host-allow

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + allowedHosts: ["app.example"], + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/", { host: "app.example" }); + expect(response.statusCode).toBe(200); + expect(response.body).toContain("host-allow"); + }); + it("rejects static path traversal attempts", async () => { const stateDir = makeTempDir("t3code-state-static-traversal-"); const staticDir = makeTempDir("t3code-static-traversal-"); @@ -1812,4 +1866,84 @@ describe("WebSocket Server", () => { const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); connections.push(authorizedWs); }); + + it("rejects websocket connections in production when the origin header is missing", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect(connectWs(port, undefined, 5, {})).rejects.toThrow("WebSocket connection failed"); + }); + + it("rejects websocket connections in production when the origin header is mismatched", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect( + connectWs(port, undefined, 5, { + origin: "http://malicious.example", + }), + ).rejects.toThrow("WebSocket connection failed"); + }); + + it("accepts websocket connections in production when the origin header matches the request origin", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port, undefined, { + origin: `http://127.0.0.1:${port}`, + }); + connections.push(ws); + }); + + it("accepts websocket connections in production behind a reverse proxy when forwarded origin matches", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port, undefined, { + origin: "https://t3.example", + "x-forwarded-host": "t3.example", + "x-forwarded-proto": "https", + }); + connections.push(ws); + }); + + it("rejects websocket connections when the host is not allowed", async () => { + server = await createTestServer({ + cwd: "/test", + allowedHosts: ["app.example"], + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); + }); + + it("accepts websocket connections when the forwarded host is allowed", async () => { + server = await createTestServer({ + cwd: "/test", + allowedHosts: ["app.example"], + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port, undefined, { + origin: "https://app.example", + "x-forwarded-host": "app.example", + "x-forwarded-proto": "https", + }); + connections.push(ws); + }); + + it("skips origin verification when running against a dev URL", async () => { + server = await createTestServer({ cwd: "/test", devUrl: "http://localhost:5173" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + }); }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..5fc165ac4c 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -111,8 +111,10 @@ const isServerNotRunningError = (error: Error): boolean => { }; function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { + const statusText = + statusCode === 401 ? "Unauthorized" : statusCode === 403 ? "Forbidden" : "Bad Request"; socket.end( - `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + + `HTTP/1.1 ${statusCode} ${statusText}\r\n` + "Connection: close\r\n" + "Content-Type: text/plain\r\n" + `Content-Length: ${Buffer.byteLength(message)}\r\n` + @@ -121,6 +123,85 @@ function rejectUpgrade(socket: Duplex, statusCode: number, message: string): voi ); } +function getFirstHeaderValue(header: string | readonly string[] | undefined): string | undefined { + if (typeof header === "string") { + return header; + } + return header?.[0]; +} + +function getForwardedHeaderValue( + header: string | readonly string[] | undefined, +): string | undefined { + const value = getFirstHeaderValue(header)?.trim(); + if (!value) { + return undefined; + } + const [firstValue] = value.split(","); + return firstValue?.trim() || undefined; +} + +function normalizeRequestHost(header: string | readonly string[] | undefined): string | null { + const host = getForwardedHeaderValue(header); + if (!host) { + return null; + } + + try { + const parsed = new URL(`http://${host}`); + return parsed.host.toLowerCase(); + } catch { + return null; + } +} + +function hasAllowedRequestHost( + request: http.IncomingMessage, + allowedHosts: ReadonlySet, +): boolean { + if (allowedHosts.size === 0) { + return true; + } + + const requestHost = + normalizeRequestHost(request.headers["x-forwarded-host"]) ?? + normalizeRequestHost(request.headers.host); + return requestHost !== null && allowedHosts.has(requestHost); +} + +function getUpgradeRequestOrigin(request: http.IncomingMessage): URL | null { + const host = + normalizeRequestHost(request.headers["x-forwarded-host"]) ?? + normalizeRequestHost(request.headers.host); + if (!host) { + return null; + } + + const protocol = + getForwardedHeaderValue(request.headers["x-forwarded-proto"]) ?? + ((request.socket as { encrypted?: boolean }).encrypted ? "https" : "http"); + + try { + return new URL(`${protocol}://${host}`); + } catch { + return null; + } +} + +function hasVerifiedWebSocketOrigin(request: http.IncomingMessage): boolean { + const requestOrigin = getUpgradeRequestOrigin(request); + const originHeader = getForwardedHeaderValue(request.headers.origin); + if (!requestOrigin || !originHeader) { + return false; + } + + try { + return new URL(originHeader).origin === requestOrigin.origin; + } catch { + return false; + } +} + function websocketRawToString(raw: unknown): string | null { if (typeof raw === "string") { return raw; @@ -244,10 +325,12 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< staticDir, devUrl, authToken, + allowedHosts, host, logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; + const allowedHostSet = new Set(allowedHosts); const availableEditors = resolveAvailableEditors(); const gitManager = yield* GitManager; @@ -423,6 +506,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { + if (!hasAllowedRequestHost(req, allowedHostSet)) { + respond(403, { "Content-Type": "text/plain" }, "Forbidden host"); + return; + } + const url = new URL(req.url ?? "/", `http://localhost:${port}`); if (tryHandleProjectFaviconRequest(url, res)) { return; @@ -932,6 +1020,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< httpServer.on("upgrade", (request, socket, head) => { socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake + if (!hasAllowedRequestHost(request, allowedHostSet)) { + rejectUpgrade(socket, 403, "Forbidden host"); + return; + } + + if (!devUrl && !hasVerifiedWebSocketOrigin(request)) { + rejectUpgrade(socket, 403, "Forbidden WebSocket origin"); + return; + } + if (authToken) { let providedToken: string | null = null; try { From 11091bf337ea4f6cafb24d7ab22d84c216713639 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Mar 2026 12:54:25 -0700 Subject: [PATCH 2/2] Refactor allowed host parsing for origin validation - validate allowed hosts with `Schema.Trim` + `Schema.check` - normalize hosts via lowercase decode step - simplify CSV decode/encode by removing Effect-wrapped transforms --- apps/server/src/main.ts | 62 +++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index e914da361b..8420d7ca1f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -44,59 +44,53 @@ export class StartupError extends Data.TaggedError("StartupError")<{ readonly cause?: unknown; }> {} -const AllowedHost = Schema.String.pipe( - Schema.decodeTo( - Schema.String, - SchemaTransformation.transformOrFail({ - decode: (input) => { - const candidate = input.trim(); - const invalidHostIssue = new SchemaIssue.InvalidValue(Option.some(input), { - message: `Invalid host "${input}". Expected bare host[:port], for example "app.example" or "app.example:443".`, - }); - - if (candidate.length === 0 || candidate.includes("://")) { - return Effect.fail(invalidHostIssue); +const invalidAllowedHostIssue = (input: string) => + new SchemaIssue.InvalidValue(Option.some(input), { + message: `Invalid host "${input}". Expected bare host[:port], for example "app.example" or "app.example:443".`, + }); + +const AllowedHost = Schema.Trim.pipe( + Schema.check( + Schema.makeFilter( + (input) => { + if (input.length === 0 || input.includes("://")) { + return invalidAllowedHostIssue(input); } - const candidateUrl = `http://${candidate}`; + const candidateUrl = `http://${input}`; if (!URL.canParse(candidateUrl)) { - return Effect.fail(invalidHostIssue); + return invalidAllowedHostIssue(input); } const parsed = new URL(candidateUrl); - if ( - parsed.username || + return parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash - ) { - return Effect.fail(invalidHostIssue); - } - - return Effect.succeed(parsed.host.toLowerCase()); + ? invalidAllowedHostIssue(input) + : true; }, - encode: (input) => Effect.succeed(input), - }), + { identifier: "AllowedHost" }, + ), ), + Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()), ); const AllowedHostsCsv = Schema.String.pipe( Schema.decodeTo( Schema.Array(AllowedHost), - SchemaTransformation.transformOrFail({ - decode: (input) => - Effect.succeed( - Array.from( - new Set( - input - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - ), + SchemaTransformation.transform({ + decode: (input): readonly string[] => + Array.from( + new Set( + input + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), ), ), - encode: (input: readonly string[]) => Effect.succeed(input.join(", ")), + encode: (input: readonly string[]) => input.join(", "), }), ), );