diff --git a/apps/app/src/lib/dev-websocket-url.test.ts b/apps/app/src/lib/dev-websocket-url.test.ts index b8c5ecb0de..e01783eae9 100644 --- a/apps/app/src/lib/dev-websocket-url.test.ts +++ b/apps/app/src/lib/dev-websocket-url.test.ts @@ -7,6 +7,7 @@ function installWindowLocation(url: string): void { location: { host: location.host, hostname: location.hostname, + port: location.port, protocol: location.protocol, }, }); @@ -19,6 +20,7 @@ describe("buildDevWebSocketUrl", () => { it("connects directly to the backend for HTTP source dev", () => { vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802); + vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802); installWindowLocation("http://devbox.local:15802/threads/thr_1"); expect(buildDevWebSocketUrl({ path: "/ws" })).toBe( @@ -28,6 +30,7 @@ describe("buildDevWebSocketUrl", () => { it("uses the proxied app origin for HTTPS bb connect shares", () => { vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802); + vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802); installWindowLocation( "https://sawyer--15802.getbb.app/threads/thr_jew2ruik89", ); @@ -37,13 +40,24 @@ describe("buildDevWebSocketUrl", () => { ); }); + it("uses the proxied app origin for HTTP local Cloud", () => { + vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802); + vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802); + installWindowLocation("http://sawyer.localhost:35802/threads/thr_1"); + + expect(buildDevWebSocketUrl({ path: "/ws" })).toBe( + "ws://sawyer.localhost:35802/ws", + ); + }); + it("preserves terminal websocket paths on the proxied app origin", () => { vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802); + vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802); installWindowLocation("https://dev.example.test:15802/threads/thr_1"); - expect( - buildDevWebSocketUrl({ path: "/ws/terminals/term_1" }), - ).toBe("wss://dev.example.test:15802/ws/terminals/term_1"); + expect(buildDevWebSocketUrl({ path: "/ws/terminals/term_1" })).toBe( + "wss://dev.example.test:15802/ws/terminals/term_1", + ); }); it("returns undefined outside the dev build", () => { diff --git a/apps/app/src/lib/dev-websocket-url.ts b/apps/app/src/lib/dev-websocket-url.ts index 9101e33b9c..5ae6bae372 100644 --- a/apps/app/src/lib/dev-websocket-url.ts +++ b/apps/app/src/lib/dev-websocket-url.ts @@ -2,25 +2,35 @@ interface BuildDevWebSocketUrlArgs { path: string; } -function resolveBrowserHostDevWebSocketBaseUrl(port: number): string { +function resolveBrowserHostDevWebSocketBaseUrl( + serverPort: number, + appPort: number, +): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; // HTTPS dev origins are typically reverse proxies or bb connect shares. // Their public origin does not expose the backend's local TCP port, so keep // the socket on the app origin and let Vite proxy /ws to the server. - if (window.location.protocol === "https:") { + if ( + window.location.protocol === "https:" || + window.location.port !== String(appPort) + ) { return `${protocol}//${window.location.host}/ws`; } // Direct sockets remain preferable for ordinary localhost/LAN source dev: // they survive backend restarts more reliably than Vite's WS proxy. - return `${protocol}//${window.location.hostname}:${port}/ws`; + return `${protocol}//${window.location.hostname}:${serverPort}/ws`; } function resolveDevWebSocketBaseUrl(): string | undefined { - if (typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number") { + if ( + typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number" && + typeof __BB_DEV_APP_BROWSER_HOST_PORT__ === "number" + ) { return resolveBrowserHostDevWebSocketBaseUrl( __BB_DEV_WS_BROWSER_HOST_PORT__, + __BB_DEV_APP_BROWSER_HOST_PORT__, ); } diff --git a/apps/app/src/vite-env.d.ts b/apps/app/src/vite-env.d.ts index a0ffd0ac7e..f7153037bc 100644 --- a/apps/app/src/vite-env.d.ts +++ b/apps/app/src/vite-env.d.ts @@ -2,3 +2,4 @@ /** Injected by vite.dev.config.ts to bypass Vite's WebSocket proxy. */ declare const __BB_DEV_WS_BROWSER_HOST_PORT__: number | undefined; +declare const __BB_DEV_APP_BROWSER_HOST_PORT__: number | undefined; diff --git a/apps/app/vite.dev.config.ts b/apps/app/vite.dev.config.ts index 1d89e9766e..c616dc5f9a 100644 --- a/apps/app/vite.dev.config.ts +++ b/apps/app/vite.dev.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ // Connect directly to the server in dev because Vite's WS proxy does not // handle upstream server restarts reliably. __BB_DEV_WS_BROWSER_HOST_PORT__: devWebSocketBrowserHostPortDefine, + __BB_DEV_APP_BROWSER_HOST_PORT__: JSON.stringify(viteDevConfig.appPort), }, server: { // Allow Tailscale MagicDNS names when Vite is behind Tailscale Serve. diff --git a/apps/connect/src/cloud-dev.test.ts b/apps/connect/src/cloud-dev.test.ts new file mode 100644 index 0000000000..f478cee086 --- /dev/null +++ b/apps/connect/src/cloud-dev.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + CLOUD_DEV_HOST_HEADER, + publicConnectOrigin, + resolveConnectRequestHost, + resolveConnectRequestUrl, + resolveConnectRuntime, +} from "./cloud-dev.js"; + +describe("local Cloud request routing", () => { + it("accepts the launcher host and selects HTTP cookies only in local Cloud", () => { + const runtime = resolveConnectRuntime({ + ACCOUNT_APP_URL: "http://bb.localhost:8787", + BASE_DOMAIN: "bb.localhost", + CLOUD_DEV: "true", + }); + const headers = new Headers({ + host: "localhost", + [CLOUD_DEV_HOST_HEADER]: "sawyer--3000", + }); + expect(resolveConnectRequestHost(headers, runtime)).toBe( + "sawyer--3000.bb.localhost", + ); + expect(runtime.sessionCookieName).toBe("better-auth.session_token"); + expect(runtime.desktopSessionCookieName).toBe("bb-connect.desktop_session"); + expect(publicConnectOrigin("sawyer--3000", runtime)).toBe( + "http://sawyer--3000.bb.localhost:8787", + ); + expect( + resolveConnectRequestUrl( + "http://127.0.0.1:50743/threads/thr_1?view=full", + headers, + runtime, + ).toString(), + ).toBe("http://sawyer--3000.bb.localhost:8787/threads/thr_1?view=full"); + }); + + it("ignores the launcher header in production", () => { + const runtime = resolveConnectRuntime({ BASE_DOMAIN: "getbb.app" }); + const headers = new Headers({ + host: "sawyer.getbb.app", + [CLOUD_DEV_HOST_HEADER]: "attacker", + }); + expect(resolveConnectRequestHost(headers, runtime)).toBe( + "sawyer.getbb.app", + ); + expect(runtime.sessionCookieName).toBe( + "__Secure-better-auth.session_token", + ); + }); + + it("rejects deployed credential auth", () => { + expect(() => + resolveConnectRuntime({ + ACCOUNT_APP_URL: "https://getbb.app", + BASE_DOMAIN: "getbb.app", + CLOUD_DEV: "true", + }), + ).toThrow("only allowed for local Cloud development"); + }); +}); diff --git a/apps/connect/src/cloud-dev.ts b/apps/connect/src/cloud-dev.ts new file mode 100644 index 0000000000..324407cdc4 --- /dev/null +++ b/apps/connect/src/cloud-dev.ts @@ -0,0 +1,112 @@ +export const CLOUD_DEV_HOST_HEADER = "x-bb-cloud-dev-host"; +export const SECURE_SESSION_COOKIE = "__Secure-better-auth.session_token"; +export const LOCAL_SESSION_COOKIE = "better-auth.session_token"; +export const SECURE_DESKTOP_SESSION_COOKIE = + "__Secure-bb-connect.desktop_session"; +export const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session"; + +export interface ConnectRuntime { + accountAppUrl: string; + baseDomain: string; + localCloud: boolean; + sessionCookieName: string; + desktopSessionCookieName: string; +} + +function resolveCloudDevLabel( + headers: Headers, + runtime: ConnectRuntime, +): string | null { + if (!runtime.localCloud) return null; + const label = headers.get(CLOUD_DEV_HOST_HEADER)?.trim().toLowerCase(); + return label && !label.includes(".") && /^[a-z0-9-]+$/u.test(label) + ? label + : null; +} + +/** Resolve the small, fail-closed set of overrides used by local Cloud. */ +export function resolveConnectRuntime(env: { + ACCOUNT_APP_URL?: string; + BASE_DOMAIN: string; + CLOUD_DEV?: string; +}): ConnectRuntime { + const accountAppUrl = new URL( + env.ACCOUNT_APP_URL?.trim() || `https://${env.BASE_DOMAIN}`, + ); + if ( + (accountAppUrl.protocol !== "http:" && + accountAppUrl.protocol !== "https:") || + accountAppUrl.username !== "" || + accountAppUrl.password !== "" || + accountAppUrl.pathname !== "/" || + accountAppUrl.search !== "" || + accountAppUrl.hash !== "" + ) { + throw new Error("ACCOUNT_APP_URL must be an HTTP(S) origin"); + } + + const cloudDevValue = env.CLOUD_DEV?.trim(); + if (cloudDevValue && cloudDevValue !== "true") { + throw new Error("CLOUD_DEV must be true when set"); + } + const localCloud = cloudDevValue === "true"; + if (localCloud) { + const isLocalAccount = + accountAppUrl.protocol === "http:" && + accountAppUrl.hostname === env.BASE_DOMAIN && + env.BASE_DOMAIN.endsWith(".localhost"); + if (!isLocalAccount) { + throw new Error("CLOUD_DEV is only allowed for local Cloud development"); + } + } + + return { + accountAppUrl: accountAppUrl.origin, + baseDomain: env.BASE_DOMAIN, + localCloud, + sessionCookieName: localCloud + ? LOCAL_SESSION_COOKIE + : SECURE_SESSION_COOKIE, + desktopSessionCookieName: localCloud + ? LOCAL_DESKTOP_SESSION_COOKIE + : SECURE_DESKTOP_SESSION_COOKIE, + }; +} + +/** Wrangler replaces wildcard hosts locally; the launcher preserves the label. */ +export function resolveConnectRequestHost( + headers: Headers, + runtime: ConnectRuntime, +): string { + const ordinaryHost = headers.get("host") ?? ""; + const label = resolveCloudDevLabel(headers, runtime); + return label === null ? ordinaryHost : `${label}.${runtime.baseDomain}`; +} + +export function publicConnectOrigin( + label: string, + runtime: Pick, +): string { + const url = new URL(runtime.accountAppUrl); + url.hostname = `${label}.${runtime.baseDomain}`; + return url.origin; +} + +export function resolveConnectRequestUrl( + requestUrl: string, + headers: Headers, + runtime: ConnectRuntime, +): URL { + const parsed = new URL(requestUrl); + const label = resolveCloudDevLabel(headers, runtime); + if (label === null) return parsed; + const publicUrl = new URL(publicConnectOrigin(label, runtime)); + publicUrl.pathname = parsed.pathname; + publicUrl.search = parsed.search; + publicUrl.hash = parsed.hash; + return publicUrl; +} + +export function stripCloudDevHeader(headers: Headers): void { + headers.delete(CLOUD_DEV_HOST_HEADER); +} diff --git a/apps/connect/src/machine-label.ts b/apps/connect/src/machine-label.ts index 134c797a0a..da3c1a9bbb 100644 --- a/apps/connect/src/machine-label.ts +++ b/apps/connect/src/machine-label.ts @@ -8,10 +8,9 @@ import { type ConnectDb, } from "@bb/connect-db"; import { verifyMachineCredentialDetails } from "./session.js"; +import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js"; import type { Env } from "./tunnel-do.js"; -const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine"; - function fallbackLabel(machineId: string): string { const idPrefix = machineId .replace(/[^a-z0-9]/giu, "") diff --git a/apps/connect/src/protocol-headers.ts b/apps/connect/src/protocol-headers.ts new file mode 100644 index 0000000000..83fb7559b8 --- /dev/null +++ b/apps/connect/src/protocol-headers.ts @@ -0,0 +1,4 @@ +export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target"; +export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine"; +export const GATE_AUTH_HEADER = "x-bb-gate-auth"; +export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id"; diff --git a/apps/connect/src/servers.test.ts b/apps/connect/src/servers.test.ts index 8112046dcb..df8502c130 100644 --- a/apps/connect/src/servers.test.ts +++ b/apps/connect/src/servers.test.ts @@ -28,6 +28,7 @@ import { assignMachineLabelForCredential, sanitizeMachineLabelBase, } from "./machine-label.js"; +import { SECURE_SESSION_COOKIE } from "./cloud-dev.js"; import { verifyMachineCredential } from "./session.js"; // Real in-memory SQLite (never mock the DB). Same harness as session.test.ts. @@ -254,7 +255,12 @@ describe("verifyServerCredential / resolveAccountUserId", () => { const req = new Request("https://sawyer.getbb.app/api/connect/servers", { headers: { "x-bb-connect-machine": machinePlain }, }); - const userId = await resolveAccountUserId(req, "secret", db); + const userId = await resolveAccountUserId( + req, + "secret", + db, + SECURE_SESSION_COOKIE, + ); expect(userId).toBe("acct-a"); const listed = await listAccountServers(db, userId!, now.getTime()); expect(listed.map((s) => s.handle)).toEqual(["sawyer"]); @@ -262,7 +268,9 @@ describe("verifyServerCredential / resolveAccountUserId", () => { it("returns null (unauthorized) when no credential or session is presented", async () => { const req = new Request("https://sawyer.getbb.app/api/connect/servers"); - expect(await resolveAccountUserId(req, "secret", db)).toBeNull(); + expect( + await resolveAccountUserId(req, "secret", db, SECURE_SESSION_COOKIE), + ).toBeNull(); }); it("accepts a valid owner session cookie", async () => { @@ -303,7 +311,24 @@ describe("verifyServerCredential / resolveAccountUserId", () => { cookie: `__Secure-better-auth.session_token=${cookieValue}`, }, }); - expect(await resolveAccountUserId(req, secret, db)).toBe("acct-a"); + expect( + await resolveAccountUserId(req, secret, db, SECURE_SESSION_COOKIE), + ).toBe("acct-a"); + + const localRequest = new Request( + "http://sawyer.bb.localhost:8787/api/connect/servers", + { + headers: { cookie: `better-auth.session_token=${cookieValue}` }, + }, + ); + expect( + await resolveAccountUserId( + localRequest, + secret, + db, + "better-auth.session_token", + ), + ).toBe("acct-a"); }); }); diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts index 3294bac531..054e1d470a 100644 --- a/apps/connect/src/servers.ts +++ b/apps/connect/src/servers.ts @@ -11,10 +11,10 @@ import { verifyMachineCredential, verifySessionCookie, } from "./session.js"; +import { resolveConnectRuntime } from "./cloud-dev.js"; +import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js"; import type { Env } from "./tunnel-do.js"; -const SESSION_COOKIE = "__Secure-better-auth.session_token"; -export const DESKTOP_SESSION_COOKIE = "__Secure-bb-connect.desktop_session"; export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000; function bytesToBase64Url(bytes: Uint8Array): string { @@ -167,8 +167,9 @@ export async function resolveAccountUserId( request: Request, secret: string, db: ConnectDb, + sessionCookieName: string, ): Promise { - const presented = request.headers.get("x-bb-connect-machine") ?? ""; + const presented = request.headers.get(MACHINE_CREDENTIAL_HEADER) ?? ""; if (presented) { const machineUserId = await verifyMachineCredential(presented, db); if (machineUserId) return machineUserId; @@ -179,7 +180,7 @@ export async function resolveAccountUserId( if (serverUserId) return serverUserId; } - const cookie = parseCookie(request.headers.get("cookie"), SESSION_COOKIE); + const cookie = parseCookie(request.headers.get("cookie"), sessionCookieName); if (!cookie) return null; return verifySessionCookie(cookie, secret, db); } @@ -249,10 +250,12 @@ export async function handleListAccountServers( } const db = drizzle(env.DB, { schema }); + const runtime = resolveConnectRuntime(env); const userId = await resolveAccountUserId( request, env.BETTER_AUTH_SECRET, db, + runtime.sessionCookieName, ); if (!userId) { return new Response(JSON.stringify({ error: "unauthorized" }), { @@ -283,10 +286,12 @@ export async function handleCreateDesktopSession( }); } const db = drizzle(env.DB, { schema }); + const runtime = resolveConnectRuntime(env); const userId = await resolveAccountUserId( request, env.BETTER_AUTH_SECRET, db, + runtime.sessionCookieName, ); if (!userId) { return new Response(JSON.stringify({ error: "unauthorized" }), { @@ -305,7 +310,7 @@ export async function handleCreateDesktopSession( cookie: { domain: `.${env.BASE_DOMAIN}`, expiresAt, - name: DESKTOP_SESSION_COOKIE, + name: runtime.desktopSessionCookieName, value, }, }), diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts index c65df2953f..7eca8777af 100644 --- a/apps/connect/src/tunnel-do.ts +++ b/apps/connect/src/tunnel-do.ts @@ -11,12 +11,15 @@ import { type HeaderPair, } from "@bb/tunnel-contract"; import { relayedResponse } from "./response-encoding.js"; +import { TUNNEL_TARGET_HEADER } from "./protocol-headers.js"; export interface Env { TUNNEL_DO: DurableObjectNamespace; DB: D1Database; BASE_DOMAIN: string; BETTER_AUTH_SECRET: string; + ACCOUNT_APP_URL?: string; + CLOUD_DEV?: string; } const TUNNEL_TAG = "tunnel"; @@ -26,9 +29,6 @@ const RESP_HEAD_TIMEOUT_MS = 30_000; // run JS), kept under the 90s offline window. const PRESENCE_INTERVAL_MS = 50_000; -/** Gate → DO header carrying a share target; never forwarded to the origin. */ -const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target"; - // Standard WebSocket readyState numbering (workerd's READY_STATE_OPEN; the // constant itself is Cloudflare-only, so tests in Node use the number). const WS_READY_STATE_OPEN = 1; diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts index c69c7aa246..b20cb0951c 100644 --- a/apps/connect/src/worker.test.ts +++ b/apps/connect/src/worker.test.ts @@ -5,13 +5,15 @@ import { machine } from "@bb/connect-db"; import { cacheKey } from "./cache"; import { parseClientProtocolVersion } from "./tunnel-do"; import { - GATE_AUTH_HEADER, - GATE_MACHINE_ID_HEADER, - TUNNEL_TARGET_HEADER, cacheNamespace, dashboardSignInUrl, requestForTunnelDo, } from "./worker"; +import { + GATE_AUTH_HEADER, + GATE_MACHINE_ID_HEADER, + TUNNEL_TARGET_HEADER, +} from "./protocol-headers"; // ── pure helpers ──────────────────────────────────────────────────────────── @@ -112,7 +114,6 @@ vi.mock("./session.js", () => ({ })); vi.mock("./servers.js", () => ({ - DESKTOP_SESSION_COOKIE: "__Secure-bb-connect.desktop_session", handleCreateDesktopSession: vi.fn(), handleListAccountServers: vi.fn(), verifyDesktopSessionCookie: vi.fn(), @@ -152,11 +153,11 @@ import { verifySessionCookie, } from "./session.js"; import { - DESKTOP_SESSION_COOKIE, handleCreateDesktopSession, handleListAccountServers, verifyDesktopSessionCookie, } from "./servers.js"; +import { SECURE_DESKTOP_SESSION_COOKIE as DESKTOP_SESSION_COOKIE } from "./cloud-dev.js"; import { handleAssignMachineLabel } from "./machine-label.js"; import { serveWithCache } from "./cache.js"; import worker, { offlinePage, relativeTime, wantsHtml } from "./worker.js"; @@ -377,7 +378,10 @@ describe("gate tunnel authentication", () => { "sawyer-air.getbb.app", "/__tunnel?v=1&serverId=victim-server&machineId=spoofed-machine", { - headers: { authorization: `Bearer ${credential}` }, + headers: { + authorization: `Bearer ${credential}`, + "x-bb-cloud-dev-host": "smuggled", + }, }, ), env as never, @@ -395,6 +399,7 @@ describe("gate tunnel authentication", () => { "machine-air", ); expect(new URL(captured[0].url).searchParams.get("serverId")).toBeNull(); + expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull(); }); it("dials immediately after a negative resolve and label assignment", async () => { @@ -552,14 +557,20 @@ describe("machine gate auth", () => { const { env, ctx, captured } = makeEnv(() => new Response("origin")); const internal = await worker.fetch( visitorRequest("sawyer.getbb.app", "/internal/session/open", { - headers: { "x-bb-connect-machine": "bbcm_owner" }, + headers: { + "x-bb-connect-machine": "bbcm_owner", + "x-bb-cloud-dev-host": "smuggled", + }, }), env as never, ctx, ); const api = await worker.fetch( visitorRequest("sawyer.getbb.app", "/api/v1/threads", { - headers: { "x-bb-connect-machine": "bbcm_owner" }, + headers: { + "x-bb-connect-machine": "bbcm_owner", + "x-bb-cloud-dev-host": "smuggled", + }, }), env as never, ctx, @@ -572,6 +583,11 @@ describe("machine gate auth", () => { (request) => request.headers.get("x-bb-connect-machine") === null, ), ).toBe(true); + expect( + captured.every( + (request) => request.headers.get("x-bb-cloud-dev-host") === null, + ), + ).toBe(true); expect( captured.every( (request) => request.headers.get(GATE_AUTH_HEADER) === "machine", @@ -630,13 +646,16 @@ describe("machine gate auth", () => { async (path) => { const { env, ctx, captured } = makeEnv(() => new Response("artifact")); const response = await worker.fetch( - visitorRequest("sawyer.getbb.app", path), + visitorRequest("sawyer.getbb.app", path, { + headers: { "x-bb-cloud-dev-host": "smuggled" }, + }), env as never, ctx, ); expect(response.status).toBe(200); expect(await response.text()).toBe("artifact"); expect(captured).toHaveLength(1); + expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull(); expect(mockVerifyMachine).not.toHaveBeenCalled(); }, ); @@ -693,6 +712,30 @@ describe("gate worker share hosts", () => { expect(mockVerifySession).not.toHaveBeenCalled(); }); + it("renders local machine links with HTTP and the shared gateway port", async () => { + mockResolveLabel.mockResolvedValue(resolvedMachine()); + const { env, ctx } = makeEnv(() => new Response("origin")); + Object.assign(env, { + ACCOUNT_APP_URL: "http://bb.localhost:42745", + BASE_DOMAIN: "bb.localhost", + CLOUD_DEV: "true", + }); + const response = await worker.fetch( + new Request("http://127.0.0.1:50743/", { + headers: { + host: "127.0.0.1:50743", + "x-bb-cloud-dev-host": "sawyer-air", + }, + }), + env as never, + ctx, + ); + + const html = await response.text(); + expect(html).toContain("sawyer-air--<port>.bb.localhost:42745"); + expect(html).toContain('href="http://sawyer.bb.localhost:42745"'); + }); + it("applies the same owner-session check to machine share hosts", async () => { mockResolveLabel.mockResolvedValue(resolvedMachine()); const ownerEnv = makeEnv(() => new Response("machine-origin")); @@ -777,13 +820,17 @@ describe("gate worker share hosts", () => { const { env, ctx, captured } = makeEnv(() => new Response("ok")); await worker.fetch( visitorRequest("sawyer.getbb.app", "/", { - headers: { [TUNNEL_TARGET_HEADER]: "9999" }, + headers: { + [TUNNEL_TARGET_HEADER]: "9999", + "x-bb-cloud-dev-host": "smuggled", + }, }), env as never, ctx, ); expect(captured).toHaveLength(1); expect(captured[0].headers.get(TUNNEL_TARGET_HEADER)).toBeNull(); + expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull(); expect(mockServeWithCache).toHaveBeenCalledWith( expect.any(Request), "sawyer", @@ -848,6 +895,31 @@ describe("gate worker share hosts", () => { expect(captured).toHaveLength(0); }); + it("preserves the public local URL in the sign-in returnTo", async () => { + mockParseCookie.mockReturnValue(null); + const { env, ctx } = makeEnv(() => new Response("ok")); + Object.assign(env, { + ACCOUNT_APP_URL: "http://bb.localhost:42745", + BASE_DOMAIN: "bb.localhost", + CLOUD_DEV: "true", + }); + const response = await worker.fetch( + new Request("http://127.0.0.1:50743/threads/thr_1?view=full", { + headers: { + host: "127.0.0.1:50743", + "x-bb-cloud-dev-host": "sawyer", + }, + }), + env as never, + ctx, + ); + + expect(response.status).toBe(401); + expect(await response.text()).toContain( + "returnTo=http%3A%2F%2Fsawyer.bb.localhost%3A42745%2Fthreads%2Fthr_1%3Fview%3Dfull", + ); + }); + it("returns 403 when share host session is a different user", async () => { mockVerifySession.mockResolvedValue(OTHER); const { env, ctx, captured } = makeEnv(() => new Response("ok")); diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts index 5776891e5a..0650209f4d 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -9,7 +9,6 @@ import { verifySessionCookie, } from "./session.js"; import { - DESKTOP_SESSION_COOKIE, handleCreateDesktopSession, handleListAccountServers, verifyDesktopSessionCookie, @@ -17,17 +16,22 @@ import { import { serveWithCache } from "./cache.js"; import { BB_ICON_DATA_URI } from "./bb-icon.js"; import { handleAssignMachineLabel } from "./machine-label.js"; +import { + publicConnectOrigin, + resolveConnectRequestHost, + resolveConnectRequestUrl, + resolveConnectRuntime, + stripCloudDevHeader, +} from "./cloud-dev.js"; +import { + GATE_AUTH_HEADER, + GATE_MACHINE_ID_HEADER, + MACHINE_CREDENTIAL_HEADER, + TUNNEL_TARGET_HEADER, +} from "./protocol-headers.js"; export { TunnelDO }; -const SESSION_COOKIE = "__Secure-better-auth.session_token"; - -/** Internal header: gate → TunnelDO, share target (port string). Never trust visitors. */ -export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target"; -export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine"; -export const GATE_AUTH_HEADER = "x-bb-gate-auth"; -export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id"; - async function sha256Hex(value: string): Promise { const digest = await crypto.subtle.digest( "SHA-256", @@ -198,13 +202,15 @@ export function offlinePage( export function machinePage( label: string, accountHandle: string, - baseDomain: string, + runtime: ReturnType, ): Response { - const appHost = `${accountHandle}.${baseDomain}`; + const appOrigin = publicConnectOrigin(accountHandle, runtime); + const appHost = new URL(appOrigin).host; + const baseHost = new URL(runtime.accountAppUrl).host; return gatePage( `

${escapeHtml(label)} is a machine

-

This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseDomain)}.

- Open the bb app at ${escapeHtml(appHost)}`, +

This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseHost)}.

+ Open the bb app at ${escapeHtml(appHost)}`, 200, ); } @@ -223,6 +229,7 @@ export function requestForTunnelDo( headers.delete(MACHINE_CREDENTIAL_HEADER); headers.delete(GATE_AUTH_HEADER); headers.delete(GATE_MACHINE_ID_HEADER); + stripCloudDevHeader(headers); if (target !== null) { headers.set(TUNNEL_TARGET_HEADER, target); } @@ -265,7 +272,8 @@ export default { env: Env, ctx: ExecutionContext, ): Promise { - const url = new URL(request.url); + const runtime = resolveConnectRuntime(env); + const url = resolveConnectRequestUrl(request.url, request.headers, runtime); // Account-scoped APIs are handled on the gate before host/label routing so // they never proxy through a tunnel to a local bb origin. Auth is // machine/server credential or owner session — see servers.ts. @@ -279,7 +287,7 @@ export default { return handleAssignMachineLabel(request, env); } - const host = request.headers.get("host") ?? url.host; + const host = resolveConnectRequestHost(request.headers, runtime); const parsed = parseVisitorHost(host, env.BASE_DOMAIN); if (!parsed) return text("bb connect: unknown host\n", 404); // The base label is now ANY server's subdomain (the account handle names the @@ -291,7 +299,7 @@ export default { // rather than answering with a confusing "no server" page. if (RESERVED_HANDLES.has(label)) { return Response.redirect( - `https://${env.BASE_DOMAIN}${url.pathname}${url.search}`, + `${runtime.accountAppUrl}${url.pathname}${url.search}`, 301, ); } @@ -342,7 +350,11 @@ export default { } else { forward.searchParams.set("machineId", owner.id); } - return stub.fetch(new Request(forward, request)); + const headers = new Headers(request.headers); + stripCloudDevHeader(headers); + return stub.fetch( + new Request(new Request(forward, request), { headers }), + ); } // Reserve the /__ namespace: never proxy internal paths from outside. @@ -352,7 +364,7 @@ export default { // Machine labels route only explicit `