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': minor
---

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.
12 changes: 10 additions & 2 deletions packages/sandbox/src/clients/port-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export type { PortExposeResult, PortCloseResult, PortListResult };
export interface ExposePortRequest {
port: number;
name?: string;
/**
* Custom token for the preview URL (optional)
* If not provided, a random 16-character token will be generated
* Must be 1-16 characters, containing only lowercase letters, numbers, hyphens, and underscores
*/
token?: string;
}

/**
Expand All @@ -33,14 +39,16 @@ export class PortClient extends BaseHttpClient {
* @param port - Port number to expose
* @param sessionId - The session ID for this operation
* @param name - Optional name for the port
* @param token - Optional custom token for the preview URL
*/
async exposePort(
port: number,
sessionId: string,
name?: string
name?: string,
token?: string
): Promise<PortExposeResult> {
try {
const data = { port, sessionId, name };
const data = { port, sessionId, name, token };

const response = await this.post<PortExposeResult>(
'/api/expose-port',
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 any length (1-63 chars) containing only [a-z0-9_-]
Copy link
Contributor

Choose a reason for hiding this comment

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

Comment says "1-63 chars" but validateCustomToken limits to 16 chars. The regex accepts up to 63 to be forward-compatible with DNS limits, which is fine, but the comment could be clearer: "Token can be 1-16 chars (SDK validates) or up to 63 chars (DNS limit)"

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
70 changes: 66 additions & 4 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2089,7 +2089,34 @@
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 @@ -2103,17 +2130,31 @@
}

const sessionId = await this.ensureDefaultSession();
await this.client.ports.exposePort(port, sessionId, options?.name);
await this.client.ports.exposePort(
port,
sessionId,
options?.name,
options?.token
);

// We need the sandbox name to construct preview URLs
if (!this.sandboxName) {
throw new Error(

Check failure on line 2142 in packages/sandbox/src/sandbox.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should generate random token when not provided

Error: Sandbox name not available. Ensure sandbox is accessed through getSandbox() ❯ Sandbox.exposePort src/sandbox.ts:2142:13 ❯ tests/sandbox.test.ts:975:22

Check failure on line 2142 in packages/sandbox/src/sandbox.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should allow tokens with hyphens and underscores

Error: Sandbox name not available. Ensure sandbox is accessed through getSandbox() ❯ Sandbox.exposePort src/sandbox.ts:2142:13 ❯ tests/sandbox.test.ts:965:22

Check failure on line 2142 in packages/sandbox/src/sandbox.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should accept valid custom tokens of various lengths

Error: Sandbox name not available. Ensure sandbox is accessed through getSandbox() ❯ Sandbox.exposePort src/sandbox.ts:2142:13 ❯ tests/sandbox.test.ts:908:23
'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();
// Use custom token or generate a random one
let token: string;
if (options.token) {
// Validate custom token
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();
}

// Store token for this port (storage is protected by input gates)
Copy link
Contributor

Choose a reason for hiding this comment

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

Security issue: Need to check if the custom token is already in use by another port before storing. Currently allows token collision which could let users access wrong ports.

Add before line 2157:

if (options.token) {
  const existingPort = Object.entries(tokens).find(
    ([p, t]) => t === options.token && p !== port.toString()
  );
  if (existingPort) {
    throw new SecurityError(
      `Token '${options.token}' is already in use by port ${existingPort[0]}.`
    );
  }
}

const tokens =
(await this.ctx.storage.get<Record<string, string>>('portTokens')) || {};
tokens[port.toString()] = token;
Expand Down Expand Up @@ -2228,6 +2269,27 @@
return storedToken === token;
}

private validateCustomToken(token: string): void {
// Token must be at least 1 character
if (token.length === 0) {
throw new SecurityError(`Custom token cannot be empty.`);
}

// Token must not exceed 16 characters
if (token.length > 16) {
throw new SecurityError(
`Custom token too long. Maximum 16 characters allowed. Received: ${token.length} characters.`
);
}

// Token must only contain lowercase alphanumeric, hyphens, and underscores (URL-safe)
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 {
// Generate cryptographically secure 16-character token using Web Crypto API
// Available in Cloudflare Workers runtime
Expand Down
33 changes: 33 additions & 0 deletions packages/sandbox/tests/port-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,39 @@ describe('PortClient', () => {
expect(result.url.startsWith('https://')).toBe(true);
});

it('should expose ports with custom tokens', async () => {
const customToken = 'my-custom-token';
const mockResponse: PortExposeResult = {
success: true,
port: 8080,
url: 'https://8080-sandbox-my-custom-token.example.com',
timestamp: '2023-01-01T00:00:00Z'
};

mockFetch.mockResolvedValue(
new Response(JSON.stringify(mockResponse), { status: 200 })
);

const result = await client.exposePort(
8080,
'session-custom',
'api',
customToken
);

expect(result.success).toBe(true);
expect(result.port).toBe(8080);
expect(result.url).toContain(customToken);

// Verify the token was sent in the request
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.stringContaining(customToken)
})
);
});

it('should expose API services on different ports', async () => {
const mockResponse: PortExposeResult = {
success: true,
Expand Down
100 changes: 100 additions & 0 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,4 +887,104 @@
).resolves.toBeUndefined();
});
});

describe('custom token validation', () => {
beforeEach(() => {
// Mock the exposePort client call
vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({
success: true,
port: 8080,
url: 'http://localhost:8080',
timestamp: new Date().toISOString()
});

// Mock storage for tokens
vi.mocked(mockCtx.storage!.get).mockResolvedValue({});
vi.mocked(mockCtx.storage!.put).mockResolvedValue(undefined);
});

it('should accept valid custom tokens of various lengths', async () => {
const shortToken = 'short';
const result1 = await sandbox.exposePort(8080, {
hostname: 'example.com',
token: shortToken
});

expect(result1.port).toBe(8080);
expect(result1.url).toContain(shortToken);

const maxLengthToken = 'a123456789012345'; // exactly 16 chars
const result2 = await sandbox.exposePort(8081, {
hostname: 'example.com',
token: maxLengthToken
});

expect(result2.port).toBe(8081);
expect(result2.url).toContain(maxLengthToken);
});

it('should reject empty tokens', async () => {
await expect(

Check failure on line 927 in packages/sandbox/tests/sandbox.test.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should reject empty tokens

AssertionError: expected [Function] to throw error including 'Custom token cannot be empty' but got 'Sandbox name not available. Ensure sa…' Expected: "Custom token cannot be empty" Received: "Sandbox name not available. Ensure sandbox is accessed through getSandbox()" ❯ Assertion.methodWrapper home/runner/work/sandbox-sdk/sandbox-sdk/packages/sandbox/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/deps_ssr/vitest___@vitest_expect___chai.js:1601:25 ❯ tests/sandbox.test.ts:927:7
sandbox.exposePort(8080, {
hostname: 'example.com',
token: ''
})
).rejects.toThrow('Custom token cannot be empty');
});

it('should reject tokens exceeding 16 character limit', async () => {
const tooLongToken = 'a1234567890123456'; // 17 chars, exceeds 16 limit
await expect(

Check failure on line 937 in packages/sandbox/tests/sandbox.test.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should reject tokens exceeding 16 character limit

AssertionError: expected [Function] to throw error including 'Custom token too long. Maximum 16 cha…' but got 'Sandbox name not available. Ensure sa…' Expected: "Custom token too long. Maximum 16 characters allowed" Received: "Sandbox name not available. Ensure sandbox is accessed through getSandbox()" ❯ Assertion.methodWrapper home/runner/work/sandbox-sdk/sandbox-sdk/packages/sandbox/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/deps_ssr/vitest___@vitest_expect___chai.js:1601:25 ❯ tests/sandbox.test.ts:937:7
sandbox.exposePort(8080, {
hostname: 'example.com',
token: tooLongToken
})
).rejects.toThrow('Custom token too long. Maximum 16 characters allowed');
});

it('should reject tokens with invalid characters', async () => {
await expect(

Check failure on line 946 in packages/sandbox/tests/sandbox.test.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should reject tokens with invalid characters

AssertionError: expected [Function] to throw error including 'Custom token must contain only lowerc…' but got 'Sandbox name not available. Ensure sa…' Expected: "Custom token must contain only lowercase letters" Received: "Sandbox name not available. Ensure sandbox is accessed through getSandbox()" ❯ Assertion.methodWrapper home/runner/work/sandbox-sdk/sandbox-sdk/packages/sandbox/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/deps_ssr/vitest___@vitest_expect___chai.js:1601:25 ❯ tests/sandbox.test.ts:946:7
sandbox.exposePort(8080, {
hostname: 'example.com',
token: 'ABCD1234efgh5678' // uppercase not allowed
})
).rejects.toThrow('Custom token must contain only lowercase letters');
});

it('should reject tokens with special characters', async () => {
await expect(

Check failure on line 955 in packages/sandbox/tests/sandbox.test.ts

View workflow job for this annotation

GitHub Actions / unit-tests

tests/sandbox.test.ts > Sandbox - Automatic Session Management > custom token validation > should reject tokens with special characters

AssertionError: expected [Function] to throw error including 'Custom token must contain only lowerc…' but got 'Sandbox name not available. Ensure sa…' Expected: "Custom token must contain only lowercase letters" Received: "Sandbox name not available. Ensure sandbox is accessed through getSandbox()" ❯ Assertion.methodWrapper home/runner/work/sandbox-sdk/sandbox-sdk/packages/sandbox/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/deps_ssr/vitest___@vitest_expect___chai.js:1601:25 ❯ tests/sandbox.test.ts:955:7
sandbox.exposePort(8080, {
hostname: 'example.com',
token: 'abcd!@#$%^&*1234'
})
).rejects.toThrow('Custom token must contain only lowercase letters');
});

it('should allow tokens with hyphens and underscores', async () => {
const validToken = 'abc-123_def-456';
const result = await sandbox.exposePort(8080, {
hostname: 'example.com',
token: validToken
});

expect(result.port).toBe(8080);
expect(result.url).toContain(validToken);
});

it('should generate random token when not provided', async () => {
const result = await sandbox.exposePort(8080, {
hostname: 'example.com'
});

expect(result.port).toBe(8080);

// Verify a token was stored
expect(mockCtx.storage!.put).toHaveBeenCalledWith(
'portTokens',
expect.objectContaining({
'8080': expect.stringMatching(/^[a-z0-9_-]{16}$/)
})
);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing critical test case: token collision prevention. Add:

it('should reject exposing different ports with same custom token', async () => {
  await sandbox.exposePort(8080, { hostname: 'example.com', token: 'shared' });
  await expect(
    sandbox.exposePort(8081, { hostname: 'example.com', token: 'shared' })
  ).rejects.toThrow(/already in use/);
});

});
6 changes: 6 additions & 0 deletions packages/shared/src/request-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export interface FileExistsRequest {
export interface ExposePortRequest {
port: number;
name?: string;
/**
* Custom token for the preview URL (optional)
* If not provided, a random 16-character token will be generated
* Must be 1-16 characters, containing only lowercase letters, numbers, hyphens, and underscores
*/
token?: string;
}

/**
Expand Down
Loading