diff --git a/REMOTE.md b/REMOTE.md index 5eed2f803e..72e47d0056 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -14,7 +14,7 @@ The T3 Code CLI accepts the following configuration options, available either as | `--base-dir ` | `T3CODE_HOME` | Base directory. | | `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | -| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | +| `--auth-token ` | `T3CODE_AUTH_TOKEN` | Web-mode HTTP/WebSocket auth token. | > TIP: Use the `--help` flag to see all available options and their descriptions. @@ -23,6 +23,7 @@ The T3 Code CLI accepts the following configuration options, available either as - Always set `--auth-token` before exposing the server outside localhost. - Treat the token like a password. - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. +- In built web mode, the token protects both the web UI HTTP routes and WebSocket connections. ## 1) Build + run server for remote access @@ -34,19 +35,22 @@ TOKEN="$(openssl rand -hex 24)" bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser ``` -Then open on your phone: +Then open the tokenized link from your startup log, or construct it like this: -`http://:3773` +`http://:3773/?token=` Example: -`http://192.168.1.42:3773` +`http://192.168.1.42:3773/?token=0123456789abcdef` Notes: - `--host 0.0.0.0` listens on all IPv4 interfaces. - `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. - Ensure your OS firewall allows inbound TCP on the selected port. +- On first successful visit, T3 Code stores an auth cookie in the browser and redirects to a clean URL without `token=...`. +- If the server later starts with a different auth token, the old cookie is invalidated and the browser will ask for the new token again. +- If someone opens the app without a valid tokenized link or cookie, they will see a token entry page. ## 2) Tailnet / Tailscale access @@ -58,8 +62,8 @@ TOKEN="$(openssl rand -hex 24)" bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser ``` -Open from any device in your tailnet: +Open the tokenized link from any device in your tailnet: -`http://:3773` +`http://:3773/?token=` You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index b1e5da0c87..a7ea86eab9 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -19,6 +19,7 @@ import { Server, type ServerShape } from "./wsServer"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); +const openBrowser = vi.fn((_target: string) => undefined); let resolvedConfig: ServerConfigShape | null = null; const serverStart = Effect.acquireRelease( Effect.gen(function* () { @@ -48,7 +49,10 @@ const testLayer = Layer.mergeAll( stopSignal: Effect.void, } satisfies ServerShape), Layer.succeed(Open, { - openBrowser: (_target: string) => Effect.void, + openBrowser: (target: string) => + Effect.sync(() => { + openBrowser(target); + }), openInEditor: () => Effect.void, } satisfies OpenShape), AnalyticsService.layerTest, @@ -78,6 +82,7 @@ beforeEach(() => { resolvedConfig = null; start.mockImplementation(() => undefined); stop.mockImplementation(() => undefined); + openBrowser.mockImplementation(() => undefined); findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); }); @@ -178,6 +183,55 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("opens a tokenized share link in protected built web mode", () => + Effect.gen(function* () { + yield* runCli(["--mode", "web", "--port", "4010", "--auth-token", "auth-secret"], { + T3CODE_NO_BROWSER: "false", + }); + + assert.deepStrictEqual(openBrowser.mock.calls, [ + ["http://localhost:4010/?token=auth-secret"], + ]); + }), + ); + + it.effect("does not tokenize browser auto-open when a dev server url is configured", () => + Effect.gen(function* () { + yield* runCli( + [ + "--mode", + "web", + "--port", + "4010", + "--dev-url", + "http://127.0.0.1:5173", + "--auth-token", + "auth-secret", + ], + { + T3CODE_NO_BROWSER: "false", + }, + ); + + assert.deepStrictEqual(openBrowser.mock.calls, [["http://127.0.0.1:5173/"]]); + }), + ); + + it.effect("keeps browser auto-open on localhost for wildcard protected web hosts", () => + Effect.gen(function* () { + yield* runCli( + ["--mode", "web", "--port", "4010", "--host", "0.0.0.0", "--auth-token", "auth-secret"], + { + T3CODE_NO_BROWSER: "false", + }, + ); + + assert.deepStrictEqual(openBrowser.mock.calls, [ + ["http://localhost:4010/?token=auth-secret"], + ]); + }), + ); + it.effect("uses dynamic port discovery in web mode when port is omitted", () => Effect.gen(function* () { findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 17bf7f32f7..3fbaeff6b6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -27,6 +27,7 @@ import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { isProtectedWebAuthEnabled } from "./webAuth"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -206,6 +207,13 @@ const isWildcardHost = (host: string | undefined): boolean => const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +const tokenizedUrl = (baseUrl: string, authToken: string | undefined): string => { + if (!authToken) return baseUrl; + const url = new URL(baseUrl); + url.searchParams.set("token", authToken); + return url.toString(); +}; + export const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -253,23 +261,34 @@ const makeServerProgram = (input: CliInput) => yield* Effect.forkChild(recordStartupHeartbeat); const localUrl = `http://localhost:${config.port}`; - const bindUrl = - config.host && !isWildcardHost(config.host) + const hostUrl = + config.host && config.host.length > 0 ? `http://${formatHostForUrl(config.host)}:${config.port}` - : localUrl; + : undefined; + const protectedWebAuthEnabled = isProtectedWebAuthEnabled(config); + const openBaseUrl = config.host && !isWildcardHost(config.host) ? hostUrl! : localUrl; + const shareBaseUrl = hostUrl ?? localUrl; + const openUrl = + protectedWebAuthEnabled && config.authToken + ? tokenizedUrl(openBaseUrl, config.authToken) + : (config.devUrl?.toString() ?? openBaseUrl); + const shareableUrl = + protectedWebAuthEnabled && config.authToken + ? tokenizedUrl(shareBaseUrl, config.authToken) + : undefined; const { authToken, devUrl, ...safeConfig } = config; yield* Effect.logInfo("T3 Code running", { ...safeConfig, devUrl: devUrl?.toString(), authEnabled: Boolean(authToken), + ...(shareableUrl ? { shareableUrl } : {}), }); if (!config.noBrowser) { - const target = config.devUrl?.toString() ?? bindUrl; - yield* openDeps.openBrowser(target).pipe( + yield* openDeps.openBrowser(openUrl).pipe( Effect.catch(() => Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, + hint: `Open ${openUrl} in your browser.`, }), ), ); diff --git a/apps/server/src/webAuth.test.ts b/apps/server/src/webAuth.test.ts new file mode 100644 index 0000000000..d552acdd63 --- /dev/null +++ b/apps/server/src/webAuth.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerConfigShape } from "./config"; +import { isProtectedWebAuthEnabled } from "./webAuth"; + +const makeConfig = (overrides: Partial = {}): ServerConfigShape => ({ + mode: "web", + port: 3773, + cwd: "/tmp/t3-test-workspace", + host: undefined, + baseDir: "/tmp/t3-test-home", + stateDir: "/tmp/t3-test-home/userdata", + dbPath: "/tmp/t3-test-home/userdata/state.sqlite", + logsDir: "/tmp/t3-test-home/logs", + serverLogPath: "/tmp/t3-test-home/logs/server.log", + providerLogsDir: "/tmp/t3-test-home/logs/provider", + providerEventLogPath: "/tmp/t3-test-home/logs/provider/events.log", + terminalLogsDir: "/tmp/t3-test-home/logs/terminals", + attachmentsDir: "/tmp/t3-test-home/attachments", + keybindingsConfigPath: "/tmp/t3-test-home/keybindings.json", + worktreesDir: "/tmp/t3-test-home/worktrees", + anonymousIdPath: "/tmp/t3-test-home/userdata/anonymous-id", + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + authToken: "auth-secret", + autoBootstrapProjectFromCwd: true, + logWebSocketEvents: false, + ...overrides, +}); + +describe("isProtectedWebAuthEnabled", () => { + it("returns true for built web mode with an auth token", () => { + expect(isProtectedWebAuthEnabled(makeConfig())).toBe(true); + }); + + it("returns false when a dev server url is present", () => { + expect( + isProtectedWebAuthEnabled( + makeConfig({ + devUrl: new URL("http://localhost:5173"), + }), + ), + ).toBe(false); + }); + + it("returns false without an auth token", () => { + expect( + isProtectedWebAuthEnabled( + makeConfig({ + authToken: undefined, + }), + ), + ).toBe(false); + }); + + it("returns false outside web mode", () => { + expect( + isProtectedWebAuthEnabled( + makeConfig({ + mode: "desktop", + }), + ), + ).toBe(false); + }); +}); diff --git a/apps/server/src/webAuth.ts b/apps/server/src/webAuth.ts new file mode 100644 index 0000000000..60273fb73a --- /dev/null +++ b/apps/server/src/webAuth.ts @@ -0,0 +1,378 @@ +import type http from "node:http"; + +import type { ServerConfigShape } from "./config"; + +export const AUTH_COOKIE_NAME = "t3code_auth"; +export const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const AUTH_COOKIE_MAX_AGE_PAST = 0; + +export function isProtectedWebAuthEnabled(config: ServerConfigShape): boolean { + return ( + config.mode === "web" && config.devUrl === undefined && typeof config.authToken === "string" + ); +} + +export function parseCookies(cookieHeader: string | undefined): Map { + const cookies = new Map(); + if (!cookieHeader) return cookies; + + for (const part of cookieHeader.split(";")) { + const trimmed = part.trim(); + if (trimmed.length === 0) continue; + const separator = trimmed.indexOf("="); + if (separator <= 0) continue; + const name = trimmed.slice(0, separator).trim(); + const rawValue = trimmed.slice(separator + 1).trim(); + try { + cookies.set(name, decodeURIComponent(rawValue)); + } catch { + cookies.set(name, rawValue); + } + } + + return cookies; +} + +export function isSecureRequest(request: http.IncomingMessage): boolean { + const forwardedProto = request.headers["x-forwarded-proto"]; + if (typeof forwardedProto === "string") { + const proto = forwardedProto.split(",")[0]?.trim().toLowerCase(); + if (proto === "https") return true; + } + return Boolean((request.socket as { encrypted?: boolean }).encrypted); +} + +export function createAuthCookieHeader(token: string, secure: boolean): string { + const attributes = [ + `${AUTH_COOKIE_NAME}=${encodeURIComponent(token)}`, + "Path=/", + `Max-Age=${AUTH_COOKIE_MAX_AGE_SECONDS}`, + "HttpOnly", + "SameSite=Lax", + ]; + if (secure) { + attributes.push("Secure"); + } + return attributes.join("; "); +} + +export function createExpiredAuthCookieHeader(secure: boolean): string { + const attributes = [ + `${AUTH_COOKIE_NAME}=`, + "Path=/", + `Max-Age=${AUTH_COOKIE_MAX_AGE_PAST}`, + "HttpOnly", + "SameSite=Lax", + ]; + if (secure) { + attributes.push("Secure"); + } + return attributes.join("; "); +} + +export function appendSetCookieHeader( + headers: Record, + cookie: string, +): Record { + const existing = headers["Set-Cookie"]; + if (!existing) { + headers["Set-Cookie"] = cookie; + return headers; + } + headers["Set-Cookie"] = Array.isArray(existing) ? [...existing, cookie] : [existing, cookie]; + return headers; +} + +export function sanitizeNextPath(candidate: string | null | undefined): string { + if (!candidate || typeof candidate !== "string") return "/"; + if (!candidate.startsWith("/")) return "/"; + if (candidate.startsWith("//")) return "/"; + + try { + const parsed = new URL(candidate, "http://localhost"); + if (parsed.origin !== "http://localhost") return "/"; + return `${parsed.pathname}${parsed.search}`; + } catch { + return "/"; + } +} + +export function removeTokenFromRequestUrl(url: URL): string { + const next = new URL(url.pathname + url.search, "http://localhost"); + next.searchParams.delete("token"); + const search = next.searchParams.toString(); + return `${next.pathname}${search.length > 0 ? `?${search}` : ""}`; +} + +export function requestPrefersHtml(request: http.IncomingMessage, url: URL): boolean { + const method = request.method?.toUpperCase(); + if (method !== "GET" && method !== "HEAD") return false; + if (url.pathname.startsWith("/api/")) return false; + if (url.pathname.startsWith("/attachments/")) return false; + + const accept = request.headers.accept?.toLowerCase() ?? ""; + if (accept.includes("text/html") || accept.includes("application/xhtml+xml")) { + return true; + } + + if (url.pathname === "/") return true; + + const lastSegment = url.pathname.split("/").at(-1) ?? ""; + return !lastSegment.includes("."); +} + +function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderAuthPage(input: { + readonly nextPath: string; + readonly error?: string; +}): string { + const errorMarkup = input.error + ? `` + : ""; + + return ` + + + + + T3 Code Sign In + + + + + + +
+
+

T3 Code Secure Access

+

Open T3 Code

+

Use your access link or enter the auth token to continue.

+ ${errorMarkup} +
+ + + + +
+
+
+ +`; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index babe6fe9db..7356a14d29 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -280,10 +280,22 @@ function asWebSocketResponse(message: unknown): WebSocketResponse | null { return message as WebSocketResponse; } -function connectWsOnce(port: number, token?: string): Promise { +function connectWsOnce( + port: number, + options: { + 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 query = options.token ? `?token=${encodeURIComponent(options.token)}` : ""; + const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`, { + headers: options.headers, + }); + const timeoutId = setTimeout(() => { + ws.close(); + reject(new Error("WebSocket connection failed")); + }, 500); const channels: SocketChannels = { push: { queue: [], waiters: [] }, response: { queue: [], waiters: [] }, @@ -302,17 +314,32 @@ function connectWsOnce(port: number, token?: string): Promise { } }); - ws.once("open", () => resolve(ws)); - ws.once("error", () => reject(new Error("WebSocket connection failed"))); + ws.once("open", () => { + clearTimeout(timeoutId); + resolve(ws); + }); + ws.once("error", () => { + clearTimeout(timeoutId); + ws.close(); + reject(new Error("WebSocket connection failed")); + }); }); } -async function connectWs(port: number, token?: string, attempts = 5): Promise { +async function connectWs( + port: number, + options: { + token?: string; + headers?: Record; + attempts?: number; + } = {}, +): Promise { + const attempts = options.attempts ?? 5; 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, options); } catch (error) { lastError = error; if (attempt < attempts - 1) { @@ -327,9 +354,12 @@ async function connectWs(port: number, token?: string, attempts = 5): Promise; + } = {}, ): Promise<[WebSocket, WsPushMessage]> { - const ws = await connectWs(port, token); + const ws = await connectWs(port, options); const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); return [ws, welcome]; } @@ -401,14 +431,20 @@ async function rewriteKeybindingsAndWaitForPush( async function requestPath( port: number, requestPath: string, -): Promise<{ statusCode: number; body: string }> { + options: { + method?: string; + headers?: Record; + body?: string; + } = {}, +): Promise<{ statusCode: number; body: string; headers: Http.IncomingHttpHeaders }> { return new Promise((resolve, reject) => { const req = Http.request( { hostname: "127.0.0.1", port, path: requestPath, - method: "GET", + method: options.method ?? "GET", + headers: options.headers, }, (res) => { const chunks: Buffer[] = []; @@ -419,15 +455,22 @@ async function requestPath( resolve({ statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers, }); }); }, ); req.once("error", reject); - req.end(); + req.end(options.body); }); } +function firstSetCookie(headers: Http.IncomingHttpHeaders): string | null { + const header = headers["set-cookie"]; + if (!header) return null; + return Array.isArray(header) ? (header[0] ?? null) : header; +} + function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { const resolved: Array = []; for (const binding of bindings) { @@ -567,6 +610,8 @@ describe("WebSocket Server", () => { async function closeTestServer() { if (!serverScope) return; + server?.closeAllConnections?.(); + server?.closeIdleConnections?.(); const scope = serverScope; serverScope = null; await Effect.runPromise(Scope.close(scope, Exit.void)); @@ -574,7 +619,7 @@ describe("WebSocket Server", () => { afterEach(async () => { for (const ws of connections) { - ws.close(); + ws.terminate(); } connections.length = 0; await closeTestServer(); @@ -583,7 +628,7 @@ describe("WebSocket Server", () => { fs.rmSync(dir, { recursive: true, force: true }); } vi.restoreAllMocks(); - }); + }, 30_000); it("sends welcome message on connect", async () => { server = await createTestServer({ cwd: "/test/project" }); @@ -661,6 +706,157 @@ describe("WebSocket Server", () => { expect(await response.text()).toContain("static-root"); }); + it("renders auth page for unauthorized document requests in protected web mode", async () => { + const staticDir = makeTempDir("t3code-static-auth-page-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

secret

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + staticDir, + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/"); + expect(response.statusCode).toBe(401); + expect(response.body).toContain("Open T3 Code"); + expect(response.body).toContain('data-auth-page="t3code-sign-in"'); + expect(response.body).toContain('name="token"'); + }); + + it("returns 401 for unauthorized protected asset requests", async () => { + const baseDir = makeTempDir("t3code-state-auth-attachments-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); + fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); + fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); + + server = await createTestServer({ + cwd: "/test/project", + baseDir, + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/attachments/thread-a/message-a/0.png"); + expect(response.statusCode).toBe(401); + expect(response.body).toBe("Unauthorized"); + }); + + it("returns 401 for unauthorized protected favicon requests", async () => { + server = await createTestServer({ + cwd: "/test/project", + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/api/project-favicon?cwd=%2Ftest%2Fproject"); + expect(response.statusCode).toBe(401); + expect(response.body).toBe("Unauthorized"); + }); + + it("accepts tokenized web links, sets auth cookie, and redirects to a clean URL", async () => { + const staticDir = makeTempDir("t3code-static-auth-redirect-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

secret

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + staticDir, + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/settings?foo=bar&token=secret-token"); + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("/settings?foo=bar"); + expect(firstSetCookie(response.headers)).toContain("t3code_auth=secret-token"); + }); + + it("supports form login and then serves protected content with the auth cookie", async () => { + const staticDir = makeTempDir("t3code-static-auth-login-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

secret

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + staticDir, + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const loginResponse = await requestPath(port, "/auth/login", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: "token=secret-token&next=%2F", + }); + expect(loginResponse.statusCode).toBe(302); + expect(loginResponse.headers.location).toBe("/"); + const authCookie = firstSetCookie(loginResponse.headers); + expect(authCookie).toContain("t3code_auth=secret-token"); + + const authedResponse = await requestPath(port, "/", { + headers: { + cookie: authCookie?.split(";")[0] ?? "", + }, + }); + expect(authedResponse.statusCode).toBe(200); + expect(authedResponse.body).toContain("secret"); + }); + + it("re-renders the auth page with an error for invalid token submissions", async () => { + const staticDir = makeTempDir("t3code-static-auth-invalid-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

secret

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + staticDir, + authToken: "secret-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/auth/login", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: "token=wrong-token&next=%2Fsettings", + }); + expect(response.statusCode).toBe(401); + expect(response.body).toContain("Open T3 Code"); + expect(response.body).toContain('role="alert"'); + expect(response.body).toContain("Invalid auth token."); + expect(response.body).toContain('value="/settings"'); + }); + + it("clears stale auth cookies and re-prompts for the token", async () => { + const staticDir = makeTempDir("t3code-static-auth-stale-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

secret

", "utf8"); + + server = await createTestServer({ + cwd: "/test/project", + staticDir, + authToken: "new-token", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/", { + headers: { + cookie: "t3code_auth=old-token", + }, + }); + expect(response.statusCode).toBe(401); + expect(response.body).toContain("Open T3 Code"); + expect(firstSetCookie(response.headers)).toContain("Max-Age=0"); + }); + it("rejects static path traversal attempts", async () => { const baseDir = makeTempDir("t3code-state-static-traversal-"); const staticDir = makeTempDir("t3code-static-traversal-"); @@ -1908,7 +2104,68 @@ describe("WebSocket Server", () => { await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); - const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); + const [authorizedWs] = await connectAndAwaitWelcome(port, { token: "secret-token" }); + connections.push(authorizedWs); + }); + + it("accepts websocket connections authenticated by cookie in protected web mode", async () => { + server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [authorizedWs] = await connectAndAwaitWelcome(port, { + headers: { + cookie: "t3code_auth=secret-token", + origin: `http://127.0.0.1:${port}`, + }, + }); + connections.push(authorizedWs); + }); + + it("rejects websocket cookie auth with a mismatched origin", async () => { + server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect( + connectWs(port, { + headers: { + cookie: "t3code_auth=secret-token", + origin: "http://example.com", + }, + }), + ).rejects.toThrow("WebSocket connection failed"); + }); + + it("does not accept stale websocket auth cookies", async () => { + server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect( + connectWs(port, { + headers: { + cookie: "t3code_auth=old-token", + origin: `http://127.0.0.1:${port}`, + }, + }), + ).rejects.toThrow("WebSocket connection failed"); + }); + + it("does not enforce web auth in dev-url mode", async () => { + server = await createTestServer({ + cwd: "/test", + authToken: "secret-token", + devUrl: "http://localhost:5173", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const response = await requestPath(port, "/"); + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("http://localhost:5173/"); + + const [authorizedWs] = await connectAndAwaitWelcome(port); connections.push(authorizedWs); }); }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 24965fd608..6593cbb038 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -7,7 +7,9 @@ * @module Server */ import http from "node:http"; +import type net from "node:net"; import type { Duplex } from "node:stream"; +import { Buffer } from "node:buffer"; import Mime from "@effect/platform-node/Mime"; import { @@ -78,6 +80,19 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { + appendSetCookieHeader, + AUTH_COOKIE_NAME, + createAuthCookieHeader, + createExpiredAuthCookieHeader, + isProtectedWebAuthEnabled, + isSecureRequest, + parseCookies, + removeTokenFromRequestUrl, + renderAuthPage, + requestPrefersHtml, + sanitizeNextPath, +} from "./webAuth.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -119,6 +134,49 @@ function rejectUpgrade(socket: Duplex, statusCode: number, message: string): voi "\r\n" + message, ); + const closeableSocket = socket as Duplex & { + destroySoon?: () => void; + destroy: () => void; + }; + if (typeof closeableSocket.destroySoon === "function") { + closeableSocket.destroySoon(); + return; + } + closeableSocket.destroy(); +} + +function readRequestBody(request: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > 32_768) { + reject(new Error("Request body too large")); + request.destroy(); + return; + } + chunks.push(buffer); + }); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function requestHostMatchesOrigin(request: http.IncomingMessage): boolean { + const originHeader = request.headers.origin; + const hostHeader = request.headers.host; + if (typeof originHeader !== "string" || typeof hostHeader !== "string") { + return false; + } + + try { + const origin = new URL(originHeader); + return origin.host === hostHeader; + } catch { + return false; + } } function websocketRawToString(raw: unknown): string | null { @@ -248,6 +306,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< logWebSocketEvents, autoBootstrapProjectFromCwd, } = serverConfig; + const protectedWebAuthEnabled = isProtectedWebAuthEnabled(serverConfig); const availableEditors = resolveAvailableEditors(); const gitManager = yield* GitManager; @@ -258,6 +317,48 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const getRequestAuthState = (request: http.IncomingMessage, url: URL) => { + if (!protectedWebAuthEnabled || !authToken) { + return { + authorized: true, + staleCookie: false, + authenticatedBy: "disabled" as const, + }; + } + + const queryToken = url.searchParams.get("token"); + if (queryToken === authToken) { + return { + authorized: true, + staleCookie: false, + authenticatedBy: "query" as const, + }; + } + + const cookieToken = parseCookies(request.headers.cookie).get(AUTH_COOKIE_NAME); + if (!cookieToken) { + return { + authorized: false, + staleCookie: false, + authenticatedBy: "none" as const, + }; + } + + if (cookieToken === authToken) { + return { + authorized: true, + staleCookie: false, + authenticatedBy: "cookie" as const, + }; + } + + return { + authorized: false, + staleCookie: true, + authenticatedBy: "stale-cookie" as const, + }; + }; + yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => Effect.logWarning("failed to sync keybindings defaults on startup", { @@ -271,6 +372,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const providerStatuses = yield* providerHealth.getStatuses; const clients = yield* Ref.make(new Set()); + const sockets = yield* Ref.make(new Set()); const logger = createLogger("ws"); const readiness = yield* makeServerReadiness; @@ -414,7 +516,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const httpServer = http.createServer((req, res) => { const respond = ( statusCode: number, - headers: Record, + headers: Record, body?: string | Uint8Array, ) => { res.writeHead(statusCode, headers); @@ -424,6 +526,84 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + const requestAuthState = getRequestAuthState(req, url); + const clearStaleCookieHeader = requestAuthState.staleCookie + ? createExpiredAuthCookieHeader(isSecureRequest(req)) + : null; + + if ( + protectedWebAuthEnabled && + req.method === "POST" && + url.pathname === "/auth/login" && + authToken + ) { + const bodyText = yield* Effect.tryPromise({ + try: () => readRequestBody(req), + catch: (cause) => + new RouteRequestError({ + message: `Failed to read auth request: ${String(cause)}`, + }), + }); + const form = new URLSearchParams(bodyText); + const submittedToken = form.get("token"); + const nextPath = sanitizeNextPath(form.get("next")); + if (submittedToken !== authToken) { + const headers: Record = { + "Content-Type": "text/html; charset=utf-8", + }; + if (clearStaleCookieHeader) { + appendSetCookieHeader(headers, clearStaleCookieHeader); + } + respond( + 401, + headers, + renderAuthPage({ + nextPath, + error: "Invalid auth token.", + }), + ); + return; + } + + const headers: Record = { + Location: nextPath, + }; + appendSetCookieHeader(headers, createAuthCookieHeader(authToken, isSecureRequest(req))); + respond(302, headers); + return; + } + + if (protectedWebAuthEnabled && authToken && requestAuthState.authenticatedBy === "query") { + const cleanedLocation = removeTokenFromRequestUrl(url); + const headers: Record = { + Location: cleanedLocation, + }; + appendSetCookieHeader(headers, createAuthCookieHeader(authToken, isSecureRequest(req))); + respond(302, headers); + return; + } + + if (protectedWebAuthEnabled && !requestAuthState.authorized) { + const headers: Record = {}; + if (clearStaleCookieHeader) { + appendSetCookieHeader(headers, clearStaleCookieHeader); + } + if (requestPrefersHtml(req, url)) { + headers["Content-Type"] = "text/html; charset=utf-8"; + respond( + 401, + headers, + renderAuthPage({ + nextPath: sanitizeNextPath(`${url.pathname}${url.search}`), + }), + ); + return; + } + headers["Content-Type"] = "text/plain"; + respond(401, headers, "Unauthorized"); + return; + } + if (tryHandleProjectFaviconRequest(url, res)) { return; } @@ -595,6 +775,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))), Effect.flatMap(() => Ref.set(clients, new Set())), ); + const closeAllSockets = Ref.get(sockets).pipe( + Effect.flatMap( + Effect.forEach((socket) => + Effect.sync(() => { + if (!socket.destroyed) { + socket.destroy(); + } + }), + ), + ), + Effect.flatMap(() => Ref.set(sockets, new Set())), + ); const listenOptions = host ? { host, port } : { port }; @@ -701,7 +893,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* readiness.markHttpListening; yield* Effect.addFinalizer(() => - Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), + Effect.all([ + closeAllClients, + closeAllSockets, + closeWebSocketServer.pipe(Effect.ignoreCause({ log: true })), + ]), ); const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { @@ -938,7 +1134,31 @@ 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 (authToken) { + if (protectedWebAuthEnabled && authToken) { + let providedToken: string | null = null; + let cookieToken: string | undefined; + try { + const url = new URL(request.url ?? "/", `http://localhost:${port}`); + providedToken = url.searchParams.get("token"); + cookieToken = parseCookies(request.headers.cookie).get(AUTH_COOKIE_NAME); + } catch { + rejectUpgrade(socket, 400, "Invalid WebSocket URL"); + return; + } + + const tokenAuthorized = providedToken === authToken; + const cookieAuthorized = cookieToken === authToken; + + if (!tokenAuthorized && cookieAuthorized && !requestHostMatchesOrigin(request)) { + rejectUpgrade(socket, 401, "Unauthorized WebSocket origin"); + return; + } + + if (!tokenAuthorized && !cookieAuthorized) { + rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); + return; + } + } else if (authToken && serverConfig.mode === "desktop") { let providedToken: string | null = null; try { const url = new URL(request.url ?? "/", `http://localhost:${port}`); @@ -959,6 +1179,25 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); + httpServer.on("connection", (socket) => { + void runPromise( + Ref.update(sockets, (current) => { + const next = new Set(current); + next.add(socket); + return next; + }), + ); + socket.on("close", () => { + void runPromise( + Ref.update(sockets, (current) => { + const next = new Set(current); + next.delete(socket); + return next; + }), + ); + }); + }); + wss.on("connection", (ws) => { const segments = cwd.split(/[/\\]/).filter(Boolean); const projectName = segments[segments.length - 1] ?? "project";