mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Support local dashboard in remote SSH & GH codespaces
This commit is contained in:
parent
bfd15f07d1
commit
d302127e4b
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import { BrowserSecretConfirmationPageClient } from "./page-client";
|
||||
|
||||
export default function BrowserSecretConfirmationPage() {
|
||||
return <BrowserSecretConfirmationPageClient />;
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user