diff --git a/.changeset/custom-port-tokens.md b/.changeset/custom-port-tokens.md new file mode 100644 index 00000000..31aba6bf --- /dev/null +++ b/.changeset/custom-port-tokens.md @@ -0,0 +1,24 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add support for custom tokens in `exposePort()` to enable stable preview URLs across deployments. + +You can now pass a custom token when exposing ports to maintain consistent preview URLs between container restarts and deployments. This is useful for sharing URLs with users or maintaining stable references in production environments. + +```typescript +// With custom token - URL stays the same across restarts +const { url } = await sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'my-token-v1' // 1-16 chars: a-z, 0-9, -, _ +}); +// url: https://8080-sandbox-id-my-token-v1.example.com + +// Without token - generates random 16-char token (existing behavior) +const { url } = await sandbox.exposePort(8080, { + hostname: 'example.com' +}); +// url: https://8080-sandbox-id-abc123random4567.example.com +``` + +Custom tokens must be 1-16 characters and contain only lowercase letters, numbers, hyphens, and underscores to ensure URL compatibility. diff --git a/packages/sandbox/src/clients/port-client.ts b/packages/sandbox/src/clients/port-client.ts index 9aaa74c9..ce6265eb 100644 --- a/packages/sandbox/src/clients/port-client.ts +++ b/packages/sandbox/src/clients/port-client.ts @@ -1,4 +1,5 @@ import type { + ExposePortRequest, PortCloseResult, PortExposeResult, PortListResult, @@ -7,15 +8,12 @@ import type { import { BaseHttpClient } from './base-client'; // Re-export for convenience -export type { PortExposeResult, PortCloseResult, PortListResult }; - -/** - * Request interface for exposing ports - */ -export interface ExposePortRequest { - port: number; - name?: string; -} +export type { + ExposePortRequest, + PortExposeResult, + PortCloseResult, + PortListResult +}; /** * Request interface for unexposing ports diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index 602152d4..ae64ceb1 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -116,9 +116,9 @@ export async function proxyToSandbox< function extractSandboxRoute(url: URL): RouteInfo | null { // Parse subdomain pattern: port-sandboxId-token.domain (tokens mandatory) - // Token is always exactly 16 chars (generated by generatePortToken) + // Token can be 1-16 chars (SDK validates) or up to 63 chars (DNS limit for forward compatibility) const subdomainMatch = url.hostname.match( - /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]{16})\.(.+)$/ + /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]+)\.(.+)$/ ); if (!subdomainMatch) { @@ -142,8 +142,8 @@ function extractSandboxRoute(url: URL): RouteInfo | null { return null; } - // DNS subdomain length limit is 63 characters - if (sandboxId.length > 63) { + // DNS subdomain length limit is 63 characters (applies to each component) + if (sandboxId.length > 63 || token.length > 63) { return null; } diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 1b8d5923..627d4a73 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -2095,7 +2095,34 @@ export class Sandbox extends Container implements ISandbox { return this.client.files.exists(path, session); } - async exposePort(port: number, options: { name?: string; hostname: string }) { + /** + * Expose a port and get a preview URL for accessing services running in the sandbox + * + * @param port - Port number to expose (1024-65535) + * @param options - Configuration options + * @param options.hostname - Your Worker's domain name (required for preview URL construction) + * @param options.name - Optional friendly name for the port + * @param options.token - Optional custom token for the preview URL (1-16 characters: lowercase letters, numbers, hyphens, underscores) + * If not provided, a random 16-character token will be generated automatically + * @returns Preview URL information including the full URL, port number, and optional name + * + * @example + * // With auto-generated token + * const { url } = await sandbox.exposePort(8080, { hostname: 'example.com' }); + * // url: https://8080-sandbox-id-abc123random4567.example.com + * + * @example + * // With custom token for stable URLs across deployments + * const { url } = await sandbox.exposePort(8080, { + * hostname: 'example.com', + * token: 'my-token-v1' + * }); + * // url: https://8080-sandbox-id-my-token-v1.example.com + */ + async exposePort( + port: number, + options: { name?: string; hostname: string; token?: string } + ) { // Check if hostname is workers.dev domain (doesn't support wildcard subdomains) if (options.hostname.endsWith('.workers.dev')) { const errorResponse: ErrorResponse = { @@ -2108,9 +2135,6 @@ export class Sandbox extends Container implements ISandbox { throw new CustomDomainRequiredError(errorResponse); } - const sessionId = await this.ensureDefaultSession(); - await this.client.ports.exposePort(port, sessionId, options?.name); - // We need the sandbox name to construct preview URLs if (!this.sandboxName) { throw new Error( @@ -2118,10 +2142,29 @@ export class Sandbox extends Container implements ISandbox { ); } - // Generate and store token for this port (storage is protected by input gates) - const token = this.generatePortToken(); + let token: string; + if (options.token !== undefined) { + this.validateCustomToken(options.token); + token = options.token; + } else { + token = this.generatePortToken(); + } + + // Allow re-exposing same port with same token, but reject if another port uses this token const tokens = (await this.ctx.storage.get>('portTokens')) || {}; + const existingPort = Object.entries(tokens).find( + ([p, t]) => t === token && p !== port.toString() + ); + if (existingPort) { + throw new SecurityError( + `Token '${token}' is already in use by port ${existingPort[0]}. Please use a different token.` + ); + } + + const sessionId = await this.ensureDefaultSession(); + await this.client.ports.exposePort(port, sessionId, options?.name); + tokens[port.toString()] = token; await this.ctx.storage.put('portTokens', tokens); @@ -2221,7 +2264,6 @@ export class Sandbox extends Container implements ISandbox { (await this.ctx.storage.get>('portTokens')) || {}; const storedToken = tokens[port.toString()]; if (!storedToken) { - // This should not happen - all exposed ports must have tokens this.logger.error( 'Port is exposed but has no token - bug detected', undefined, @@ -2230,8 +2272,33 @@ export class Sandbox extends Container implements ISandbox { return false; } - // Constant-time comparison to prevent timing attacks - return storedToken === token; + if (storedToken.length !== token.length) { + return false; + } + + const encoder = new TextEncoder(); + const a = encoder.encode(storedToken); + const b = encoder.encode(token); + + return crypto.subtle.timingSafeEqual(a, b); + } + + private validateCustomToken(token: string): void { + if (token.length === 0) { + throw new SecurityError(`Custom token cannot be empty.`); + } + + if (token.length > 16) { + throw new SecurityError( + `Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.` + ); + } + + if (!/^[a-z0-9_-]+$/.test(token)) { + throw new SecurityError( + `Custom token must contain only lowercase letters (a-z), numbers (0-9), hyphens (-), and underscores (_). Invalid token provided.` + ); + } } private generatePortToken(): string { diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index 9272ae0a..c28d4505 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -892,6 +892,77 @@ describe('Sandbox - Automatic Session Management', () => { }); }); + describe('custom token validation', () => { + beforeEach(async () => { + await sandbox.setSandboxName('test-sandbox', false); + + vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({ + success: true, + port: 8080, + url: 'http://localhost:8080', + timestamp: new Date().toISOString() + }); + + vi.mocked(mockCtx.storage!.get).mockResolvedValue({} as any); + vi.mocked(mockCtx.storage!.put).mockResolvedValue(undefined); + }); + + it('should validate token format and length', async () => { + const result = await sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'abc-123_xyz' + }); + expect(result.url).toContain('abc-123_xyz'); + + await expect( + sandbox.exposePort(8080, { hostname: 'example.com', token: '' }) + ).rejects.toThrow('Custom token cannot be empty'); + + await expect( + sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'a1234567890123456' + }) + ).rejects.toThrow('Maximum 16 characters'); + + await expect( + sandbox.exposePort(8080, { hostname: 'example.com', token: 'ABC123' }) + ).rejects.toThrow('lowercase letters'); + }); + + it('should prevent token collision across different ports', async () => { + await sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'shared' + }); + + vi.mocked(mockCtx.storage!.get).mockResolvedValueOnce({ + '8080': 'shared' + } as any); + + await expect( + sandbox.exposePort(8081, { hostname: 'example.com', token: 'shared' }) + ).rejects.toThrow(/already in use by port 8080/); + }); + + it('should allow re-exposing same port with same token', async () => { + await sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'stable' + }); + + vi.mocked(mockCtx.storage!.get).mockResolvedValueOnce({ + '8080': 'stable' + } as any); + + const result = await sandbox.exposePort(8080, { + hostname: 'example.com', + token: 'stable' + }); + expect(result.url).toContain('stable'); + }); + }); + describe('sleepAfter configuration', () => { it('should call renewActivityTimeout when setSleepAfter is called', async () => { // Spy on renewActivityTimeout (inherited from Container) diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index 2889e16d..e20c04bf 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -459,4 +459,64 @@ console.log("Server listening on port 9092"); }, 90000 ); + + test.skipIf(skipPortExposureTests)( + 'should expose port with custom token for stable URL', + async () => { + const serverCode = ` +Bun.serve({ + port: 9093, + fetch() { + return new Response("token-test-ok"); + }, +}); + `.trim(); + + await fetch(`${workerUrl}/api/file/write`, { + method: 'POST', + headers, + body: JSON.stringify({ + path: '/workspace/token-server.js', + content: serverCode + }) + }); + + const startResponse = await fetch(`${workerUrl}/api/process/start`, { + method: 'POST', + headers, + body: JSON.stringify({ command: 'bun run /workspace/token-server.js' }) + }); + const { id: processId } = (await startResponse.json()) as Process; + + await fetch(`${workerUrl}/api/process/${processId}/waitForPort`, { + method: 'POST', + headers, + body: JSON.stringify({ port: 9093, timeout: 30000 }) + }); + + const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { + method: 'POST', + headers: portHeaders, + body: JSON.stringify({ port: 9093, token: 'my-stable-token' }) + }); + + expect(exposeResponse.status).toBe(200); + const { url } = (await exposeResponse.json()) as PortExposeResult; + expect(url).toContain('my-stable-token'); + + const apiResponse = await fetch(url); + expect(apiResponse.status).toBe(200); + expect(await apiResponse.text()).toBe('token-test-ok'); + + await fetch(`${workerUrl}/api/exposed-ports/9093`, { + method: 'DELETE', + headers: portHeaders + }); + await fetch(`${workerUrl}/api/process/${processId}`, { + method: 'DELETE', + headers + }); + }, + 90000 + ); }); diff --git a/tests/e2e/test-worker/index.ts b/tests/e2e/test-worker/index.ts index a9919e7f..fb24b273 100644 --- a/tests/e2e/test-worker/index.ts +++ b/tests/e2e/test-worker/index.ts @@ -703,11 +703,11 @@ console.log('Terminal server on port ' + port); } ); } - // Extract hostname from the request const hostname = url.hostname + (url.port ? `:${url.port}` : ''); const preview = await sandbox.exposePort(body.port, { name: body.name, - hostname: hostname + hostname: hostname, + token: body.token }); return new Response(JSON.stringify(preview), { headers: { 'Content-Type': 'application/json' }