-
Notifications
You must be signed in to change notification settings - Fork 55
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 3 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-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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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_-] | ||
|
||
| 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; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
|
@@ -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
|
||
| '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); | ||
|
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 @@ | |
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| 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
|
||
| 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
|
||
| 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
|
||
| 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.
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.