Skip to content
Merged
24 changes: 24 additions & 0 deletions .changeset/custom-port-tokens.md
Original file line number Diff line number Diff line change
@@ -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, -, _
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description mentions "stable preview URLs across deployments" but tokens are stored in Durable Object storage, which is ephemeral and cleared when the sandbox sleeps/restarts. This could mislead users about persistence guarantees.

Consider clarifying: "stable preview URLs within a session" or "while the sandbox remains active" to set accurate expectations.

});
// 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.
16 changes: 7 additions & 9 deletions packages/sandbox/src/clients/port-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
ExposePortRequest,
PortCloseResult,
PortExposeResult,
PortListResult,
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/sandbox/src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
85 changes: 76 additions & 9 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2095,7 +2095,34 @@ export class Sandbox<Env = unknown> extends Container<Env> 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 = {
Expand All @@ -2108,20 +2135,36 @@ export class Sandbox<Env = unknown> extends Container<Env> 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(
'Sandbox name not available. Ensure sandbox is accessed through getSandbox()'
);
}

// 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation happens after the container API call at line 2133. Move the entire token generation/validation block (lines 2147-2155) before the client.ports.exposePort() call to validate before side effects.

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<Record<string, string>>('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);

Expand Down Expand Up @@ -2221,7 +2264,6 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
(await this.ctx.storage.get<Record<string, string>>('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,
Expand All @@ -2230,8 +2272,33 @@ export class Sandbox<Env = unknown> extends Container<Env> 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 {
Expand Down
71 changes: 71 additions & 0 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions tests/e2e/process-readiness-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
4 changes: 2 additions & 2 deletions tests/e2e/test-worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
Loading