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-stable-token' // 1-63 chars: a-z, 0-9, -, _
});
// url: https://8080-sandbox-id-my-stable-token.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-63 characters and contain only lowercase letters, numbers, hyphens, and underscores to ensure URL and DNS 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-63 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 @@ 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-63 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-stable-token'
* });
* // url: https://8080-sandbox-id-my-stable-token.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,7 +2130,12 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
}

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) {
Expand All @@ -2112,8 +2144,17 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
);
}

// 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 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
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 should have reasonable length for URL compatibility (max 63 chars for DNS subdomain)
if (token.length > 63) {
throw new SecurityError(
`Custom token too long. Maximum 63 characters allowed (DNS subdomain limit). 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 @@ describe('Sandbox - Automatic Session Management', () => {
).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 longToken = 'my-very-long-custom-token-12345';
const result2 = await sandbox.exposePort(8081, {
hostname: 'example.com',
token: longToken
});

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

it('should reject empty tokens', async () => {
await expect(
sandbox.exposePort(8080, {
hostname: 'example.com',
token: ''
})
).rejects.toThrow('Custom token cannot be empty');
});

it('should reject tokens exceeding DNS subdomain limit', async () => {
const tooLongToken = 'a'.repeat(64); // 64 chars, exceeds 63 limit
await expect(
sandbox.exposePort(8080, {
hostname: 'example.com',
token: tooLongToken
})
).rejects.toThrow('Custom token too long. Maximum 63 characters allowed');
});

it('should reject tokens with invalid characters', async () => {
await expect(
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(
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-63 characters, containing only lowercase letters, numbers, hyphens, and underscores
*/
token?: string;
}

/**
Expand Down
Loading