diff --git a/packages/sandbox/src/preview-proxy-protocol.ts b/packages/sandbox/src/preview-proxy-protocol.ts deleted file mode 100644 index d06e9f441..000000000 --- a/packages/sandbox/src/preview-proxy-protocol.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** @internal */ -export const PREVIEW_PROXY_HEADER = 'x-sandbox-preview-proxy'; -/** @internal */ -export const PREVIEW_PROXY_PORT_HEADER = 'x-sandbox-preview-port'; -/** @internal */ -export const PREVIEW_PROXY_TOKEN_HEADER = 'x-sandbox-preview-token'; -/** @internal */ -export const PREVIEW_PROXY_SANDBOX_ID_HEADER = 'x-sandbox-preview-sandbox-id'; - -/** @internal */ -export const PREVIEW_PROXY_HEADERS = [ - PREVIEW_PROXY_HEADER, - PREVIEW_PROXY_PORT_HEADER, - PREVIEW_PROXY_TOKEN_HEADER, - PREVIEW_PROXY_SANDBOX_ID_HEADER -] as const; diff --git a/packages/sandbox/src/preview-forwarding.ts b/packages/sandbox/src/preview/forwarding.ts similarity index 100% rename from packages/sandbox/src/preview-forwarding.ts rename to packages/sandbox/src/preview/forwarding.ts diff --git a/packages/sandbox/src/preview/protocol.ts b/packages/sandbox/src/preview/protocol.ts new file mode 100644 index 000000000..bec524e43 --- /dev/null +++ b/packages/sandbox/src/preview/protocol.ts @@ -0,0 +1,63 @@ +import { validatePort } from '../security'; + +/** @internal */ +export const PREVIEW_PROXY_HEADER = 'x-sandbox-preview-proxy'; +/** @internal */ +export const PREVIEW_PROXY_PORT_HEADER = 'x-sandbox-preview-port'; +/** @internal */ +export const PREVIEW_PROXY_TOKEN_HEADER = 'x-sandbox-preview-token'; +/** @internal */ +export const PREVIEW_PROXY_SANDBOX_ID_HEADER = 'x-sandbox-preview-sandbox-id'; + +/** @internal */ +export const PREVIEW_PROXY_HEADERS = [ + PREVIEW_PROXY_HEADER, + PREVIEW_PROXY_PORT_HEADER, + PREVIEW_PROXY_TOKEN_HEADER, + PREVIEW_PROXY_SANDBOX_ID_HEADER +] as const; + +export interface PreviewProxyMetadata { + port: number; + token: string; + sandboxId: string; +} + +export function isPreviewProxyRequest(request: Request): boolean { + return request.headers.get(PREVIEW_PROXY_HEADER) === '1'; +} + +export function readPreviewProxyMetadata( + request: Request +): PreviewProxyMetadata | null { + if (!isPreviewProxyRequest(request)) { + return null; + } + + const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER); + const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER); + const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER); + const port = portValue === null ? Number.NaN : Number.parseInt(portValue, 10); + + if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) { + return null; + } + + return { port, token, sandboxId }; +} + +export function withPreviewProxyMetadata( + request: Request, + { port, token, sandboxId }: PreviewProxyMetadata +): Request { + const headers = new Headers(request.headers); + for (const header of PREVIEW_PROXY_HEADERS) { + headers.delete(header); + } + headers.set(PREVIEW_PROXY_HEADER, '1'); + headers.set(PREVIEW_PROXY_PORT_HEADER, port.toString()); + headers.set(PREVIEW_PROXY_TOKEN_HEADER, token); + headers.set(PREVIEW_PROXY_SANDBOX_ID_HEADER, sandboxId); + + return new Request(request, { headers }); +} diff --git a/packages/sandbox/src/preview/proxy-request.ts b/packages/sandbox/src/preview/proxy-request.ts new file mode 100644 index 000000000..ae386159e --- /dev/null +++ b/packages/sandbox/src/preview/proxy-request.ts @@ -0,0 +1,46 @@ +import { PREVIEW_PROXY_HEADERS } from './protocol'; + +export interface BuildPreviewProxyRequestOptions { + port: number; + sandboxId: string; + sandboxName: string | null; +} + +export function buildPreviewProxyRequest( + request: Request, + { port, sandboxId, sandboxName }: BuildPreviewProxyRequestOptions +): Request { + const url = new URL(request.url); + const proxyURL = `http://localhost:${port}${url.pathname}${url.search}`; + const headers = stripPreviewProxyHeaders(request.headers); + + headers.set('X-Original-URL', request.url); + headers.set('X-Forwarded-Host', url.hostname); + headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')); + headers.set('X-Sandbox-Name', sandboxName ?? sandboxId); + + const upgradeHeader = request.headers.get('Upgrade'); + if (upgradeHeader?.toLowerCase() === 'websocket') { + return new Request(request, { + headers, + redirect: 'manual' + }); + } + + return new Request(proxyURL, { + method: request.method, + headers, + body: request.body, + // @ts-expect-error - duplex required for body streaming in modern runtimes + duplex: 'half', + redirect: 'manual' + }); +} + +function stripPreviewProxyHeaders(source: Headers): Headers { + const headers = new Headers(source); + for (const header of PREVIEW_PROXY_HEADERS) { + headers.delete(header); + } + return headers; +} diff --git a/packages/sandbox/src/preview/route.ts b/packages/sandbox/src/preview/route.ts new file mode 100644 index 000000000..1a3f6af2e --- /dev/null +++ b/packages/sandbox/src/preview/route.ts @@ -0,0 +1,140 @@ +import { + SandboxSecurityError, + sanitizeSandboxId, + validatePort +} from '../security'; +import { isLocalhostPattern } from './url'; + +export interface PreviewRouteInfo { + port: number; + sandboxId: string; + token: string; +} + +export interface ConstructPreviewURLOptions { + port: number; + sandboxId: string; + effectiveId: string; + hostname: string; + token: string; + normalizeId: boolean; +} + +export function parsePreviewRoute(url: URL): PreviewRouteInfo | null { + // URL format: {port}-{sandboxId}-{token}.{domain} + // Tokens are [a-z0-9_]+, so split at the last hyphen to handle sandbox IDs + // with hyphens such as UUIDs. + const dotIndex = url.hostname.indexOf('.'); + if (dotIndex === -1) { + return null; + } + + const subdomain = url.hostname.slice(0, dotIndex); + + const firstHyphen = subdomain.indexOf('-'); + if (firstHyphen === -1) { + return null; + } + + const portStr = subdomain.slice(0, firstHyphen); + if (!/^\d{4,5}$/.test(portStr)) { + return null; + } + + const port = Number.parseInt(portStr, 10); + if (!validatePort(port)) { + return null; + } + + const rest = subdomain.slice(firstHyphen + 1); + const lastHyphen = rest.lastIndexOf('-'); + if (lastHyphen === -1) { + return null; + } + + const sandboxId = rest.slice(0, lastHyphen); + const token = rest.slice(lastHyphen + 1); + + if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) { + return null; + } + + if (sandboxId.length === 0 || sandboxId.length > 63) { + return null; + } + + let sanitizedSandboxId: string; + try { + sanitizedSandboxId = sanitizeSandboxId(sandboxId); + } catch { + return null; + } + + return { + port, + sandboxId: sanitizedSandboxId, + token + }; +} + +export function constructPreviewURL({ + port, + sandboxId, + effectiveId, + hostname, + token, + normalizeId +}: ConstructPreviewURLOptions): string { + if (!validatePort(port)) { + throw new SandboxSecurityError( + `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + ); + } + + const hasUppercase = /[A-Z]/.test(effectiveId); + if (!normalizeId && hasUppercase) { + throw new SandboxSecurityError( + `Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\n` + + `To fix this:\n` + + `1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n` + + `2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\n` + + `Note: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.` + ); + } + + const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase(); + const isLocalhost = isLocalhostPattern(hostname); + + if (isLocalhost) { + const [host, portStr] = hostname.split(':'); + const mainPort = portStr || '80'; + + try { + const baseURL = new URL(`http://${host}:${mainPort}`); + const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${host}`; + baseURL.hostname = subdomainHost; + + return baseURL.toString(); + } catch (error) { + throw new SandboxSecurityError( + `Failed to construct preview URL: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } + } + + try { + const baseURL = new URL(`https://${hostname}`); + const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${hostname}`; + baseURL.hostname = subdomainHost; + + return baseURL.toString(); + } catch (error) { + throw new SandboxSecurityError( + `Failed to construct preview URL: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +} diff --git a/packages/sandbox/src/preview/service.ts b/packages/sandbox/src/preview/service.ts new file mode 100644 index 000000000..348920be7 --- /dev/null +++ b/packages/sandbox/src/preview/service.ts @@ -0,0 +1,511 @@ +import { type Logger, logCanonicalEvent } from '@repo/shared'; +import type { + CurrentRuntimeIdentity, + RuntimeIdentity +} from '../current-runtime-identity'; +import type { ErrorResponse } from '../errors'; +import { CustomDomainRequiredError, ErrorCode } from '../errors'; +import { SandboxSecurityError, validatePort } from '../security'; +import { forwardPreviewRequest, type PreviewTCPPort } from './forwarding'; +import { readPreviewProxyMetadata } from './protocol'; +import { buildPreviewProxyRequest } from './proxy-request'; +import { constructPreviewURL } from './route'; +import { + type CurrentPreviewPort, + clearActivePreviewPorts, + PORT_TOKENS_STORAGE_KEY, + type PortTokenEntry, + type PreviewPortActivations, + readActivePreviewPorts, + readPortTokens, + readPreviewState, + writeActivePreviewPorts +} from './state'; +import { + assertValidCustomPreviewToken, + generatePreviewToken, + previewTokensMatch +} from './token'; + +export type PreviewForwardingContainer = { + running?: boolean; + getTcpPort(port: number): PreviewTCPPort; +}; + +type StalePreviewRuntime = { + status: 'stale'; + reason: + | 'runtime-not-healthy' + | 'runtime-not-running' + | 'missing-runtime-id' + | 'missing-activation' + | 'runtime-mismatch' + | 'token-mismatch'; + containerStatus?: string; +}; + +type PreviewURLRuntimeValidation = + | { status: 'invalid' } + | StalePreviewRuntime + | { status: 'active'; runtime: RuntimeIdentity }; + +type PreviewRuntimeAvailability = + | StalePreviewRuntime + | { status: 'active'; runtime: RuntimeIdentity }; + +type PreviewRuntimeSnapshot = { + containerStatus: string; + containerRunning: boolean; + tokens: Record; + activations: PreviewPortActivations; + runtime: RuntimeIdentity | null; +}; + +export interface PreviewServiceDeps { + storage: DurableObjectStorage; + logger: Logger; + currentRuntime: CurrentRuntimeIdentity; + getContainerState(): Promise<{ status: string }>; + getForwardingContainer(): PreviewForwardingContainer | undefined; + ensureRuntimeActiveForPreview(): Promise; + getSandboxName(): string | null; + getNormalizeID(): boolean; + beginForward(): () => void; + renewActivity(): void; +} + +export class PreviewService { + constructor(private readonly deps: PreviewServiceDeps) {} + + async clearActivePreviewPorts(): Promise { + await clearActivePreviewPorts(this.deps.storage); + } + + async clearPreviewState(): Promise { + await this.deps.storage.transaction(async (txn) => { + await txn.delete(PORT_TOKENS_STORAGE_KEY); + await clearActivePreviewPorts(txn); + }); + } + + async exposePort( + port: number, + options: { name?: string; hostname: string; token?: string } + ): Promise<{ url: string; port: number; name?: string }> { + const exposeStartTime = Date.now(); + let outcome: 'success' | 'error' = 'error'; + let caughtError: Error | undefined; + try { + if (!validatePort(port)) { + throw new SandboxSecurityError( + `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + ); + } + + if (options.hostname.endsWith('.workers.dev')) { + const errorResponse: ErrorResponse = { + code: ErrorCode.CUSTOM_DOMAIN_REQUIRED, + message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`, + context: { originalError: options.hostname }, + httpStatus: 400, + timestamp: new Date().toISOString() + }; + throw new CustomDomainRequiredError(errorResponse); + } + + const sandboxName = this.deps.getSandboxName(); + if (!sandboxName) { + throw new Error( + 'Sandbox name not available. Ensure sandbox is accessed through getSandbox()' + ); + } + + if (options.token !== undefined) { + assertValidCustomPreviewToken(options.token); + } + + const runtime = await this.deps.ensureRuntimeActiveForPreview(); + await this.deps.currentRuntime.assertActive(runtime); + + const token = await this.deps.storage.transaction(async (txn) => { + const tokens = await readPortTokens(txn); + const existingEntry = tokens[port.toString()]; + const nextToken = + options.token ?? existingEntry?.token ?? generatePreviewToken(); + + const existingPort = Object.entries(tokens).find( + ([p, entry]) => entry.token === nextToken && p !== port.toString() + ); + if (existingPort) { + throw new SandboxSecurityError( + `Token '${nextToken}' is already in use by port ${existingPort[0]}. Please use a different token.` + ); + } + + const activations = await readActivePreviewPorts(txn); + + tokens[port.toString()] = { token: nextToken, name: options.name }; + activations[port.toString()] = runtime.scope({ token: nextToken }); + await Promise.all([ + txn.put(PORT_TOKENS_STORAGE_KEY, tokens), + writeActivePreviewPorts(activations, txn) + ]); + + return nextToken; + }); + + await this.deps.currentRuntime.assertActive(runtime); + + const url = constructPreviewURL({ + port, + sandboxId: sandboxName, + effectiveId: sandboxName, + hostname: options.hostname, + token, + normalizeId: this.deps.getNormalizeID() + }); + + outcome = 'success'; + + return { + url, + port, + name: options.name + }; + } catch (error) { + caughtError = error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + logCanonicalEvent(this.deps.logger, { + event: 'port.expose', + outcome, + port, + durationMs: Date.now() - exposeStartTime, + name: options.name, + hostname: options.hostname, + error: caughtError + }); + } + } + + async unexposePort(port: number): Promise { + const unexposeStartTime = Date.now(); + let outcome: 'success' | 'error' = 'error'; + let caughtError: Error | undefined; + try { + if (!validatePort(port)) { + throw new SandboxSecurityError( + `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` + ); + } + + await this.deps.storage.transaction(async (txn) => { + const tokens = await readPortTokens(txn); + if (tokens[port.toString()]) { + delete tokens[port.toString()]; + await txn.put(PORT_TOKENS_STORAGE_KEY, tokens); + } + + const activations = await readActivePreviewPorts(txn); + if (activations[port.toString()]) { + delete activations[port.toString()]; + await writeActivePreviewPorts(activations, txn); + } + }); + + outcome = 'success'; + } catch (error) { + caughtError = error instanceof Error ? error : new Error(String(error)); + throw error; + } finally { + logCanonicalEvent(this.deps.logger, { + event: 'port.unexpose', + outcome, + port, + durationMs: Date.now() - unexposeStartTime, + error: caughtError + }); + } + } + + async getExposedPorts( + hostname: string + ): Promise> { + const sandboxName = this.deps.getSandboxName(); + if (!sandboxName) { + throw new Error( + 'Sandbox name not available. Ensure sandbox is accessed through getSandbox()' + ); + } + + const activePorts = await this.getCurrentPreviewPorts(); + return activePorts.map(({ port, entry }) => ({ + url: constructPreviewURL({ + port, + sandboxId: sandboxName, + effectiveId: sandboxName, + hostname, + token: entry.token, + normalizeId: this.deps.getNormalizeID() + }), + port, + status: 'active' as const + })); + } + + async isPortExposed(port: number): Promise { + if (!validatePort(port)) { + return false; + } + + const activePorts = await this.getCurrentPreviewPorts(); + return activePorts.some((activePort) => activePort.port === port); + } + + async validatePortToken(port: number, token: string): Promise { + const tokens = await readPortTokens(this.deps.storage); + const entry = tokens[port.toString()]; + if (!entry) { + return false; + } + + return previewTokensMatch(entry.token, token); + } + + async proxyPreviewRequest(request: Request): Promise { + const target = readPreviewProxyMetadata(request); + if (!target) { + return this.invalidPreviewTokenResponse(); + } + + const { port, token, sandboxId } = target; + const proxyRequest = buildPreviewProxyRequest(request, { + port, + sandboxId, + sandboxName: this.deps.getSandboxName() + }); + + const validation = await this.validatePreviewURLForRuntime(port, token); + if (validation.status === 'invalid') { + return this.invalidPreviewTokenResponse(); + } + + if (validation.status === 'stale') { + this.deps.logger.warn('Stale preview URL blocked', { + port, + sandboxId, + containerStatus: validation.containerStatus, + reason: validation.reason, + method: request.method + }); + return this.stalePreviewURLResponse(); + } + + return await this.fetchPreviewIfRunning( + proxyRequest, + port, + validation.runtime + ); + } + + private invalidPreviewTokenResponse(): Response { + return new Response( + JSON.stringify({ + error: 'Access denied: Invalid token or port not exposed', + code: 'INVALID_TOKEN' + }), + { + status: 404, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + private stalePreviewURLResponse(): Response { + return new Response( + JSON.stringify({ + error: 'Preview URL is stale because the sandbox runtime is not active', + code: 'STALE_PREVIEW_URL' + }), + { + status: 410, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + private async fetchPreviewIfRunning( + request: Request, + port: number, + runtime: RuntimeIdentity + ): Promise { + const container = this.deps.getForwardingContainer(); + const state = await this.deps.getContainerState(); + + if (!container?.running || state.status !== 'healthy') { + return this.stalePreviewURLResponse(); + } + + if (!(await this.deps.currentRuntime.isActive(runtime))) { + return this.stalePreviewURLResponse(); + } + + const tcpPort = container.getTcpPort(port); + + const result = await forwardPreviewRequest(tcpPort, request, { + beginForward: () => this.deps.beginForward(), + renewActivity: () => this.deps.renewActivity() + }); + + if (result.status === 'network-lost') { + if (!(await this.deps.currentRuntime.isActive(runtime))) { + return this.stalePreviewURLResponse(); + } + + return new Response('Container suddenly disconnected, try again', { + status: 500 + }); + } + + return result.response; + } + + private async validatePreviewURLForRuntime( + port: number, + token: string + ): Promise { + const snapshot = await this.readRuntimeSnapshot(); + const entry = snapshot.tokens[port.toString()]; + if (!entry) { + return { status: 'invalid' }; + } + + const tokenMatches = previewTokensMatch(entry.token, token); + if (!tokenMatches) { + return { status: 'invalid' }; + } + + const availability = this.getRuntimeAvailability(snapshot); + if (availability.status === 'stale') { + return availability; + } + + const activation = snapshot.activations[port.toString()]; + if (!activation) { + return { + status: 'stale', + reason: 'missing-activation', + containerStatus: snapshot.containerStatus + }; + } + + if (!availability.runtime.owns(activation)) { + return { + status: 'stale', + reason: 'runtime-mismatch', + containerStatus: snapshot.containerStatus + }; + } + + const activationTokenMatches = previewTokensMatch(activation.token, token); + if (!activationTokenMatches) { + this.deps.logger.warn('Preview URL activation token mismatch', { + port, + runtimeIdentityID: availability.runtime.id + }); + return { + status: 'stale', + reason: 'token-mismatch', + containerStatus: snapshot.containerStatus + }; + } + + return { status: 'active', runtime: availability.runtime }; + } + + private async getCurrentPreviewPorts(): Promise { + const snapshot = await this.readRuntimeSnapshot(); + const availability = this.getRuntimeAvailability(snapshot); + if (availability.status === 'stale') { + return []; + } + + const activePorts: CurrentPreviewPort[] = []; + + for (const [portKey, activation] of Object.entries(snapshot.activations)) { + const port = Number.parseInt(portKey, 10); + const entry = snapshot.tokens[portKey]; + if (!entry || !Number.isInteger(port) || !validatePort(port)) { + continue; + } + + if (!availability.runtime.owns(activation)) { + continue; + } + + if (!previewTokensMatch(entry.token, activation.token)) { + continue; + } + + activePorts.push({ port, entry }); + } + + return activePorts.sort((a, b) => a.port - b.port); + } + + private async readRuntimeSnapshot(): Promise { + const containerState = await this.deps.getContainerState(); + const containerRunning = + this.deps.getForwardingContainer()?.running === true; + const { tokens, activations, runtime } = + await this.deps.storage.transaction(async (txn) => { + const [previewState, runtime] = await Promise.all([ + readPreviewState(txn), + this.deps.currentRuntime.getStored(txn) + ]); + return { ...previewState, runtime }; + }); + + return { + containerStatus: containerState.status, + containerRunning, + tokens, + activations, + runtime + }; + } + + private getRuntimeAvailability( + snapshot: PreviewRuntimeSnapshot + ): PreviewRuntimeAvailability { + if (snapshot.containerStatus !== 'healthy') { + return { + status: 'stale', + reason: 'runtime-not-healthy', + containerStatus: snapshot.containerStatus + }; + } + + if (!snapshot.containerRunning) { + return { + status: 'stale', + reason: 'runtime-not-running', + containerStatus: snapshot.containerStatus + }; + } + + if (!snapshot.runtime) { + return { + status: 'stale', + reason: 'missing-runtime-id', + containerStatus: snapshot.containerStatus + }; + } + + return { status: 'active', runtime: snapshot.runtime }; + } +} diff --git a/packages/sandbox/src/preview/state.ts b/packages/sandbox/src/preview/state.ts new file mode 100644 index 000000000..0a96fa522 --- /dev/null +++ b/packages/sandbox/src/preview/state.ts @@ -0,0 +1,89 @@ +import type { RuntimeScoped } from '../current-runtime-identity'; + +/** + * Persisted record for a single exposed port. `token` authorizes preview + * URL requests; `name` is the optional friendly name the caller passed to + * `exposePort()` and is preserved across container restarts. + */ +export type PortTokenEntry = { + token: string; + name?: string; +}; + +export type PreviewPortActivation = RuntimeScoped<{ + token: string; +}>; + +export type PreviewPortActivations = Record; + +export type CurrentPreviewPort = { + port: number; + entry: PortTokenEntry; +}; + +export type PreviewStateStorage = Pick< + DurableObjectStorage | DurableObjectTransaction, + 'get' | 'put' | 'delete' +>; + +export const PORT_TOKENS_STORAGE_KEY = 'portTokens'; +export const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = 'activePreviewPorts'; + +/** + * Read the `portTokens` map from DO storage, normalizing the legacy + * string-valued format (just a token) to the current object format + * ({ token, name? }). The legacy format predates port-name persistence and + * can appear on any DO whose storage was written before that change. + */ +export async function readPortTokens( + storage: PreviewStateStorage +): Promise> { + const raw = + (await storage.get>( + PORT_TOKENS_STORAGE_KEY + )) ?? {}; + const normalized: Record = {}; + for (const [port, value] of Object.entries(raw)) { + normalized[port] = typeof value === 'string' ? { token: value } : value; + } + return normalized; +} + +export async function readActivePreviewPorts( + storage: PreviewStateStorage +): Promise { + return ( + (await storage.get( + ACTIVE_PREVIEW_PORTS_STORAGE_KEY + )) ?? {} + ); +} + +export async function writeActivePreviewPorts( + activations: PreviewPortActivations, + storage: PreviewStateStorage +): Promise { + if (Object.keys(activations).length === 0) { + await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY); + return; + } + + await storage.put(ACTIVE_PREVIEW_PORTS_STORAGE_KEY, activations); +} + +export async function readPreviewState(storage: PreviewStateStorage): Promise<{ + tokens: Record; + activations: PreviewPortActivations; +}> { + const [tokens, activations] = await Promise.all([ + readPortTokens(storage), + readActivePreviewPorts(storage) + ]); + return { tokens, activations }; +} + +export async function clearActivePreviewPorts( + storage: PreviewStateStorage +): Promise { + await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY); +} diff --git a/packages/sandbox/src/preview/token.ts b/packages/sandbox/src/preview/token.ts new file mode 100644 index 000000000..1a1f59d19 --- /dev/null +++ b/packages/sandbox/src/preview/token.ts @@ -0,0 +1,49 @@ +import { SandboxSecurityError } from '../security'; + +const CUSTOM_TOKEN_PATTERN = /^[a-z0-9_]+$/; + +export function assertValidCustomPreviewToken(token: string): void { + if (token.length === 0) { + throw new SandboxSecurityError(`Custom token cannot be empty.`); + } + + if (token.length > 16) { + throw new SandboxSecurityError( + `Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.` + ); + } + + if (!CUSTOM_TOKEN_PATTERN.test(token)) { + throw new SandboxSecurityError( + `Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.` + ); + } +} + +export function generatePreviewToken(): string { + const array = new Uint8Array(12); + crypto.getRandomValues(array); + + const base64 = btoa(String.fromCharCode(...array)); + return base64 + .replace(/\+/g, '_') + .replace(/\//g, '_') + .replace(/=/g, '') + .toLowerCase(); +} + +export function previewTokensMatch(expected: string, actual: string): boolean { + const encoder = new TextEncoder(); + const a = encoder.encode(expected); + const b = encoder.encode(actual); + + try { + return ( + crypto.subtle as SubtleCrypto & { + timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean; + } + ).timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/packages/sandbox/src/preview-url.ts b/packages/sandbox/src/preview/url.ts similarity index 100% rename from packages/sandbox/src/preview-url.ts rename to packages/sandbox/src/preview/url.ts diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index e93fc6f05..164c5a89d 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -1,24 +1,12 @@ import { createLogger, TraceContext } from '@repo/shared'; -import { - PREVIEW_PROXY_HEADER, - PREVIEW_PROXY_HEADERS, - PREVIEW_PROXY_PORT_HEADER, - PREVIEW_PROXY_SANDBOX_ID_HEADER, - PREVIEW_PROXY_TOKEN_HEADER -} from './preview-proxy-protocol'; +import { withPreviewProxyMetadata } from './preview/protocol'; +import { parsePreviewRoute } from './preview/route'; import { getSandbox, type Sandbox } from './sandbox'; -import { sanitizeSandboxId, validatePort } from './security'; export interface SandboxEnv = Sandbox> { Sandbox: DurableObjectNamespace; } -interface RouteInfo { - port: number; - sandboxId: string; - token: string; -} - function createProxyLogger(request: Request) { const traceId = TraceContext.fromHeaders(request.headers) || TraceContext.generate(); @@ -35,7 +23,7 @@ export async function proxyToSandbox< >(request: Request, env: E): Promise { try { const url = new URL(request.url); - const routeInfo = extractSandboxRoute(url); + const routeInfo = parsePreviewRoute(url); if (!routeInfo) { return null; // Not a request to an exposed container port @@ -45,17 +33,9 @@ export async function proxyToSandbox< // Preview URLs always use normalized (lowercase) IDs const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true }); - const headers = new Headers(request.headers); - for (const header of PREVIEW_PROXY_HEADERS) { - headers.delete(header); - } - headers.set(PREVIEW_PROXY_HEADER, '1'); - headers.set(PREVIEW_PROXY_PORT_HEADER, port.toString()); - headers.set(PREVIEW_PROXY_TOKEN_HEADER, token); - headers.set(PREVIEW_PROXY_SANDBOX_ID_HEADER, sandboxId); - - const previewRequest = new Request(request, { headers }); - return await sandbox.fetch(previewRequest); + return await sandbox.fetch( + withPreviewProxyMetadata(request, { port, token, sandboxId }) + ); } catch (error) { const logger = createProxyLogger(request); logger.error( @@ -65,65 +45,3 @@ export async function proxyToSandbox< return new Response('Proxy routing error', { status: 500 }); } } - -function extractSandboxRoute(url: URL): RouteInfo | null { - // URL format: {port}-{sandboxId}-{token}.{domain} - // Tokens are [a-z0-9_]+, so we split at the last hyphen to handle sandboxIds with hyphens (UUIDs) - const dotIndex = url.hostname.indexOf('.'); - if (dotIndex === -1) { - return null; - } - - const subdomain = url.hostname.slice(0, dotIndex); - - // Extract port (digits at start followed by hyphen) - const firstHyphen = subdomain.indexOf('-'); - if (firstHyphen === -1) { - return null; - } - - const portStr = subdomain.slice(0, firstHyphen); - if (!/^\d{4,5}$/.test(portStr)) { - return null; - } - - const port = parseInt(portStr, 10); - if (!validatePort(port)) { - return null; - } - - // Extract token (last hyphen-delimited segment) and sandboxId (everything between port and token) - const rest = subdomain.slice(firstHyphen + 1); - const lastHyphen = rest.lastIndexOf('-'); - if (lastHyphen === -1) { - return null; - } - - const sandboxId = rest.slice(0, lastHyphen); - const token = rest.slice(lastHyphen + 1); - - // No hyphens in tokens: URL is {port}-{sandboxId}-{token}.{domain} - // We split at the LAST hyphen, so hyphens in tokens would be ambiguous. - // The SDK issues tokens up to 16 chars; 63 is the DNS label component limit. - if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) { - return null; - } - - // Validate and sanitize sandboxId - if (sandboxId.length === 0 || sandboxId.length > 63) { - return null; - } - - let sanitizedSandboxId: string; - try { - sanitizedSandboxId = sanitizeSandboxId(sandboxId); - } catch { - return null; - } - - return { - port, - sandboxId: sanitizedSandboxId, - token - }; -} diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 5a3247e4d..16a2128ef 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -67,13 +67,11 @@ import { import { ContainerControlClient } from './container-control'; import { CurrentRuntimeIdentity, - type RuntimeIdentity, - type RuntimeScoped + type RuntimeIdentity } from './current-runtime-identity'; import type { ErrorResponse } from './errors'; import { ContainerUnavailableError, - CustomDomainRequiredError, ErrorCode, OperationInterruptedError, ProcessExitedBeforeReadyError, @@ -85,18 +83,11 @@ import { SandboxExtension } from './extensions'; import { collectFile, streamFile } from './file-stream'; import { LocalMountSyncManager } from './local-mount-sync'; import { isPlatformTransientError } from './platform-errors'; +import { isPreviewProxyRequest } from './preview/protocol'; import { - forwardPreviewRequest, - type PreviewTCPPort -} from './preview-forwarding'; -import { - PREVIEW_PROXY_HEADER, - PREVIEW_PROXY_HEADERS, - PREVIEW_PROXY_PORT_HEADER, - PREVIEW_PROXY_SANDBOX_ID_HEADER, - PREVIEW_PROXY_TOKEN_HEADER -} from './preview-proxy-protocol'; -import { isLocalhostPattern } from './preview-url'; + type PreviewForwardingContainer, + PreviewService +} from './preview/service'; import { createSandboxProcessPromise, resolveStdinForRpc, @@ -160,50 +151,6 @@ type ExecuteResponse = Awaited< ReturnType >; -/** - * Persisted record for a single exposed port. `token` authorizes preview - * URL requests; `name` is the optional friendly name the caller passed to - * `exposePort()` and is preserved across container restarts. - */ -type PortTokenEntry = { - token: string; - name?: string; -}; - -type PreviewURLRuntimeValidation = - | { status: 'invalid' } - | { - status: 'stale'; - reason: - | 'runtime-not-healthy' - | 'runtime-not-running' - | 'missing-runtime-id' - | 'missing-activation' - | 'runtime-mismatch' - | 'token-mismatch'; - containerStatus?: string; - } - | { status: 'active'; runtime: RuntimeIdentity }; - -type PreviewPortActivation = RuntimeScoped<{ - token: string; -}>; - -type PreviewPortActivations = Record; - -type CurrentPreviewPort = { - port: number; - entry: PortTokenEntry; -}; - -type PreviewStateStorage = Pick< - DurableObjectStorage | DurableObjectTransaction, - 'get' | 'put' | 'delete' ->; - -const PORT_TOKENS_STORAGE_KEY = 'portTokens'; -const ACTIVE_PREVIEW_PORTS_STORAGE_KEY = 'activePreviewPorts'; - type SandboxConfiguration = { sandboxName?: { name: string; @@ -245,10 +192,7 @@ type EgressContainerState = DurableObjectState<{}> & { }; type PreviewForwardingContainerState = DurableObjectState<{}> & { - container?: { - running: boolean; - getTcpPort(port: number): PreviewTCPPort; - }; + container?: PreviewForwardingContainer; }; type PreviewForwardingLifecycleState = { @@ -967,6 +911,7 @@ export class Sandbox extends Container implements ISandbox { private currentRuntime: CurrentRuntimeIdentity; private currentLifetime: CurrentSandboxLifetime; private backupService: BackupService; + private previewService: PreviewService; private r2AccessKeyId: string | null = null; private r2SecretAccessKey: string | null = null; @@ -1163,6 +1108,18 @@ export class Sandbox extends Container implements ISandbox { ); this.client = this.createClient(); + this.previewService = new PreviewService({ + storage: this.ctx.storage, + logger: this.logger, + currentRuntime: this.currentRuntime, + getContainerState: () => this.getState(), + getForwardingContainer: () => this.getPreviewForwardingContainer(), + ensureRuntimeActiveForPreview: () => this.ensureRuntimeActiveForPreview(), + getSandboxName: () => this.sandboxName, + getNormalizeID: () => this.normalizeId, + beginForward: () => this.beginPreviewForward(), + renewActivity: () => this.renewActivityTimeout() + }); this.ctx.blockConcurrencyWhile(async () => { this.sandboxName = @@ -2464,8 +2421,7 @@ export class Sandbox extends Container implements ISandbox { // teardown work. Concurrent preview traffic should observe missing // auth or runtime state and fail from DO-owned state without reaching // the container. - await this.ctx.storage.delete(PORT_TOKENS_STORAGE_KEY); - await this.clearActivePreviewPorts(); + await this.previewService.clearPreviewState(); await this.currentLifetime.rotate(); await this.currentRuntime.clear(); @@ -2577,69 +2533,10 @@ export class Sandbox extends Container implements ISandbox { signal?: Parameters['stop']>[0] ): Promise { await this.currentRuntime.clear(); - await this.clearActivePreviewPorts(); + await this.previewService.clearActivePreviewPorts(); await super.stop(signal); } - /** - * Read the `portTokens` map from DO storage, normalizing the legacy - * string-valued format (just a token) to the current object format - * ({ token, name? }). The legacy format predates port-name persistence and - * can appear on any DO whose storage was written before that change. - */ - private async readPortTokens( - storage: PreviewStateStorage = this.ctx.storage - ): Promise> { - const raw = - (await storage.get>( - PORT_TOKENS_STORAGE_KEY - )) ?? {}; - const normalized: Record = {}; - for (const [port, value] of Object.entries(raw)) { - normalized[port] = typeof value === 'string' ? { token: value } : value; - } - return normalized; - } - - private async readActivePreviewPorts( - storage: PreviewStateStorage = this.ctx.storage - ): Promise { - return ( - (await storage.get( - ACTIVE_PREVIEW_PORTS_STORAGE_KEY - )) ?? {} - ); - } - - private async writeActivePreviewPorts( - activations: PreviewPortActivations, - storage: PreviewStateStorage = this.ctx.storage - ): Promise { - if (Object.keys(activations).length === 0) { - await storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY); - return; - } - - await storage.put(ACTIVE_PREVIEW_PORTS_STORAGE_KEY, activations); - } - - private async readPreviewState( - storage: PreviewStateStorage = this.ctx.storage - ): Promise<{ - tokens: Record; - activations: PreviewPortActivations; - }> { - const [tokens, activations] = await Promise.all([ - this.readPortTokens(storage), - this.readActivePreviewPorts(storage) - ]); - return { tokens, activations }; - } - - private async clearActivePreviewPorts(): Promise { - await this.ctx.storage.delete(ACTIVE_PREVIEW_PORTS_STORAGE_KEY); - } - /** * Check if the container version matches the SDK version * Logs a warning if there's a mismatch @@ -2689,7 +2586,7 @@ export class Sandbox extends Container implements ISandbox { this.logger.debug('Sandbox stopped'); await this.currentRuntime.clear(); - await this.clearActivePreviewPorts(); + await this.previewService.clearActivePreviewPorts(); try { this.ensureTunnelsBuilt(); @@ -3047,42 +2944,6 @@ export class Sandbox extends Container implements ISandbox { } } - private isPreviewProxyRequest(request: Request): boolean { - // These headers are internal control metadata added by proxyToSandbox() - // before the request enters this Durable Object. - return request.headers.get(PREVIEW_PROXY_HEADER) === '1'; - } - - private invalidPreviewTokenResponse(): Response { - return new Response( - JSON.stringify({ - error: 'Access denied: Invalid token or port not exposed', - code: 'INVALID_TOKEN' - }), - { - status: 404, - headers: { - 'Content-Type': 'application/json' - } - } - ); - } - - private stalePreviewURLResponse(): Response { - return new Response( - JSON.stringify({ - error: 'Preview URL is stale because the sandbox runtime is not active', - code: 'STALE_PREVIEW_URL' - }), - { - status: 410, - headers: { - 'Content-Type': 'application/json' - } - } - ); - } - private getPreviewForwardingContainer(): PreviewForwardingContainerState['container'] { return (this.ctx as PreviewForwardingContainerState).container; } @@ -3108,123 +2969,6 @@ export class Sandbox extends Container implements ISandbox { }; } - private async fetchPreviewIfRunning( - request: Request, - port: number, - runtime: RuntimeIdentity - ): Promise { - const container = this.getPreviewForwardingContainer(); - const state = await this.getState(); - - if (!container?.running || state.status !== 'healthy') { - return this.stalePreviewURLResponse(); - } - - if (!(await this.currentRuntime.isActive(runtime))) { - return this.stalePreviewURLResponse(); - } - - const tcpPort = container.getTcpPort(port); - - // Keep dispatch adjacent to the final runtime check above. Stale preview - // traffic must not observe an active runtime and then reach a different - // runtime through another async interleaving. - const result = await forwardPreviewRequest(tcpPort, request, { - beginForward: () => this.beginPreviewForward(), - renewActivity: () => this.renewActivityTimeout() - }); - - if (result.status === 'network-lost') { - if (!(await this.currentRuntime.isActive(runtime))) { - return this.stalePreviewURLResponse(); - } - - return new Response('Container suddenly disconnected, try again', { - status: 500 - }); - } - - return result.response; - } - - private buildPreviewProxyRequest( - request: Request, - port: number, - sandboxId: string - ): Request { - const url = new URL(request.url); - const proxyUrl = `http://localhost:${port}${url.pathname}${url.search}`; - const headers = new Headers(request.headers); - for (const header of PREVIEW_PROXY_HEADERS) { - headers.delete(header); - } - headers.set('X-Original-URL', request.url); - headers.set('X-Forwarded-Host', url.hostname); - headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')); - headers.set('X-Sandbox-Name', this.sandboxName ?? sandboxId); - - const upgradeHeader = request.headers.get('Upgrade'); - if (upgradeHeader?.toLowerCase() === 'websocket') { - // WebSocket upgrade requests keep the original Request object so the - // Workers runtime preserves upgrade semantics. Preview forwarding routes - // by the explicit port argument, while the request URL still provides - // the path and query. - return new Request(request, { - headers, - redirect: 'manual' - }); - } - - return new Request(proxyUrl, { - method: request.method, - headers, - body: request.body, - // @ts-expect-error - duplex required for body streaming in modern runtimes - duplex: 'half', - redirect: 'manual' - }); - } - - private async proxyPreviewRequest(request: Request): Promise { - const portValue = request.headers.get(PREVIEW_PROXY_PORT_HEADER); - const token = request.headers.get(PREVIEW_PROXY_TOKEN_HEADER); - const sandboxId = request.headers.get(PREVIEW_PROXY_SANDBOX_ID_HEADER); - const port = - portValue === null ? Number.NaN : Number.parseInt(portValue, 10); - - if (!Number.isFinite(port) || !validatePort(port) || !token || !sandboxId) { - return this.invalidPreviewTokenResponse(); - } - - const proxyRequest = this.buildPreviewProxyRequest( - request, - port, - sandboxId - ); - - const validation = await this.validatePreviewURLForRuntime(port, token); - if (validation.status === 'invalid') { - return this.invalidPreviewTokenResponse(); - } - - if (validation.status === 'stale') { - this.logger.warn('Stale preview URL blocked', { - port, - sandboxId, - containerStatus: validation.containerStatus, - reason: validation.reason, - method: request.method - }); - return this.stalePreviewURLResponse(); - } - - return await this.fetchPreviewIfRunning( - proxyRequest, - port, - validation.runtime - ); - } - // Override fetch to route internal container requests to appropriate ports override async fetch(request: Request): Promise { // Extract or generate trace ID from request @@ -3236,8 +2980,8 @@ export class Sandbox extends Container implements ISandbox { const url = new URL(request.url); - if (this.isPreviewProxyRequest(request)) { - return await this.proxyPreviewRequest(request); + if (isPreviewProxyRequest(request)) { + return await this.previewService.proxyPreviewRequest(request); } // Capture and store the sandbox name from the header if present @@ -4336,6 +4080,20 @@ export class Sandbox extends Container implements ISandbox { }); } + private async ensureRuntimeActiveForPreview(): Promise { + await this.startAndWaitForPorts({ + ports: this.defaultPort, + cancellationOptions: { + instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS, + portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS, + waitInterval: this.containerTimeouts.waitIntervalMS + } + }); + + const runtime = await this.currentRuntime.get(); + return runtime ?? (await this.currentRuntime.markStarted()); + } + /** * Expose a port and get a preview URL for accessing services running in the sandbox * @@ -4369,118 +4127,7 @@ export class Sandbox extends Container implements ISandbox { port: number, options: { name?: string; hostname: string; token?: string } ) { - const exposeStartTime = Date.now(); - let outcome: 'success' | 'error' = 'error'; - let caughtError: Error | undefined; - try { - if (!validatePort(port)) { - throw new SandboxSecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` - ); - } - - // Check if hostname is workers.dev domain (doesn't support wildcard subdomains) - if (options.hostname.endsWith('.workers.dev')) { - const errorResponse: ErrorResponse = { - code: ErrorCode.CUSTOM_DOMAIN_REQUIRED, - message: `Port exposure requires a custom domain. .workers.dev domains do not support wildcard subdomains required for port proxying.`, - context: { originalError: options.hostname }, - httpStatus: 400, - timestamp: new Date().toISOString() - }; - throw new CustomDomainRequiredError(errorResponse); - } - - // We need the sandbox name to construct preview URLs - if (!this.sandboxName) { - throw new Error( - 'Sandbox name not available. Ensure sandbox is accessed through getSandbox()' - ); - } - - if (options.token !== undefined) { - this.validateCustomToken(options.token); - } - - const runtime = await this.ensureRuntimeActiveForPreview(); - await this.currentRuntime.assertActive(runtime); - - const token = await this.ctx.storage.transaction(async (txn) => { - const tokens = await this.readPortTokens(txn); - const existingEntry = tokens[port.toString()]; - const nextToken = - options.token ?? existingEntry?.token ?? this.generatePortToken(); - - // Allow re-exposing same port with same token, but reject if another port uses this token - const existingPort = Object.entries(tokens).find( - ([p, entry]) => entry.token === nextToken && p !== port.toString() - ); - if (existingPort) { - throw new SandboxSecurityError( - `Token '${nextToken}' is already in use by port ${existingPort[0]}. Please use a different token.` - ); - } - - const activations = await this.readActivePreviewPorts(txn); - - tokens[port.toString()] = { token: nextToken, name: options.name }; - activations[port.toString()] = runtime.scope({ token: nextToken }); - await Promise.all([ - txn.put(PORT_TOKENS_STORAGE_KEY, tokens), - this.writeActivePreviewPorts(activations, txn) - ]); - - return nextToken; - }); - - // If a concurrent lifecycle hook records a newer runtime identity after - // the storage writes, fail instead of returning a URL that is stale on - // arrival. The stale activation remains harmless because preview - // forwarding requires ownership by the current runtime identity. - await this.currentRuntime.assertActive(runtime); - - const url = this.constructPreviewUrl( - port, - this.sandboxName, - options.hostname, - token - ); - - outcome = 'success'; - - return { - url, - port, - name: options.name - }; - } catch (error) { - caughtError = error instanceof Error ? error : new Error(String(error)); - throw error; - } finally { - logCanonicalEvent(this.logger, { - event: 'port.expose', - outcome, - port, - durationMs: Date.now() - exposeStartTime, - name: options.name, - hostname: options.hostname, - error: caughtError - }); - } - } - - private async ensureRuntimeActiveForPreview(): Promise { - await this.startAndWaitForPorts({ - ports: this.defaultPort, - cancellationOptions: { - instanceGetTimeoutMS: this.containerTimeouts.instanceGetTimeoutMS, - portReadyTimeoutMS: this.containerTimeouts.portReadyTimeoutMS, - waitInterval: this.containerTimeouts.waitIntervalMS - } - }); - - const runtime = await this.currentRuntime.get(); - return runtime ?? (await this.currentRuntime.markStarted()); + return await this.previewService.exposePort(port, options); } /** @@ -4490,47 +4137,8 @@ export class Sandbox extends Container implements ISandbox { * still successful. The operation clears Durable Object-owned preview state * only and does not contact, probe, wake, or clean up the container runtime. */ - async unexposePort(port: number) { - const unexposeStartTime = Date.now(); - let outcome: 'success' | 'error' = 'error'; - let caughtError: Error | undefined; - try { - if (!validatePort(port)) { - throw new SandboxSecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` - ); - } - - // Storage is the source of truth for preview-URL auth and activation. - // Clearing DO-owned state is sufficient to revoke forwarding and does - // not need to contact the container runtime. - await this.ctx.storage.transaction(async (txn) => { - const tokens = await this.readPortTokens(txn); - if (tokens[port.toString()]) { - delete tokens[port.toString()]; - await txn.put(PORT_TOKENS_STORAGE_KEY, tokens); - } - - const activations = await this.readActivePreviewPorts(txn); - if (activations[port.toString()]) { - delete activations[port.toString()]; - await this.writeActivePreviewPorts(activations, txn); - } - }); - - outcome = 'success'; - } catch (error) { - caughtError = error instanceof Error ? error : new Error(String(error)); - throw error; - } finally { - logCanonicalEvent(this.logger, { - event: 'port.unexpose', - outcome, - port, - durationMs: Date.now() - unexposeStartTime, - error: caughtError - }); - } + async unexposePort(port: number): Promise { + return await this.previewService.unexposePort(port); } /** @@ -4538,24 +4146,26 @@ export class Sandbox extends Container implements ISandbox { * Durable authorization without current-runtime activation is omitted. */ async getExposedPorts(hostname: string) { - // We need the sandbox name to construct preview URLs - if (!this.sandboxName) { - throw new Error( - 'Sandbox name not available. Ensure sandbox is accessed through getSandbox()' - ); - } + return await this.previewService.getExposedPorts(hostname); + } - const activePorts = await this.getCurrentPreviewPorts(); - return activePorts.map(({ port, entry }) => ({ - url: this.constructPreviewUrl( - port, - this.sandboxName!, - hostname, - entry.token - ), - port, - status: 'active' as const - })); + /** + * Returns whether a port is currently preview-forwardable. + * This checks Durable Object-owned auth and runtime activation without + * contacting or waking the container. + */ + async isPortExposed(port: number): Promise { + return await this.previewService.isPortExposed(port); + } + + /** + * Checks durable preview URL authorization for a port/token pair. + * + * This does not check whether the port is activated for the current runtime + * and is not sufficient to decide whether preview traffic may forward. + */ + async validatePortToken(port: number, token: string): Promise { + return await this.previewService.validatePortToken(port, token); } /** @@ -4604,274 +4214,14 @@ export class Sandbox extends Container implements ISandbox { this.tunnelExitHandler = built.handleTunnelExit; } - /** - * Returns whether a port is currently preview-forwardable. - * This checks Durable Object-owned auth and runtime activation without - * contacting or waking the container. - */ - async isPortExposed(port: number): Promise { - if (!validatePort(port)) { - return false; - } - - const activePorts = await this.getCurrentPreviewPorts(); - return activePorts.some((activePort) => activePort.port === port); - } + // ============================================================================ + // Session Management - Advanced Use Cases + // ============================================================================ /** - * Checks durable preview URL authorization for a port/token pair. - * - * This does not check whether the port is activated for the current runtime - * and is not sufficient to decide whether preview traffic may forward. + * Create isolated execution session for advanced use cases + * Returns ExecutionSession with full sandbox API bound to specific session */ - async validatePortToken(port: number, token: string): Promise { - const tokens = await this.readPortTokens(); - const entry = tokens[port.toString()]; - if (!entry) { - return false; - } - - return this.previewTokensMatch(entry.token, token); - } - - private async validatePreviewURLForRuntime( - port: number, - token: string - ): Promise { - const containerState = await this.getState(); - const containerRunning = this.ctx.container?.running === true; - const { tokens, activations, runtime } = await this.ctx.storage.transaction( - async (txn) => { - const [previewState, runtime] = await Promise.all([ - this.readPreviewState(txn), - this.currentRuntime.getStored(txn) - ]); - return { ...previewState, runtime }; - } - ); - - const entry = tokens[port.toString()]; - if (!entry) { - return { status: 'invalid' }; - } - - const tokenMatches = this.previewTokensMatch(entry.token, token); - if (!tokenMatches) { - return { status: 'invalid' }; - } - - if (containerState.status !== 'healthy') { - return { - status: 'stale', - reason: 'runtime-not-healthy', - containerStatus: containerState.status - }; - } - - if (!containerRunning) { - return { - status: 'stale', - reason: 'runtime-not-running', - containerStatus: containerState.status - }; - } - - if (!runtime) { - return { - status: 'stale', - reason: 'missing-runtime-id', - containerStatus: containerState.status - }; - } - - const activation = activations[port.toString()]; - if (!activation) { - return { - status: 'stale', - reason: 'missing-activation', - containerStatus: containerState.status - }; - } - - if (!runtime.owns(activation)) { - return { - status: 'stale', - reason: 'runtime-mismatch', - containerStatus: containerState.status - }; - } - - const activationTokenMatches = this.previewTokensMatch( - activation.token, - token - ); - if (!activationTokenMatches) { - this.logger.warn('Preview URL activation token mismatch', { - port, - runtimeIdentityID: runtime.id - }); - return { - status: 'stale', - reason: 'token-mismatch', - containerStatus: containerState.status - }; - } - - return { status: 'active', runtime }; - } - - private async getCurrentPreviewPorts(): Promise { - const containerState = await this.getState(); - const containerRunning = this.ctx.container?.running === true; - const { tokens, activations, runtime } = await this.ctx.storage.transaction( - async (txn) => { - const [previewState, runtime] = await Promise.all([ - this.readPreviewState(txn), - this.currentRuntime.getStored(txn) - ]); - return { ...previewState, runtime }; - } - ); - - if (containerState.status !== 'healthy' || !containerRunning || !runtime) { - return []; - } - - const activePorts: CurrentPreviewPort[] = []; - - for (const [portKey, activation] of Object.entries(activations)) { - const port = Number.parseInt(portKey, 10); - const entry = tokens[portKey]; - if (!entry || !Number.isInteger(port) || !validatePort(port)) { - continue; - } - - if (!runtime.owns(activation)) { - continue; - } - - if (!this.previewTokensMatch(entry.token, activation.token)) { - continue; - } - - activePorts.push({ port, entry }); - } - - return activePorts.sort((a, b) => a.port - b.port); - } - - private previewTokensMatch(expected: string, actual: string): boolean { - const encoder = new TextEncoder(); - const a = encoder.encode(expected); - const b = encoder.encode(actual); - - try { - // Workers runtime extends SubtleCrypto with timingSafeEqual. - return ( - crypto.subtle as SubtleCrypto & { - timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean; - } - ).timingSafeEqual(a, b); - } catch { - return false; - } - } - - private validateCustomToken(token: string): void { - if (token.length === 0) { - throw new SandboxSecurityError(`Custom token cannot be empty.`); - } - - if (token.length > 16) { - throw new SandboxSecurityError( - `Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.` - ); - } - - if (!/^[a-z0-9_]+$/.test(token)) { - throw new SandboxSecurityError( - `Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.` - ); - } - } - - private generatePortToken(): string { - // Generate cryptographically secure 16-character token using Web Crypto API - // Available in Cloudflare Workers runtime - const array = new Uint8Array(12); // 12 bytes = 16 base64url chars (after padding removal) - crypto.getRandomValues(array); - - const base64 = btoa(String.fromCharCode(...array)); - return base64 - .replace(/\+/g, '_') - .replace(/\//g, '_') - .replace(/=/g, '') - .toLowerCase(); - } - - private constructPreviewUrl( - port: number, - sandboxId: string, - hostname: string, - token: string - ): string { - if (!validatePort(port)) { - throw new SandboxSecurityError( - `Invalid port number: ${port}. Must be 1024-65535, excluding 3000 (sandbox control plane).` - ); - } - - // Hostnames are case-insensitive, routing requests to wrong DO instance when keys contain uppercase letters - const effectiveId = this.sandboxName || sandboxId; - const hasUppercase = /[A-Z]/.test(effectiveId); - if (!this.normalizeId && hasUppercase) { - throw new SandboxSecurityError( - `Preview URLs require lowercase sandbox IDs. Your ID "${effectiveId}" contains uppercase letters.\n\n` + - `To fix this:\n` + - `1. Create a new sandbox with: getSandbox(ns, "${effectiveId}", { normalizeId: true })\n` + - `2. This will create a sandbox with ID: "${effectiveId.toLowerCase()}"\n\n` + - `Note: Due to DNS case-insensitivity, IDs with uppercase letters cannot be used with preview URLs.` - ); - } - - const sanitizedSandboxId = sanitizeSandboxId(sandboxId).toLowerCase(); - - const isLocalhost = isLocalhostPattern(hostname); - - if (isLocalhost) { - const [host, portStr] = hostname.split(':'); - const mainPort = portStr || '80'; - - try { - const baseUrl = new URL(`http://${host}:${mainPort}`); - const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${host}`; - baseUrl.hostname = subdomainHost; - - return baseUrl.toString(); - } catch (error) { - throw new SandboxSecurityError( - `Failed to construct preview URL: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } - } - - try { - const baseUrl = new URL(`https://${hostname}`); - const subdomainHost = `${port}-${sanitizedSandboxId}-${token}.${hostname}`; - baseUrl.hostname = subdomainHost; - - return baseUrl.toString(); - } catch (error) { - throw new SandboxSecurityError( - `Failed to construct preview URL: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } - } - async createSession(options?: SessionOptions): Promise { const sessionId = options?.id || `session-${Date.now()}`; diff --git a/packages/sandbox/tests/preview-forwarding.test.ts b/packages/sandbox/tests/preview-forwarding.test.ts index d338d5aa1..1da5a6e60 100644 --- a/packages/sandbox/tests/preview-forwarding.test.ts +++ b/packages/sandbox/tests/preview-forwarding.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { forwardPreviewRequest, type PreviewForwardingLifecycle -} from '../src/preview-forwarding'; +} from '../src/preview/forwarding'; function createLifecycle() { const settle = vi.fn(); diff --git a/packages/sandbox/tests/preview-protocol.test.ts b/packages/sandbox/tests/preview-protocol.test.ts new file mode 100644 index 000000000..9235c542b --- /dev/null +++ b/packages/sandbox/tests/preview-protocol.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + readPreviewProxyMetadata, + withPreviewProxyMetadata +} from '../src/preview/protocol'; + +describe('preview proxy protocol helpers', () => { + it('writes preview proxy metadata and strips stale internal headers', () => { + const request = new Request('https://8080-sandbox-token.example.com/path', { + headers: { + 'x-sandbox-preview-proxy': '0', + 'x-sandbox-preview-port': '9000', + 'x-sandbox-preview-token': 'stale', + 'x-sandbox-preview-sandbox-id': 'stale-sandbox', + 'x-custom-header': 'preserved' + } + }); + + const forwarded = withPreviewProxyMetadata(request, { + port: 8080, + token: 'token', + sandboxId: 'sandbox' + }); + + expect(forwarded.headers.get('x-sandbox-preview-proxy')).toBe('1'); + expect(forwarded.headers.get('x-sandbox-preview-port')).toBe('8080'); + expect(forwarded.headers.get('x-sandbox-preview-token')).toBe('token'); + expect(forwarded.headers.get('x-sandbox-preview-sandbox-id')).toBe( + 'sandbox' + ); + expect(forwarded.headers.get('x-custom-header')).toBe('preserved'); + }); + + it('reads valid preview proxy metadata', () => { + const request = withPreviewProxyMetadata( + new Request('https://example.com/path'), + { + port: 8080, + token: 'token', + sandboxId: 'sandbox' + } + ); + + expect(readPreviewProxyMetadata(request)).toEqual({ + port: 8080, + token: 'token', + sandboxId: 'sandbox' + }); + }); + + it('rejects missing and invalid preview proxy metadata', () => { + expect(readPreviewProxyMetadata(new Request('https://example.com'))).toBe( + null + ); + + const invalidPort = new Request('https://example.com', { + headers: { + 'x-sandbox-preview-proxy': '1', + 'x-sandbox-preview-port': '3000', + 'x-sandbox-preview-token': 'token', + 'x-sandbox-preview-sandbox-id': 'sandbox' + } + }); + + expect(readPreviewProxyMetadata(invalidPort)).toBe(null); + }); +}); diff --git a/packages/sandbox/tests/preview-route.test.ts b/packages/sandbox/tests/preview-route.test.ts new file mode 100644 index 000000000..0f6101a89 --- /dev/null +++ b/packages/sandbox/tests/preview-route.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { constructPreviewURL, parsePreviewRoute } from '../src/preview/route'; + +describe('preview route helpers', () => { + it('parses preview URLs with hyphenated sandbox IDs', () => { + const route = parsePreviewRoute( + new URL('https://8080-test-sandbox-token123.example.com/api') + ); + + expect(route).toEqual({ + port: 8080, + sandboxId: 'test-sandbox', + token: 'token123' + }); + }); + + it('returns null for non-preview URLs', () => { + expect(parsePreviewRoute(new URL('https://example.com/api'))).toBeNull(); + expect( + parsePreviewRoute(new URL('https://3000-sandbox-token.example.com')) + ).toBeNull(); + }); + + it('constructs production preview URLs', () => { + expect( + constructPreviewURL({ + port: 8080, + sandboxId: 'test-sandbox', + effectiveId: 'test-sandbox', + hostname: 'example.com', + token: 'token123', + normalizeId: false + }) + ).toBe('https://8080-test-sandbox-token123.example.com/'); + }); + + it('constructs localhost preview URLs over HTTP', () => { + expect( + constructPreviewURL({ + port: 8080, + sandboxId: 'test-sandbox', + effectiveId: 'test-sandbox', + hostname: 'localhost:8787', + token: 'token123', + normalizeId: false + }) + ).toBe('http://8080-test-sandbox-token123.localhost:8787/'); + }); +}); diff --git a/packages/sandbox/tests/sandbox-destroy-lifetime.test.ts b/packages/sandbox/tests/sandbox-destroy-lifetime.test.ts index f6fba2c37..67e52b5a8 100644 --- a/packages/sandbox/tests/sandbox-destroy-lifetime.test.ts +++ b/packages/sandbox/tests/sandbox-destroy-lifetime.test.ts @@ -48,7 +48,11 @@ function createMockState() { calls.push({ method: 'delete', key }); values.delete(key); }), - list: vi.fn(async () => new Map()) + list: vi.fn(async () => new Map()), + transaction: vi.fn( + async (callback: (txn: DurableObjectStorage) => Promise) => + callback(storage) + ) } as unknown as DurableObjectStorage; const state = {