-
Notifications
You must be signed in to change notification settings - Fork 57
Add custom token support to exposePort for stable preview URLs #329
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
98b85c8
f847b6d
a88a818
4a39985
6ca6c73
736ed8a
daa1b4a
cf29340
3e65507
334ce49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
|
@@ -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) { | ||
|
|
@@ -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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| token = options.token; | ||
| } else { | ||
| token = this.generatePortToken(); | ||
| } | ||
|
|
||
| // Store token for this port (storage is protected by input gates) | ||
|
||
| const tokens = | ||
| (await this.ctx.storage.get<Record<string, string>>('portTokens')) || {}; | ||
| tokens[port.toString()] = token; | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}$/) | ||
| }) | ||
| ); | ||
| }); | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/);
}); |
||
| }); | ||
There was a problem hiding this comment.
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)"