Support local dashboard in remote SSH & GH codespaces

This commit is contained in:
Konstantin Wohlwend 2026-06-02 17:20:18 -07:00
parent bfd15f07d1
commit d302127e4b
19 changed files with 1036 additions and 183 deletions

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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<string, string>) {
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);
});
});

View File

@ -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<string> {
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<boolean> {
}
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({

View File

@ -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,
});
}

View File

@ -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<string, unknown> {
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<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<div className="relative z-10 min-h-screen bg-background text-foreground flex items-center justify-center px-4">
<div className="w-full max-w-lg rounded-2xl border border-black/[0.10] dark:border-white/[0.10] bg-white dark:bg-background p-6 shadow-sm">
<div className="mb-3 inline-flex rounded-full bg-blue-500/10 px-3 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">
Browser authorization
</div>
<h1 className="text-2xl font-semibold tracking-tight">Authorize this browser</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
This dashboard is reachable through a forwarded address. To keep it private, enter the 6-character confirmation code shown by the running <code className="rounded bg-black/[0.04] dark:bg-white/[0.06] px-1 py-0.5 text-xs">stack dev</code> command.
</p>
<p className="mt-3 text-sm text-muted-foreground">{expiresText}</p>
<form className="mt-5 space-y-4" onSubmit={(event) => {
runAsynchronouslyWithAlert(submitCode(event));
}}>
<div>
<label htmlFor="browser-secret-code" className="text-sm font-medium">
Confirmation code
</label>
<input
id="browser-secret-code"
autoFocus
autoComplete="one-time-code"
value={code}
onChange={(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"
/>
</div>
{errorMessage != null && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<button
type="submit"
disabled={submitting || code.length !== 6}
className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "Authorizing..." : "Authorize browser"}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { BrowserSecretConfirmationPageClient } from "./page-client";
export default function BrowserSecretConfirmationPage() {
return <BrowserSecretConfirmationPageClient />;
}

View File

@ -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 <path-to-stack.config.ts> -- <your app command>",
@ -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 (
<>
<StackProvider app={stackClientApp} lang={props.translationLocale as React.ComponentProps<typeof StackProvider>["lang"]}>
<StackTheme>
<ClientPolyfill />
<DevEnvironmentHealthGate>
<RemoteDevelopmentEnvironmentAuthGate>
<DevEnvironmentHealthGate disabled={isBrowserSecretAuthorizationPage}>
<RemoteDevelopmentEnvironmentAuthGate disabled={isBrowserSecretAuthorizationPage}>
<RouterProvider>
<UserIdentity />
<VersionAlerter />

View File

@ -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<RemoteDevelopmentEnvironmentAccessTokenResponse> {
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 <WrongAddressScreen suggestedUrl={loopbackError.suggestedUrl} />;
}
if (!accessTokenInstalled) {
return <Loading />;
}
@ -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;
}

View File

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function responseHasInvalidBrowserSecretError(response: Response): Promise<boolean> {
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<void> {
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<boolean> {
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<void> {
if (await tryInstallBrowserSecretFromLocalboundServer()) return;
redirectToBrowserSecretConfirmation();
}
export async function fetchWithRemoteDevelopmentEnvironmentBrowserSecret(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const response = await fetch(input, init);
if (!await responseHasInvalidBrowserSecretError(response)) {
return response;
}
await ensureRemoteDevelopmentEnvironmentBrowserSecret();
return await fetch(input, init);
}

View File

@ -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<false>,
configUpdate: EnvironmentConfigOverrideOverride,
): Promise<void> {
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",

View File

@ -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";

View File

@ -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<string, IssuedBrowserSecret>,
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<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();
}
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<void>((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,
};
}

View File

@ -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();

View File

@ -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<string, string>) {
return new Request("http://127.0.0.1:26700/api/remote-development-environment/sessions", { headers }) as any;
function request(headers: Record<string, string>, 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 () => {

View File

@ -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<string> {
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);
}

View File

@ -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<boolean> {
async function isDashboardReachable(url: string, secret: string): Promise<boolean> {
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<boolean> {
async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: string, port: number }): Promise<void> {
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);
}
}