diff --git a/apps/dashboard/src/app/api/development-environment/browser-secret/init-confirmation-code/route.ts b/apps/dashboard/src/app/api/development-environment/browser-secret/init-confirmation-code/route.ts new file mode 100644 index 000000000..7337266ce --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/browser-secret/init-confirmation-code/route.ts @@ -0,0 +1,8 @@ +import { initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode } from "@/lib/remote-development-environment/browser-secret"; +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; + +export function POST(req: NextRequest) { + return initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(req); +} diff --git a/apps/dashboard/src/app/api/development-environment/browser-secret/start-localbound-server/route.ts b/apps/dashboard/src/app/api/development-environment/browser-secret/start-localbound-server/route.ts new file mode 100644 index 000000000..0091d98ad --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/browser-secret/start-localbound-server/route.ts @@ -0,0 +1,8 @@ +import { startRemoteDevelopmentEnvironmentBrowserSecretLocalboundServer } from "@/lib/remote-development-environment/browser-secret"; +import { NextRequest } from "next/server"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + return await startRemoteDevelopmentEnvironmentBrowserSecretLocalboundServer(req); +} diff --git a/apps/dashboard/src/app/api/development-environment/browser-secret/store/route.ts b/apps/dashboard/src/app/api/development-environment/browser-secret/store/route.ts new file mode 100644 index 000000000..db89a63ff --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/browser-secret/store/route.ts @@ -0,0 +1,31 @@ +import { readRemoteDevelopmentEnvironmentJsonBody } from "@/lib/remote-development-environment/route-json"; +import { assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest, storeRemoteDevelopmentEnvironmentBrowserSecret } from "@/lib/remote-development-environment/browser-secret"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +function browserSecretFromBody(value: unknown): string | null { + if ( + value == null || + typeof value !== "object" || + !("browser_secret" in value) || + typeof value.browser_secret !== "string" + ) { + return null; + } + return value.browser_secret; +} + +export async function POST(req: NextRequest) { + const securityResponse = assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest(req); + if (securityResponse != null) return securityResponse; + + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + const browserSecret = browserSecretFromBody(parsedBody); + if (browserSecret == null) { + return NextResponse.json({ error: "browser_secret is required." }, { status: 400 }); + } + + return storeRemoteDevelopmentEnvironmentBrowserSecret(req, browserSecret); +} diff --git a/apps/dashboard/src/app/api/development-environment/browser-secret/submit-confirmation-code/route.ts b/apps/dashboard/src/app/api/development-environment/browser-secret/submit-confirmation-code/route.ts new file mode 100644 index 000000000..88897bf28 --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/browser-secret/submit-confirmation-code/route.ts @@ -0,0 +1,28 @@ +import { submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode } from "@/lib/remote-development-environment/browser-secret"; +import { readRemoteDevelopmentEnvironmentJsonBody } from "@/lib/remote-development-environment/route-json"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +function confirmationCodeFromBody(value: unknown): string | null { + if ( + value == null || + typeof value !== "object" || + !("code" in value) || + typeof value.code !== "string" + ) { + return null; + } + return value.code; +} + +export async function POST(req: NextRequest) { + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + const code = confirmationCodeFromBody(parsedBody); + if (code == null) { + return NextResponse.json({ error: "code is required." }, { status: 400 }); + } + + return submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(req, code); +} diff --git a/apps/dashboard/src/app/api/development-environment/health/route.test.ts b/apps/dashboard/src/app/api/development-environment/health/route.test.ts index 2c69bd78b..f4f867f8d 100644 --- a/apps/dashboard/src/app/api/development-environment/health/route.test.ts +++ b/apps/dashboard/src/app/api/development-environment/health/route.test.ts @@ -1,7 +1,35 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); + +let tempDir: string | undefined; +const remoteDevelopmentEnvironmentEnabledEnv = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT"; + +function useTempStateFile(secret = "secret") { + tempDir = mkdtempSync(join(tmpdir(), "stack-rde-health-")); + process.env[remoteDevelopmentEnvironmentEnabledEnv] = "true"; + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); + writeFileSync(process.env.STACK_DEV_ENVS_PATH, JSON.stringify({ + version: 1, + localDashboardsByPort: { + "26700": { + port: 26700, + secret, + pid: 123, + startedAtMillis: Date.now(), + }, + }, + projectsByConfigPath: {}, + })); + chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600); +} function request(headers: Record) { - return new Request("http://127.0.0.1:26700/api/development-environment/health", { headers }) as any; + return new NextRequest("http://127.0.0.1:26700/api/development-environment/health", { headers }); } async function getHealthResponse(req: Request) { @@ -12,36 +40,46 @@ async function getHealthResponse(req: Request) { afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); + delete process.env[remoteDevelopmentEnvironmentEnabledEnv]; + delete process.env.STACK_DEV_ENVS_PATH; + if (tempDir != null) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } }); describe("development environment health route", () => { - it("rejects arbitrary localhost origins", async () => { + it("rejects browser health checks without a browser secret", async () => { + useTempStateFile(); const response = await getHealthResponse(request({ host: "127.0.0.1:26700", - origin: "http://evil.localhost:26700", + origin: "http://127.0.0.1:26700", + "sec-fetch-site": "same-origin", + })); + + expect(response.status).toBe(401); + expect(response.headers.get("x-hexclave-development-environment-browser-secret-error")).toBe("invalid_browser_secret"); + }); + + it("allows CLI bearer health checks from loopback", async () => { + useTempStateFile(); + const response = await getHealthResponse(request({ + host: "127.0.0.1:26700", + authorization: "Bearer secret", + })); + + expect(response.status).toBe(503); + }); + + it("rejects CLI bearer health checks from non-loopback hosts", async () => { + useTempStateFile(); + const response = await getHealthResponse(new NextRequest("http://preview.example.test/api/development-environment/health", { + headers: { + host: "preview.example.test", + authorization: "Bearer secret", + }, })); expect(response.status).toBe(403); }); - - it("does not reject the expected remote development environment dashboard origin", async () => { - const response = await getHealthResponse(request({ - host: "127.0.0.1:26700", - origin: "http://127.0.0.1:26700", - })); - - expect(response.status).not.toBe(403); - }); - - it("uses the configured local dashboard port for allowed origins", async () => { - vi.stubEnv("NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT", "26701"); - const response = await getHealthResponse(new Request("http://127.0.0.1:26701/api/development-environment/health", { - headers: { - host: "127.0.0.1:26701", - origin: "http://127.0.0.1:26701", - }, - })); - - expect(response.status).not.toBe(403); - }); }); diff --git a/apps/dashboard/src/app/api/development-environment/health/route.ts b/apps/dashboard/src/app/api/development-environment/health/route.ts index d9d5ca024..edaf56078 100644 --- a/apps/dashboard/src/app/api/development-environment/health/route.ts +++ b/apps/dashboard/src/app/api/development-environment/health/route.ts @@ -1,45 +1,16 @@ import { getPublicEnvVar } from "@/lib/env"; -import { createUrlIfValid, isLocalhost } from "@hexclave/shared/dist/utils/urls"; +import { assertRemoteDevelopmentEnvironmentBrowserRequest, assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; import { NextRequest, NextResponse } from "next/server"; export const runtime = "nodejs"; const LOCAL_EMULATOR_HEALTH_TIMEOUT_MS = 2_000; -const DEFAULT_LOCAL_DASHBOARD_PORT = "26700"; type HealthResponse = { ok: boolean, restart_command: string, }; -function requestHostIsLoopback(req: NextRequest): boolean { - const host = req.headers.get("host"); - if (host == null) return false; - return isLocalhost(`http://${host}`); -} - -function urlOrigin(value: string | undefined): string | null { - if (value == null || value.length === 0) return null; - return createUrlIfValid(value)?.origin ?? null; -} - -function expectedDashboardOrigins(): Set { - const localDashboardPort = getPublicEnvVar("NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT") ?? DEFAULT_LOCAL_DASHBOARD_PORT; - return new Set([ - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_STACK_DASHBOARD_URL")), - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL")), - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL")), - `http://127.0.0.1:${localDashboardPort}`, - ].filter((origin): origin is string => typeof origin === "string")); -} - -function originIsAllowed(req: NextRequest): boolean { - const origin = req.headers.get("origin"); - if (origin == null) return true; - const parsedOrigin = urlOrigin(origin); - return parsedOrigin != null && expectedDashboardOrigins().has(parsedOrigin); -} - function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } @@ -80,12 +51,13 @@ async function localEmulatorIsHealthy(): Promise { } export async function GET(req: NextRequest) { - if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { - return NextResponse.json({ error: "You're accessing the development environment using an unsupported address (such as 'localhost'). Please go to http://127.0.0.1:26700 instead — copy and paste this address into your browser." }, { status: 403 }); - } - const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; if (isRemoteDevelopmentEnvironment) { + const securityResponse = req.headers.has("authorization") + ? assertRemoteDevelopmentEnvironmentRequest(req) + : assertRemoteDevelopmentEnvironmentBrowserRequest(req); + if (securityResponse != null) return securityResponse; + const { getRemoteDevelopmentEnvironmentHealth } = await import("@/lib/remote-development-environment/manager"); const health = getRemoteDevelopmentEnvironmentHealth(); return healthResponse({ diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts index 94dfc7b79..0ab1f676a 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { heartbeatRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; +import { getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode, heartbeatRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; export const runtime = "nodejs"; @@ -12,5 +12,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ ses if (!heartbeatRemoteDevelopmentEnvironmentSession(sessionId)) { return NextResponse.json({ error: "Unknown remote development environment session." }, { status: 404 }); } - return NextResponse.json({ ok: true }); + const confirmationCode = getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(); + return NextResponse.json({ + ok: true, + browser_secret_confirmation_code: confirmationCode?.code, + browser_secret_confirmation_code_expires_at_millis: confirmationCode?.expiresAtMillis, + }); } diff --git a/apps/dashboard/src/app/development-environment/browser-secret/page-client.tsx b/apps/dashboard/src/app/development-environment/browser-secret/page-client.tsx new file mode 100644 index 000000000..bd3a63349 --- /dev/null +++ b/apps/dashboard/src/app/development-environment/browser-secret/page-client.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { storeRemoteDevelopmentEnvironmentBrowserSecret } from "@/app/remote-development-environment-browser-secret-client"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import type { FormEvent } from "react"; +import { useEffect, useState } from "react"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseInitResponse(value: unknown): { expiresAtMillis: number } { + if (!isRecord(value) || typeof value.expires_at_millis !== "number") { + throw new Error("Development environment confirmation-code endpoint returned an invalid response."); + } + return { expiresAtMillis: value.expires_at_millis }; +} + +function parseSubmitResponse(value: unknown): { browserSecret: string } { + if (!isRecord(value) || typeof value.browser_secret !== "string") { + throw new Error("Development environment confirmation-code submit endpoint returned an invalid response."); + } + return { browserSecret: value.browser_secret }; +} + +function sameOriginReturnTo(searchParams: URLSearchParams): string { + const returnTo = searchParams.get("return_to"); + if (returnTo == null) return "/"; + const parsed = new URL(returnTo, window.location.href); + return parsed.origin === window.location.origin ? parsed.toString() : "/"; +} + +export function BrowserSecretConfirmationPageClient() { + const [code, setCode] = useState(""); + const [expiresAtMillis, setExpiresAtMillis] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [returnTo, setReturnTo] = useState("/"); + + useEffect(() => { + setReturnTo(sameOriginReturnTo(new URLSearchParams(window.location.search))); + runAsynchronouslyWithAlert((async () => { + const response = await fetch("/api/development-environment/browser-secret/init-confirmation-code", { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Failed to create development environment confirmation code (${response.status}): ${await response.text()}`); + } + setExpiresAtMillis(parseInitResponse(await response.json()).expiresAtMillis); + })()); + }, []); + + const submitCode = async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setErrorMessage(null); + try { + const response = await fetch("/api/development-environment/browser-secret/submit-confirmation-code", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ code }), + }); + if (!response.ok) { + setErrorMessage("That confirmation code did not work. Check the running CLI and try again."); + return; + } + const { browserSecret } = parseSubmitResponse(await response.json()); + await storeRemoteDevelopmentEnvironmentBrowserSecret(browserSecret); + window.location.assign(returnTo); + } finally { + setSubmitting(false); + } + }; + + const expiresText = expiresAtMillis == null + ? "Creating a code..." + : `The code expires in about ${Math.max(0, Math.ceil((expiresAtMillis - Date.now()) / 1000))} seconds.`; + + return ( +
+
+
+ Browser authorization +
+

Authorize this browser

+

+ This dashboard is reachable through a forwarded address. To keep it private, enter the 6-character confirmation code shown by the running stack dev command. +

+

{expiresText}

+
{ + runAsynchronouslyWithAlert(submitCode(event)); + }}> +
+ + setCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6))} + className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-lg font-mono tracking-[0.35em]" + placeholder="ABC123" + /> +
+ {errorMessage != null && ( +

{errorMessage}

+ )} + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/development-environment/browser-secret/page.tsx b/apps/dashboard/src/app/development-environment/browser-secret/page.tsx new file mode 100644 index 000000000..38ac5b9d8 --- /dev/null +++ b/apps/dashboard/src/app/development-environment/browser-secret/page.tsx @@ -0,0 +1,5 @@ +import { BrowserSecretConfirmationPageClient } from "./page-client"; + +export default function BrowserSecretConfirmationPage() { + return ; +} diff --git a/apps/dashboard/src/app/layout-client.tsx b/apps/dashboard/src/app/layout-client.tsx index e2ad42aeb..9a0400884 100644 --- a/apps/dashboard/src/app/layout-client.tsx +++ b/apps/dashboard/src/app/layout-client.tsx @@ -9,12 +9,14 @@ import { getPublicEnvVar } from "@/lib/env"; import { stackClientApp } from "@/stack/client"; import { StackProvider, StackTheme } from "@hexclave/next"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { usePathname } from "next/navigation"; import React, { useSyncExternalStore } from "react"; import { BackgroundShine } from "./background-shine"; import { ClientPolyfill } from "./client-polyfill"; import { DevelopmentPortDisplay } from "./development-port-display"; import Loading from "./loading"; import { UserIdentity } from "./providers"; +import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "./remote-development-environment-browser-secret-client"; import { RemoteDevelopmentEnvironmentAuthGate } from "./remote-development-environment-auth-gate"; import { WrongAddressScreen } from "./wrong-address-screen"; @@ -60,7 +62,7 @@ async function refreshDevEnvironmentHealth() { }; try { - const response = await fetch("/api/development-environment/health", { + const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/development-environment/health", { cache: "no-store", headers: { Accept: "application/json", @@ -86,7 +88,10 @@ async function refreshDevEnvironmentHealth() { setSnapshotIfCurrent(body.ok && response.ok ? HEALTHY_DEV_ENVIRONMENT_HEALTH_SNAPSHOT : { status: "unhealthy", restartCommand: body.restart_command }); - } catch { + } catch (error) { + if (error instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { + return; + } setSnapshotIfCurrent({ status: "unhealthy", restartCommand: "stack dev --config-file -- ", @@ -149,10 +154,10 @@ function DevEnvironmentStoppedScreen(props: { restartCommand: string }) { ); } -function DevEnvironmentHealthGate(props: { children: React.ReactNode }) { +function DevEnvironmentHealthGate(props: { children: React.ReactNode, disabled?: boolean }) { const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; - const shouldCheckHealth = isLocalEmulator || isRemoteDevelopmentEnvironment; + const shouldCheckHealth = props.disabled !== true && (isLocalEmulator || isRemoteDevelopmentEnvironment); const health = useSyncExternalStore( shouldCheckHealth ? subscribeDevEnvironmentHealth : subscribeHealthyDevEnvironment, shouldCheckHealth ? getDevEnvironmentHealthSnapshot : getHealthyDevEnvironmentSnapshot, @@ -182,13 +187,16 @@ export function LayoutClient(props: { children: React.ReactNode, translationLocale?: string, }) { + const pathname = usePathname(); + const isBrowserSecretAuthorizationPage = pathname === "/development-environment/browser-secret"; + return ( <> ["lang"]}> - - + + diff --git a/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx index 335e41d7d..3e53ea83b 100644 --- a/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx +++ b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx @@ -6,7 +6,7 @@ import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { useStackApp } from "@hexclave/next"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { useEffect, useState } from "react"; -import { WrongAddressScreen } from "./wrong-address-screen"; +import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret, RemoteDevelopmentEnvironmentBrowserSecretRedirectingError } from "./remote-development-environment-browser-secret-client"; const RDE_ACCESS_TOKEN_MIN_EXPIRATION_MS = 30_000; const RDE_ACCESS_TOKEN_MAX_AGE_MS = 60_000; @@ -88,42 +88,14 @@ function shouldRefreshAccessToken(token: RemoteDevelopmentEnvironmentAccessToken ); } -class LoopbackAddressError extends Error { - constructor(message: string, public readonly suggestedUrl: string) { - super(message); - } -} - -function extractLoopbackSuggestedUrl(errorMessage: string): string | null { - const match = errorMessage.match(/http:\/\/127\.0\.0\.1(?::\d+)?/); - return match?.[0] ?? null; -} - async function getRemoteDevelopmentEnvironmentAccessToken(): Promise { - const response = await fetch("/api/remote-development-environment/auth", { + const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/auth", { headers: { Accept: "application/json", }, }); if (!response.ok) { - const responseText = await response.text(); - // For 403 errors (e.g. accessing via 'localhost' instead of 127.0.0.1), - // throw a LoopbackAddressError so the auth gate can show a dedicated screen - if (response.status === 403) { - try { - const errorMessage = JSON.parse(responseText)?.error; - if (typeof errorMessage === "string") { - const suggestedUrl = extractLoopbackSuggestedUrl(errorMessage); - if (suggestedUrl != null) { - throw new LoopbackAddressError(errorMessage, suggestedUrl); - } - throw new Error(errorMessage); - } - } catch (e) { - if (!(e instanceof SyntaxError)) throw e; - } - } - throw new Error(`Failed to authenticate local remote development environment dashboard (${response.status}): ${responseText}`); + throw new Error(`Failed to authenticate local remote development environment dashboard (${response.status}): ${await response.text()}`); } return parseRemoteDevelopmentEnvironmentAccessTokenResponse(await response.json()); @@ -141,7 +113,6 @@ async function installRemoteDevelopmentEnvironmentAccessToken(app: unknown): Pro function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.ReactNode }) { const app = useStackApp(); const [accessTokenInstalled, setAccessTokenInstalled] = useState(false); - const [loopbackError, setLoopbackError] = useState<{ suggestedUrl: string } | null>(null); useEffect(() => { let cancelled = false; @@ -167,10 +138,7 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac requestRefresh(); }, getRefreshInMillis(token)); } catch (e) { - if (e instanceof LoopbackAddressError) { - if (!cancelled) { - setLoopbackError({ suggestedUrl: e.suggestedUrl }); - } + if (e instanceof RemoteDevelopmentEnvironmentBrowserSecretRedirectingError) { return; } throw e; @@ -210,10 +178,6 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac }; }, [app]); - if (loopbackError != null) { - return ; - } - if (!accessTokenInstalled) { return ; } @@ -221,9 +185,9 @@ function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.Reac return props.children; } -export function RemoteDevelopmentEnvironmentAuthGate(props: { children: React.ReactNode }) { +export function RemoteDevelopmentEnvironmentAuthGate(props: { children: React.ReactNode, disabled?: boolean }) { const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; - if (!isRemoteDevelopmentEnvironment) { + if (!isRemoteDevelopmentEnvironment || props.disabled === true) { return props.children; } diff --git a/apps/dashboard/src/app/remote-development-environment-browser-secret-client.ts b/apps/dashboard/src/app/remote-development-environment-browser-secret-client.ts new file mode 100644 index 000000000..e6ee0013e --- /dev/null +++ b/apps/dashboard/src/app/remote-development-environment-browser-secret-client.ts @@ -0,0 +1,114 @@ +"use client"; + +import { + REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_ERROR_HEADER, + REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE, +} from "@/lib/remote-development-environment/browser-secret-common"; + +export class RemoteDevelopmentEnvironmentBrowserSecretRedirectingError extends Error { + constructor() { + super("Redirecting to development environment browser authorization."); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function responseHasInvalidBrowserSecretError(response: Response): Promise { + if (response.headers.get(REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_ERROR_HEADER) === REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE) { + return true; + } + + let body: unknown; + try { + body = await response.clone().json(); + } catch { + return false; + } + if (!isRecord(body)) return false; + return body.code === REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE; +} + +function redirectToBrowserSecretConfirmation(): never { + const url = new URL("/development-environment/browser-secret", window.location.href); + url.searchParams.set("return_to", window.location.href); + window.location.assign(url.toString()); + throw new RemoteDevelopmentEnvironmentBrowserSecretRedirectingError(); +} + +function parseLocalboundStartResponse(value: unknown): { url: string } { + if (!isRecord(value) || typeof value.url !== "string") { + throw new Error("Development environment local browser-secret endpoint returned an invalid response."); + } + return { url: value.url }; +} + +function parseBrowserSecretResponse(value: unknown): { browserSecret: string } { + if (!isRecord(value) || typeof value.browser_secret !== "string") { + throw new Error("Development environment browser-secret endpoint returned an invalid response."); + } + return { browserSecret: value.browser_secret }; +} + +export async function storeRemoteDevelopmentEnvironmentBrowserSecret(browserSecret: string): Promise { + const response = await fetch("/api/development-environment/browser-secret/store", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ browser_secret: browserSecret }), + }); + if (!response.ok) { + throw new Error(`Failed to store development environment browser secret (${response.status}): ${await response.text()}`); + } +} + +async function tryInstallBrowserSecretFromLocalboundServer(): Promise { + const startResponse = await fetch("/api/development-environment/browser-secret/start-localbound-server", { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + if (!startResponse.ok) { + return false; + } + + const { url } = parseLocalboundStartResponse(await startResponse.json()); + let localboundResponse: Response; + try { + localboundResponse = await fetch(url, { + cache: "no-store", + credentials: "omit", + headers: { + Accept: "application/json", + }, + }); + } catch { + return false; + } + if (!localboundResponse.ok) { + return false; + } + + const { browserSecret } = parseBrowserSecretResponse(await localboundResponse.json()); + await storeRemoteDevelopmentEnvironmentBrowserSecret(browserSecret); + return true; +} + +async function ensureRemoteDevelopmentEnvironmentBrowserSecret(): Promise { + if (await tryInstallBrowserSecretFromLocalboundServer()) return; + redirectToBrowserSecretConfirmation(); +} + +export async function fetchWithRemoteDevelopmentEnvironmentBrowserSecret(input: RequestInfo | URL, init?: RequestInit): Promise { + const response = await fetch(input, init); + if (!await responseHasInvalidBrowserSecretError(response)) { + return response; + } + + await ensureRemoteDevelopmentEnvironmentBrowserSecret(); + return await fetch(input, init); +} diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index aa1eb279a..9b8080b0a 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -2,6 +2,7 @@ import { Link } from "@/components/link"; import { ActionDialog } from "@/components/ui/action-dialog"; +import { fetchWithRemoteDevelopmentEnvironmentBrowserSecret } from "@/app/remote-development-environment-browser-secret-client"; import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import type { OAuthConnection, PushedConfigSource, StackAdminApp } from "@hexclave/next"; @@ -492,7 +493,7 @@ async function updateRemoteDevelopmentEnvironmentConfigFile( adminApp: StackAdminApp, configUpdate: EnvironmentConfigOverrideOverride, ): Promise { - const response = await fetch("/api/remote-development-environment/config/apply-update", { + const response = await fetchWithRemoteDevelopmentEnvironmentBrowserSecret("/api/remote-development-environment/config/apply-update", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/dashboard/src/lib/remote-development-environment/browser-secret-common.ts b/apps/dashboard/src/lib/remote-development-environment/browser-secret-common.ts new file mode 100644 index 000000000..f8b88a65a --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/browser-secret-common.ts @@ -0,0 +1,3 @@ +export const REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_COOKIE_NAME = "hexclave-rde-browser-secret"; +export const REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_ERROR_HEADER = "x-hexclave-development-environment-browser-secret-error"; +export const REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE = "invalid_browser_secret"; diff --git a/apps/dashboard/src/lib/remote-development-environment/browser-secret.ts b/apps/dashboard/src/lib/remote-development-environment/browser-secret.ts new file mode 100644 index 000000000..bfa058e46 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/browser-secret.ts @@ -0,0 +1,460 @@ +import "server-only"; + +/** + * Browser access to the development-environment dashboard is capability-based. + * + * The dashboard process may listen on 0.0.0.0 so SSH tunnels, Codespaces-style + * preview URLs, and other forwarding setups can reach it. Because hostnames and + * origins are request metadata rather than authentication, browser-only + * endpoints require a high-entropy secret in an HttpOnly cookie. Each issued + * secret is pinned to the browser page host that requested it; requests with a + * different Host header, or with an Origin that does not match the pinned origin, + * are treated exactly like requests with no secret. + * + * There are two bootstrap paths: + * - Simple local browser use asks this module to start a one-shot helper server + * bound to 127.0.0.1 on an available port. The helper checks loopback Host, + * the pinned Origin, and an unguessable helper token before returning a + * browser secret. + * - Forwarded/public browser use asks the running CLI to show a short-lived + * confirmation code. Submitting the correct code returns a browser secret. + * + * JavaScript never stores the long-lived browser capability directly. It only + * relays a freshly issued secret to the same-origin store endpoint, which sets + * the HttpOnly cookie. + */ + +import { createHash, randomBytes, timingSafeEqual } from "crypto"; +import { createServer, type Server } from "http"; +import { NextRequest, NextResponse } from "next/server"; +import { createUrlIfValid, isLocalhost } from "@hexclave/shared/dist/utils/urls"; +import { + REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_COOKIE_NAME, + REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_ERROR_HEADER, + REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE, +} from "./browser-secret-common"; +import { isRemoteDevelopmentEnvironmentEnabled } from "./env"; +import { readRemoteDevelopmentEnvironmentState } from "./state"; + +const BROWSER_SECRET_RATE_LIMIT_MAX_REQUESTS = 50; +const BROWSER_SECRET_RATE_LIMIT_WINDOW_MS = 10_000; +const BROWSER_SECRET_BYTES = 32; +const LOCALBOUND_HELPER_TOKEN_BYTES = 24; +const LOCALBOUND_HELPER_TTL_MS = 60_000; +const BROWSER_SECRET_TTL_MS = 12 * 60 * 60 * 1000; +const CONFIRMATION_CODE_TTL_MS = 2 * 60 * 1000; +const CONFIRMATION_CODE_MAX_ATTEMPTS = 8; +const CONFIRMATION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +type IssuedBrowserSecret = { + host: string, + origin: string, + expiresAtMs: number, +}; + +type LocalboundHelperState = { + server: Server, + port: number, + helperToken: string, + targetOrigin: string, + targetHost: string, + expiresAtMs: number, +}; + +type ConfirmationCodeState = { + code: string, + targetOrigin: string, + targetHost: string, + expiresAtMs: number, + attempts: number, + shownByCli: boolean, +}; + +type BrowserSecretGlobals = { + issuedSecretsByHash: Map, + localboundHelper?: LocalboundHelperState, + confirmationCode?: ConfirmationCodeState, + rateLimitTimestamps: number[], +}; + +const browserSecretGlobals = globalThis as typeof globalThis & { + __stackRemoteDevelopmentEnvironmentBrowserSecret?: BrowserSecretGlobals, +}; + +function getGlobals(): BrowserSecretGlobals { + browserSecretGlobals.__stackRemoteDevelopmentEnvironmentBrowserSecret ??= { + issuedSecretsByHash: new Map(), + rateLimitTimestamps: [], + }; + return browserSecretGlobals.__stackRemoteDevelopmentEnvironmentBrowserSecret; +} + +function nowMs(): number { + return performance.now(); +} + +function unixNowMs(): number { + return Date.now(); +} + +function randomBase64Url(bytes: number): string { + return randomBytes(bytes).toString("base64url"); +} + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret).digest("base64url"); +} + +function stringsEqualConstantTime(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) return false; + return timingSafeEqual(leftBuffer, rightBuffer); +} + +function requestHost(req: NextRequest): string | null { + const host = req.headers.get("host"); + return host == null || host.length === 0 ? null : host; +} + +function requestProtocol(req: NextRequest): "http" | "https" { + const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + if (forwardedProto === "https") return "https"; + if (forwardedProto === "http") return "http"; + return createUrlIfValid(req.url)?.protocol === "https:" ? "https" : "http"; +} + +function requestHostOrigin(req: NextRequest): string | null { + const host = requestHost(req); + if (host == null) return null; + return `${requestProtocol(req)}://${host}`; +} + +function urlOrigin(value: string | null): string | null { + if (value == null || value.length === 0) return null; + return createUrlIfValid(value)?.origin ?? null; +} + +function expectedRequestOrigin(req: NextRequest): string | null { + return urlOrigin(req.headers.get("origin")) ?? requestHostOrigin(req); +} + +function requestMatchesPinnedHost(req: NextRequest, pin: { host: string, origin: string }): boolean { + const host = requestHost(req); + if (host !== pin.host) return false; + + const origin = req.headers.get("origin"); + if (origin != null && urlOrigin(origin) !== pin.origin) return false; + + return true; +} + +function requestLooksSameOrigin(req: NextRequest): boolean { + const hostOrigin = requestHostOrigin(req); + if (hostOrigin == null) return false; + + const origin = req.headers.get("origin"); + if (origin != null && urlOrigin(origin) !== hostOrigin) return false; + + const fetchSite = req.headers.get("sec-fetch-site"); + return fetchSite == null || fetchSite === "same-origin" || fetchSite === "none"; +} + +function hasActiveLocalDashboard(): boolean { + const state = readRemoteDevelopmentEnvironmentState(); + return Object.values(state.localDashboardsByPort ?? {}) + .some((dashboard) => dashboard != null && dashboard.secret.length > 0); +} + +export function rateLimitRemoteDevelopmentEnvironmentBrowserSecret(): NextResponse | null { + if (takeRemoteDevelopmentEnvironmentBrowserSecretRateLimitSlot()) return null; + return NextResponse.json({ error: "Too many development environment browser-secret requests." }, { status: 429 }); +} + +function takeRemoteDevelopmentEnvironmentBrowserSecretRateLimitSlot(): boolean { + const globals = getGlobals(); + const now = nowMs(); + while (globals.rateLimitTimestamps.length > 0 && now - globals.rateLimitTimestamps[0] > BROWSER_SECRET_RATE_LIMIT_WINDOW_MS) { + globals.rateLimitTimestamps.shift(); + } + if (globals.rateLimitTimestamps.length >= BROWSER_SECRET_RATE_LIMIT_MAX_REQUESTS) { + return false; + } + globals.rateLimitTimestamps.push(now); + return true; +} + +export function remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(): NextResponse { + const response = NextResponse.json({ + code: REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE, + error: "The development environment browser secret is missing or invalid.", + }, { + status: 401, + headers: { + [REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_ERROR_HEADER]: REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_INVALID_ERROR_CODE, + }, + }); + response.cookies.delete(REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_COOKIE_NAME); + return response; +} + +export function assertRemoteDevelopmentEnvironmentBrowserSecret(req: NextRequest): NextResponse | null { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); + } + if (!hasActiveLocalDashboard()) { + return NextResponse.json({ error: "Remote development environment is not active." }, { status: 404 }); + } + + const secret = req.cookies.get(REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_COOKIE_NAME)?.value; + if (secret == null || secret.length === 0) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + const globals = getGlobals(); + const issued = globals.issuedSecretsByHash.get(hashSecret(secret)); + if (issued == null || unixNowMs() > issued.expiresAtMs || !requestMatchesPinnedHost(req, issued)) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + const fetchSite = req.headers.get("sec-fetch-site"); + if (fetchSite != null && fetchSite !== "same-origin" && fetchSite !== "none") { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + return null; +} + +export function assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest(req: NextRequest): NextResponse | null { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); + } + if (!hasActiveLocalDashboard()) { + return NextResponse.json({ error: "Remote development environment is not active." }, { status: 404 }); + } + const rateLimitResponse = rateLimitRemoteDevelopmentEnvironmentBrowserSecret(); + if (rateLimitResponse != null) return rateLimitResponse; + if (!requestLooksSameOrigin(req)) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + return null; +} + +function issueBrowserSecret(target: { host: string, origin: string }): string { + const secret = randomBase64Url(BROWSER_SECRET_BYTES); + getGlobals().issuedSecretsByHash.set(hashSecret(secret), { + host: target.host, + origin: target.origin, + expiresAtMs: unixNowMs() + BROWSER_SECRET_TTL_MS, + }); + return secret; +} + +function browserSecretCookieIsSecure(req: NextRequest): boolean { + return requestProtocol(req) === "https"; +} + +export function storeRemoteDevelopmentEnvironmentBrowserSecret(req: NextRequest, secret: string): NextResponse { + const issued = getGlobals().issuedSecretsByHash.get(hashSecret(secret)); + if (issued == null || unixNowMs() > issued.expiresAtMs || !requestMatchesPinnedHost(req, issued)) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + const response = NextResponse.json({ ok: true }); + response.cookies.set(REMOTE_DEVELOPMENT_ENVIRONMENT_BROWSER_SECRET_COOKIE_NAME, secret, { + httpOnly: true, + sameSite: "strict", + secure: browserSecretCookieIsSecure(req), + path: "/", + expires: new Date(issued.expiresAtMs), + }); + return response; +} + +function localboundRequestHostIsLoopback(host: string | string[] | undefined): boolean { + if (Array.isArray(host) || host == null || host.length === 0) return false; + return isLocalhost(`http://${host}`); +} + +function stopExpiredLocalboundHelper(): void { + const helper = getGlobals().localboundHelper; + if (helper == null || unixNowMs() <= helper.expiresAtMs) return; + helper.server.close(); + getGlobals().localboundHelper = undefined; +} + +export async function startRemoteDevelopmentEnvironmentBrowserSecretLocalboundServer(req: NextRequest): Promise { + const securityResponse = assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest(req); + if (securityResponse != null) return securityResponse; + + const targetHost = requestHost(req); + const targetOrigin = expectedRequestOrigin(req); + if (targetHost == null || targetOrigin == null) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + stopExpiredLocalboundHelper(); + const existing = getGlobals().localboundHelper; + if (existing != null && existing.targetHost === targetHost && existing.targetOrigin === targetOrigin) { + return NextResponse.json({ url: `http://127.0.0.1:${existing.port}/browser-secret?token=${encodeURIComponent(existing.helperToken)}` }); + } + if (existing != null) { + existing.server.close(); + getGlobals().localboundHelper = undefined; + } + + const helperToken = randomBase64Url(LOCALBOUND_HELPER_TOKEN_BYTES); + const expiresAtMs = unixNowMs() + LOCALBOUND_HELPER_TTL_MS; + const server = createServer((request, response) => { + const origin = typeof request.headers.origin === "string" ? request.headers.origin : null; + const allowCors = origin != null && urlOrigin(origin) === targetOrigin; + if (allowCors) { + response.setHeader("Access-Control-Allow-Origin", targetOrigin); + response.setHeader("Vary", "Origin"); + response.setHeader("Access-Control-Allow-Private-Network", "true"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type"); + } + + if (request.method === "OPTIONS") { + response.statusCode = allowCors ? 204 : 403; + response.end(); + return; + } + + const parsedUrl = createUrlIfValid(request.url ?? "", "http://127.0.0.1"); + const requestToken = parsedUrl?.searchParams.get("token"); + const allowed = ( + takeRemoteDevelopmentEnvironmentBrowserSecretRateLimitSlot() && + request.method === "GET" && + parsedUrl?.pathname === "/browser-secret" && + allowCors && + localboundRequestHostIsLoopback(request.headers.host) && + requestToken != null && + stringsEqualConstantTime(requestToken, helperToken) && + unixNowMs() <= expiresAtMs + ); + + if (!allowed) { + response.statusCode = 403; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ error: "Unauthorized." })); + return; + } + + const browserSecret = issueBrowserSecret({ host: targetHost, origin: targetOrigin }); + response.statusCode = 200; + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + response.end(JSON.stringify({ browser_secret: browserSecret })); + }); + + await new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolvePromise(); + }); + }); + const address = server.address(); + if (address == null || typeof address === "string") { + server.close(); + throw new Error("Localbound browser-secret server did not report a TCP port."); + } + + server.unref(); + getGlobals().localboundHelper = { + server, + port: address.port, + helperToken, + targetOrigin, + targetHost, + expiresAtMs, + }; + + return NextResponse.json({ url: `http://127.0.0.1:${address.port}/browser-secret?token=${encodeURIComponent(helperToken)}` }); +} + +function randomConfirmationCode(): string { + let code = ""; + for (let i = 0; i < 6; i++) { + code += CONFIRMATION_CODE_ALPHABET[randomBytes(1)[0] % CONFIRMATION_CODE_ALPHABET.length]; + } + return code; +} + +export function initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(req: NextRequest): NextResponse { + const securityResponse = assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest(req); + if (securityResponse != null) return securityResponse; + + const targetHost = requestHost(req); + const targetOrigin = expectedRequestOrigin(req); + if (targetHost == null || targetOrigin == null) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + const existing = getGlobals().confirmationCode; + if ( + existing != null && + unixNowMs() <= existing.expiresAtMs && + existing.targetHost === targetHost && + existing.targetOrigin === targetOrigin + ) { + return NextResponse.json({ expires_at_millis: existing.expiresAtMs }); + } + + const code = randomConfirmationCode(); + const expiresAtMs = unixNowMs() + CONFIRMATION_CODE_TTL_MS; + getGlobals().confirmationCode = { + code, + targetHost, + targetOrigin, + expiresAtMs, + attempts: 0, + shownByCli: false, + }; + return NextResponse.json({ expires_at_millis: expiresAtMs }); +} + +export function submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(req: NextRequest, code: string): NextResponse { + const securityResponse = assertRemoteDevelopmentEnvironmentBrowserSecretSetupRequest(req); + if (securityResponse != null) return securityResponse; + + const targetHost = requestHost(req); + const targetOrigin = expectedRequestOrigin(req); + const confirmationCode = getGlobals().confirmationCode; + if ( + confirmationCode == null || + targetHost == null || + targetOrigin == null || + unixNowMs() > confirmationCode.expiresAtMs || + confirmationCode.targetHost !== targetHost || + confirmationCode.targetOrigin !== targetOrigin + ) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + confirmationCode.attempts += 1; + if ( + confirmationCode.attempts > CONFIRMATION_CODE_MAX_ATTEMPTS || + !stringsEqualConstantTime(code.toUpperCase(), confirmationCode.code) + ) { + return remoteDevelopmentEnvironmentBrowserSecretInvalidResponse(); + } + + getGlobals().confirmationCode = undefined; + return NextResponse.json({ + browser_secret: issueBrowserSecret({ host: confirmationCode.targetHost, origin: confirmationCode.targetOrigin }), + }); +} + +export function consumeRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli(): { code: string, expiresAtMillis: number } | null { + const confirmationCode = getGlobals().confirmationCode; + if (confirmationCode == null || unixNowMs() > confirmationCode.expiresAtMs || confirmationCode.shownByCli) { + return null; + } + confirmationCode.shownByCli = true; + return { + code: confirmationCode.code, + expiresAtMillis: confirmationCode.expiresAtMs, + }; +} diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.ts b/apps/dashboard/src/lib/remote-development-environment/manager.ts index cfc544c1f..8b0e0603b 100644 --- a/apps/dashboard/src/lib/remote-development-environment/manager.ts +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -11,6 +11,7 @@ import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; import { randomUUID } from "crypto"; import { watch, type FSWatcher } from "fs"; import { basename, dirname } from "path"; +import { consumeRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli } from "./browser-secret"; import { ensureConfigFileExists, readConfigFile, @@ -566,6 +567,11 @@ export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): return true; } +export function getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(): { code: string, expiresAtMillis: number } | null { + assertRemoteDevelopmentEnvironmentEnabled(); + return consumeRemoteDevelopmentEnvironmentBrowserSecretConfirmationCodeForCli(); +} + export function closeRemoteDevelopmentEnvironmentSession(sessionId: string): void { assertRemoteDevelopmentEnvironmentEnabled(); const state = getGlobals(); diff --git a/apps/dashboard/src/lib/remote-development-environment/security.test.ts b/apps/dashboard/src/lib/remote-development-environment/security.test.ts index 9e3b94d0b..5d99bee23 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.test.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.test.ts @@ -2,6 +2,7 @@ import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; vi.mock("server-only", () => ({})); @@ -27,8 +28,8 @@ function useTempStateFile(secret = "secret") { chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600); } -function request(headers: Record) { - return new Request("http://127.0.0.1:26700/api/remote-development-environment/sessions", { headers }) as any; +function request(headers: Record, url = "http://127.0.0.1:26700/api/remote-development-environment/sessions") { + return new NextRequest(url, { headers }); } afterEach(() => { @@ -72,7 +73,7 @@ describe("remote development environment security", () => { expect(badHost?.status).toBe(403); }); - it("allows same-origin browser auth without exposing the CLI bearer token", async () => { + it("requires a browser secret for same-origin browser auth", async () => { useTempStateFile(); const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ @@ -80,7 +81,8 @@ describe("remote development environment security", () => { origin: "http://127.0.0.1:26700", "sec-fetch-site": "same-origin", })); - expect(response).toBeNull(); + expect(response?.status).toBe(401); + expect(response?.headers.get("x-hexclave-development-environment-browser-secret-error")).toBe("invalid_browser_secret"); }); it("rejects browser auth without an active local dashboard", async () => { @@ -94,25 +96,105 @@ describe("remote development environment security", () => { expect(response?.status).toBe(404); }); - it("rejects browser auth from arbitrary localhost origins", async () => { + it("rejects browser auth without the pinned browser secret", async () => { useTempStateFile(); const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ - host: "127.0.0.1:26700", - origin: "http://evil.localhost:26700", + host: "preview.example.test", + origin: "http://preview.example.test", "sec-fetch-site": "same-origin", })); - expect(response?.status).toBe(403); + expect(response?.status).toBe(401); }); - it("rejects cross-site browser auth navigation", async () => { + it("accepts browser auth with a confirmation-code-issued host-pinned secret", async () => { useTempStateFile(); + const { + initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode, + storeRemoteDevelopmentEnvironmentBrowserSecret, + submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode, + } = await import("./browser-secret"); + const { getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode } = await import("./manager"); const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); - const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ - host: "127.0.0.1:26700", - "sec-fetch-site": "cross-site", + + const hostPinnedRequest = request({ + host: "preview.example.test", + origin: "http://preview.example.test", + "sec-fetch-site": "same-origin", + }); + expect(initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(hostPinnedRequest).status).toBe(200); + const confirmationCode = getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(); + expect(confirmationCode?.code).toMatch(/^[A-Z0-9]{6}$/); + if (confirmationCode == null) { + throw new Error("Confirmation code should have been created."); + } + + const submitResponse = submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(hostPinnedRequest, confirmationCode.code); + expect(submitResponse.status).toBe(200); + const submitBody = await submitResponse.json(); + if ( + submitBody == null || + typeof submitBody !== "object" || + !("browser_secret" in submitBody) || + typeof submitBody.browser_secret !== "string" + ) { + throw new Error("Expected submit response to include browser_secret."); + } + + const storeResponse = storeRemoteDevelopmentEnvironmentBrowserSecret(hostPinnedRequest, submitBody.browser_secret); + const cookie = storeResponse.headers.get("set-cookie"); + expect(storeResponse.status).toBe(200); + expect(cookie).toContain("hexclave-rde-browser-secret="); + + const browserResponse = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ + host: "preview.example.test", + origin: "http://preview.example.test", + "sec-fetch-site": "same-origin", + cookie: cookie ?? "", })); - expect(response?.status).toBe(403); + expect(browserResponse).toBeNull(); + }); + + it("rejects browser secrets replayed on another host", async () => { + useTempStateFile(); + const { + initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode, + storeRemoteDevelopmentEnvironmentBrowserSecret, + submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode, + } = await import("./browser-secret"); + const { getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode } = await import("./manager"); + const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); + + const hostPinnedRequest = request({ + host: "preview.example.test", + origin: "http://preview.example.test", + "sec-fetch-site": "same-origin", + }); + initRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(hostPinnedRequest); + const confirmationCode = getPendingRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(); + if (confirmationCode == null) { + throw new Error("Confirmation code should have been created."); + } + const submitResponse = submitRemoteDevelopmentEnvironmentBrowserSecretConfirmationCode(hostPinnedRequest, confirmationCode.code); + const submitBody = await submitResponse.json(); + if ( + submitBody == null || + typeof submitBody !== "object" || + !("browser_secret" in submitBody) || + typeof submitBody.browser_secret !== "string" + ) { + throw new Error("Expected submit response to include browser_secret."); + } + const storeResponse = storeRemoteDevelopmentEnvironmentBrowserSecret(hostPinnedRequest, submitBody.browser_secret); + const cookie = storeResponse.headers.get("set-cookie"); + + const browserResponse = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ + host: "attacker.example.test", + origin: "http://attacker.example.test", + "sec-fetch-site": "same-origin", + cookie: cookie ?? "", + }, "http://attacker.example.test/api/remote-development-environment/sessions")); + expect(browserResponse?.status).toBe(401); }); it("accepts CLI bearer requests from loopback without trusting arbitrary origins", async () => { @@ -153,19 +235,19 @@ describe("remote development environment security", () => { chmodSync(statePath, 0o600); const { assertRemoteDevelopmentEnvironmentBrowserRequest, assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); - expect(assertRemoteDevelopmentEnvironmentRequest(new Request("http://127.0.0.1:26701/api/remote-development-environment/sessions", { + expect(assertRemoteDevelopmentEnvironmentRequest(new NextRequest("http://127.0.0.1:26701/api/remote-development-environment/sessions", { headers: { host: "127.0.0.1:26701", authorization: "Bearer second-secret", }, - }) as any)).toBeNull(); - expect(assertRemoteDevelopmentEnvironmentBrowserRequest(new Request("http://127.0.0.1:26701/api/remote-development-environment/sessions", { + }))).toBeNull(); + expect(assertRemoteDevelopmentEnvironmentBrowserRequest(new NextRequest("http://127.0.0.1:26701/api/remote-development-environment/sessions", { headers: { host: "127.0.0.1:26701", origin: "http://127.0.0.1:26701", "sec-fetch-site": "same-origin", }, - }) as any)).toBeNull(); + }))?.status).toBe(401); }); it("rejects config writes without an active session", async () => { diff --git a/apps/dashboard/src/lib/remote-development-environment/security.ts b/apps/dashboard/src/lib/remote-development-environment/security.ts index aa60e799b..2ea0d0f9e 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.ts @@ -1,16 +1,11 @@ import "server-only"; -import { getPublicEnvVar } from "@/lib/env"; import { NextRequest, NextResponse } from "next/server"; import { createUrlIfValid, isLocalhost } from "@hexclave/shared/dist/utils/urls"; +import { assertRemoteDevelopmentEnvironmentBrowserSecret } from "./browser-secret"; import { isRemoteDevelopmentEnvironmentEnabled } from "./env"; import { LocalDashboardState, RemoteDevelopmentEnvironmentState, readRemoteDevelopmentEnvironmentState } from "./state"; -function urlOrigin(value: string | undefined): string | null { - if (value == null || value.length === 0) return null; - return createUrlIfValid(value)?.origin ?? null; -} - function requestHostIsLoopback(req: NextRequest): boolean { const host = req.headers.get("host"); if (host == null) return false; @@ -30,10 +25,6 @@ function requestHostUrl(req: NextRequest): URL | null { return createUrlIfValid(`http://${host}`); } -function requestHostOrigin(req: NextRequest): string | null { - return requestHostUrl(req)?.origin ?? null; -} - function requestHostPort(req: NextRequest): number | null { const port = requestHostUrl(req)?.port; return port == null || port.length === 0 ? null : Number(port); @@ -50,30 +41,6 @@ function localDashboardSecretForRequest(req: NextRequest, state: RemoteDevelopme return localDashboards(state).find((dashboard) => dashboard.port === port)?.secret ?? null; } -function hasActiveLocalDashboard(state: RemoteDevelopmentEnvironmentState): boolean { - return localDashboards(state).some((dashboard) => dashboard.secret.length > 0); -} - -function expectedDashboardOrigins(state: RemoteDevelopmentEnvironmentState): Set { - return new Set([ - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_STACK_DASHBOARD_URL")), - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL")), - urlOrigin(getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL")), - ...localDashboards(state).map((dashboard) => `http://127.0.0.1:${dashboard.port}`), - ].filter((origin): origin is string => origin != null)); -} - -function browserRequestOriginIsAllowed(req: NextRequest, state: RemoteDevelopmentEnvironmentState): boolean { - const allowedOrigins = expectedDashboardOrigins(state); - const requestOrigin = requestHostOrigin(req); - if (requestOrigin == null || !allowedOrigins.has(requestOrigin)) return false; - - const origin = req.headers.get("origin"); - if (origin == null) return true; - const parsedOrigin = urlOrigin(origin); - return parsedOrigin != null && allowedOrigins.has(parsedOrigin); -} - export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): NextResponse | null { if (!isRemoteDevelopmentEnvironmentEnabled()) { return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); @@ -98,23 +65,5 @@ export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): Nex } export function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextRequest): NextResponse | null { - if (!isRemoteDevelopmentEnvironmentEnabled()) { - return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); - } - - const state = readRemoteDevelopmentEnvironmentState(); - if (!hasActiveLocalDashboard(state)) { - return NextResponse.json({ error: "Remote development environment is not active." }, { status: 404 }); - } - - if (!requestHostIsLoopback(req) || !browserRequestOriginIsAllowed(req, state)) { - return NextResponse.json({ error: loopbackRejectionMessage(req, state) }, { status: 403 }); - } - - const fetchSite = req.headers.get("sec-fetch-site"); - if (fetchSite != null && fetchSite !== "same-origin" && fetchSite !== "none") { - return NextResponse.json({ error: "Remote development environment browser auth only accepts same-origin navigation." }, { status: 403 }); - } - - return null; + return assertRemoteDevelopmentEnvironmentBrowserSecret(req); } diff --git a/packages/stack-cli/src/commands/dev.ts b/packages/stack-cli/src/commands/dev.ts index 564ebfd02..e9074a8e2 100644 --- a/packages/stack-cli/src/commands/dev.ts +++ b/packages/stack-cli/src/commands/dev.ts @@ -24,6 +24,12 @@ type SessionResponse = { onboarding_outstanding: boolean, }; +type HeartbeatResponse = { + ok: true, + browser_secret_confirmation_code?: string, + browser_secret_confirmation_code_expires_at_millis?: number, +}; + const HEARTBEAT_INTERVAL_MS = 5_000; const HEARTBEAT_STOP_POLL_MS = 100; const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000; @@ -246,11 +252,12 @@ function prepareDashboardRuntime(env: NodeJS.ProcessEnv, port: number): string { return runtimeServerPath; } -async function isDashboardReachable(url: string): Promise { +async function isDashboardReachable(url: string, secret: string): Promise { try { const response = await fetch(`${url}${DASHBOARD_HEALTH_PATH}`, { headers: { Accept: "application/json", + Authorization: `Bearer ${secret}`, }, }); const body: unknown = await response.json(); @@ -269,7 +276,7 @@ async function isDashboardReachable(url: string): Promise { async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: string, port: number }): Promise { const url = dashboardUrl(options.port); - if (await isDashboardReachable(url)) { + if (await isDashboardReachable(url, options.secret)) { logDev(`Using existing Hexclave dashboard on ${url}.`); return; } @@ -279,7 +286,7 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str ...process.env, NODE_ENV: "production", PORT: String(options.port), - HOSTNAME: "127.0.0.1", + HOSTNAME: "0.0.0.0", STACK_API_URL: options.apiBaseUrl, NEXT_PUBLIC_STACK_API_URL: options.apiBaseUrl, NEXT_PUBLIC_BROWSER_STACK_API_URL: options.apiBaseUrl, @@ -321,7 +328,7 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str const startedAt = performance.now(); while (performance.now() - startedAt < DASHBOARD_START_TIMEOUT_MS) { - if (await isDashboardReachable(url)) { + if (await isDashboardReachable(url, options.secret)) { progress.stop(`Started Hexclave dashboard`); return; } @@ -397,6 +404,35 @@ function isSessionResponse(value: unknown): value is SessionResponse { ); } +function isHeartbeatResponse(value: unknown): value is HeartbeatResponse { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "ok" in value && + value.ok === true && + ( + !("browser_secret_confirmation_code" in value) || + typeof value.browser_secret_confirmation_code === "string" + ) && + ( + !("browser_secret_confirmation_code_expires_at_millis" in value) || + typeof value.browser_secret_confirmation_code_expires_at_millis === "number" + ) + ); +} + +function logBrowserSecretConfirmationCode(response: HeartbeatResponse): void { + if (response.browser_secret_confirmation_code == null) return; + const expiresAtMillis = response.browser_secret_confirmation_code_expires_at_millis; + const expiresInSeconds = expiresAtMillis == null + ? undefined + : Math.max(0, Math.ceil((expiresAtMillis - Date.now()) / 1000)); + logDev(expiresInSeconds == null + ? `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code}` + : `Dashboard browser confirmation code: ${response.browser_secret_confirmation_code} (expires in ${expiresInSeconds}s)`); +} + async function createRemoteDevelopmentEnvironmentSession(options: { apiBaseUrl: string, configFilePath: string, @@ -527,7 +563,15 @@ async function heartbeatUntilStopped(sessionState: DashboardSessionState, option }); sessionState.dashboardReachableSinceMs = performance.now(); logDev(`Hexclave dashboard running at ${dashboardUrl(options.port)}`); + continue; } + + const heartbeatBody: unknown = await response.json(); + if (!isHeartbeatResponse(heartbeatBody)) { + logDev("Development environment heartbeat returned an invalid response."); + continue; + } + logBrowserSecretConfirmationCode(heartbeatBody); } }