diff --git a/.changeset/custom-port-tokens.md b/.changeset/custom-port-tokens.md index 31aba6bf..de30dcb5 100644 --- a/.changeset/custom-port-tokens.md +++ b/.changeset/custom-port-tokens.md @@ -10,7 +10,7 @@ You can now pass a custom token when exposing ports to maintain consistent previ // 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, -, _ + token: 'my_token_v1' // 1-16 chars: a-z, 0-9, _ }); // url: https://8080-sandbox-id-my-token-v1.example.com @@ -21,4 +21,4 @@ const { url } = await sandbox.exposePort(8080, { // 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. +Custom tokens must be 1-16 characters containing only lowercase letters, numbers, and underscores. diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index ae64ceb1..c2e4e415 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -115,35 +115,56 @@ export async function proxyToSandbox< } function extractSandboxRoute(url: URL): RouteInfo | null { - // Parse subdomain pattern: port-sandboxId-token.domain (tokens mandatory) - // Token can be 1-16 chars (SDK validates) or up to 63 chars (DNS limit for forward compatibility) - const subdomainMatch = url.hostname.match( - /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]+)\.(.+)$/ - ); + // URL format: {port}-{sandboxId}-{token}.{domain} + // Tokens are [a-z0-9_]+, so we split at the last hyphen to handle sandboxIds with hyphens (UUIDs) + const dotIndex = url.hostname.indexOf('.'); + if (dotIndex === -1) { + return null; + } - if (!subdomainMatch) { + const subdomain = url.hostname.slice(0, dotIndex); + const domain = url.hostname.slice(dotIndex + 1); + + // Extract port (digits at start followed by hyphen) + const firstHyphen = subdomain.indexOf('-'); + if (firstHyphen === -1) { return null; } - const portStr = subdomainMatch[1]; - const sandboxId = subdomainMatch[2]; - const token = subdomainMatch[3]; // Mandatory token - const domain = subdomainMatch[4]; + const portStr = subdomain.slice(0, firstHyphen); + if (!/^\d{4,5}$/.test(portStr)) { + return null; + } const port = parseInt(portStr, 10); if (!validatePort(port)) { return null; } - let sanitizedSandboxId: string; - try { - sanitizedSandboxId = sanitizeSandboxId(sandboxId); - } catch (error) { + // Extract token (last hyphen-delimited segment) and sandboxId (everything between port and token) + const rest = subdomain.slice(firstHyphen + 1); + const lastHyphen = rest.lastIndexOf('-'); + if (lastHyphen === -1) { return null; } - // DNS subdomain length limit is 63 characters (applies to each component) - if (sandboxId.length > 63 || token.length > 63) { + const sandboxId = rest.slice(0, lastHyphen); + const token = rest.slice(lastHyphen + 1); + + // Validate token format (no hyphens allowed) + if (!/^[a-z0-9_]+$/.test(token) || token.length === 0 || token.length > 63) { + return null; + } + + // Validate and sanitize sandboxId + if (sandboxId.length === 0 || sandboxId.length > 63) { + return null; + } + + let sanitizedSandboxId: string; + try { + sanitizedSandboxId = sanitizeSandboxId(sandboxId); + } catch { return null; } diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 8ca82350..1f559eb9 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -2311,9 +2311,9 @@ export class Sandbox extends Container implements ISandbox { ); } - if (!/^[a-z0-9_-]+$/.test(token)) { + 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.` + `Custom token must contain only lowercase letters (a-z), numbers (0-9), and underscores (_). Invalid token provided.` ); } } @@ -2324,10 +2324,9 @@ export class Sandbox extends Container implements ISandbox { const array = new Uint8Array(12); // 12 bytes = 16 base64url chars (after padding removal) crypto.getRandomValues(array); - // Convert to base64url format (URL-safe, no padding, lowercase) const base64 = btoa(String.fromCharCode(...array)); return base64 - .replace(/\+/g, '-') + .replace(/\+/g, '_') .replace(/\//g, '_') .replace(/=/g, '') .toLowerCase(); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index c28d4505..ec1dadfd 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -910,9 +910,9 @@ describe('Sandbox - Automatic Session Management', () => { it('should validate token format and length', async () => { const result = await sandbox.exposePort(8080, { hostname: 'example.com', - token: 'abc-123_xyz' + token: 'abc_123_xyz' }); - expect(result.url).toContain('abc-123_xyz'); + expect(result.url).toContain('abc_123_xyz'); await expect( sandbox.exposePort(8080, { hostname: 'example.com', token: '' }) @@ -928,6 +928,10 @@ describe('Sandbox - Automatic Session Management', () => { await expect( sandbox.exposePort(8080, { hostname: 'example.com', token: 'ABC123' }) ).rejects.toThrow('lowercase letters'); + + await expect( + sandbox.exposePort(8080, { hostname: 'example.com', token: 'abc-123' }) + ).rejects.toThrow('underscores (_)'); }); it('should prevent token collision across different ports', async () => { diff --git a/tests/e2e/process-readiness-workflow.test.ts b/tests/e2e/process-readiness-workflow.test.ts index e20c04bf..64a754cb 100644 --- a/tests/e2e/process-readiness-workflow.test.ts +++ b/tests/e2e/process-readiness-workflow.test.ts @@ -497,12 +497,12 @@ Bun.serve({ const exposeResponse = await fetch(`${workerUrl}/api/port/expose`, { method: 'POST', headers: portHeaders, - body: JSON.stringify({ port: 9093, token: 'my-stable-token' }) + body: JSON.stringify({ port: 9093, token: 'my_stable_token' }) }); expect(exposeResponse.status).toBe(200); const { url } = (await exposeResponse.json()) as PortExposeResult; - expect(url).toContain('my-stable-token'); + expect(url).toContain('my_stable_token'); const apiResponse = await fetch(url); expect(apiResponse.status).toBe(200); diff --git a/tests/e2e/test-worker/Dockerfile b/tests/e2e/test-worker/Dockerfile index e170a7f8..0b48def8 100644 --- a/tests/e2e/test-worker/Dockerfile +++ b/tests/e2e/test-worker/Dockerfile @@ -7,6 +7,7 @@ FROM docker.io/cloudflare/sandbox-test:0.6.11 # 9090: process-readiness-workflow.test.ts (waitForPort test) # 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test) # 9092: process-readiness-workflow.test.ts (port exposure test) +# 9093: process-readiness-workflow.test.ts (custom token port exposure test) # 9998: reserved for process-lifecycle-workflow.test.ts # 9999: reserved for websocket-workflow.test.ts -EXPOSE 8080 9090 9091 9092 9998 9999 +EXPOSE 8080 9090 9091 9092 9093 9998 9999 diff --git a/tests/e2e/test-worker/Dockerfile.python b/tests/e2e/test-worker/Dockerfile.python index e4b304a8..bf14d195 100644 --- a/tests/e2e/test-worker/Dockerfile.python +++ b/tests/e2e/test-worker/Dockerfile.python @@ -7,6 +7,7 @@ FROM docker.io/cloudflare/sandbox-test:0.6.11-python # 9090: process-readiness-workflow.test.ts (waitForPort test) # 9091: process-readiness-workflow.test.ts (chained waitForLog/Port test) # 9092: process-readiness-workflow.test.ts (port exposure test) +# 9093: process-readiness-workflow.test.ts (custom token port exposure test) # 9998: reserved for process-lifecycle-workflow.test.ts # 9999: reserved for websocket-workflow.test.ts -EXPOSE 8080 9090 9091 9092 9998 9999 +EXPOSE 8080 9090 9091 9092 9093 9998 9999