-
Notifications
You must be signed in to change notification settings - Fork 53
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
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
98b85c8
Add custom token support to exposePort for stable preview URLs
mikenomitch f847b6d
Remove 16-character limitation for custom tokens
mikenomitch a88a818
Set maximum token length to 16 characters
mikenomitch 4a39985
Address PR feedback: Fix validation ordering and add token collision …
mikenomitch 6ca6c73
Merge branch 'main' into feat/custom-port-tokens
mikenomitch 736ed8a
Fix empty token validation and clean up token handling
ghostwriternr daa1b4a
Remove misleading constant-time comparison comment
ghostwriternr cf29340
Use timing-safe comparison for token validation
ghostwriternr 3e65507
Remove redundant comments in token validation
ghostwriternr 334ce49
Downgrade changeset from minor to patch
ghostwriternr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, -, _ | ||
| }); | ||
| // 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = { | ||
|
|
@@ -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); | ||
|
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(); | ||
| } | ||
|
|
||
| // 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); | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.