From 44631d412504cfb5aa6d8ed91e6758ed8c9497e8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:06:38 +0000 Subject: [PATCH 1/3] Fix preview URL DNS compliance by removing underscores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview URLs generated by exposePort() contained underscores in tokens, violating DNS hostname requirements (RFC 952/1123). Changed token generation to use hyphens for both + and / characters instead of underscores, ensuring all generated hostnames are DNS-valid. - Modified generatePortToken() to replace / with - instead of _ - Updated regex pattern in request-handler to match new token format - Added test to verify tokens contain only DNS-valid characters - Updated existing test expectations to match new pattern Fixes #244 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: whoiskatrin --- .changeset/fix-preview-url-underscores.md | 7 ++++ packages/sandbox/src/request-handler.ts | 3 +- packages/sandbox/src/sandbox.ts | 3 +- packages/sandbox/tests/sandbox.test.ts | 39 +++++++++++++++++++++-- 4 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-preview-url-underscores.md diff --git a/.changeset/fix-preview-url-underscores.md b/.changeset/fix-preview-url-underscores.md new file mode 100644 index 00000000..8bfb3d85 --- /dev/null +++ b/.changeset/fix-preview-url-underscores.md @@ -0,0 +1,7 @@ +--- +'@cloudflare/sandbox': patch +--- + +Fix preview URL DNS compliance by removing underscores from tokens + +Preview URLs generated by `exposePort()` now use only DNS-valid characters (RFC 952/1123). The token generation now replaces both `+` and `/` characters with hyphens instead of using underscores, ensuring all generated hostnames are valid for DNS resolution. diff --git a/packages/sandbox/src/request-handler.ts b/packages/sandbox/src/request-handler.ts index a80991ac..c8c784ab 100644 --- a/packages/sandbox/src/request-handler.ts +++ b/packages/sandbox/src/request-handler.ts @@ -117,8 +117,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) + // Tokens contain only lowercase alphanumeric and hyphens (RFC 952/1123 compliant) const subdomainMatch = url.hostname.match( - /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9_-]{16})\.(.+)$/ + /^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9-]{16})\.(.+)$/ ); if (!subdomainMatch) { diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index b5014691..e3c2e62e 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1655,10 +1655,11 @@ export class Sandbox extends Container implements ISandbox { crypto.getRandomValues(array); // Convert to base64url format (URL-safe, no padding, lowercase) + // Use hyphen for both + and / to ensure DNS hostname compatibility (RFC 952/1123) 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 77033703..c1c60a38 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -797,7 +797,7 @@ describe('Sandbox - Automatic Session Management', () => { }); expect(result.url).toMatch( - /^https:\/\/8080-my-project-[a-z0-9_-]{16}\.example\.com\/?$/ + /^https:\/\/8080-my-project-[a-z0-9-]{16}\.example\.com\/?$/ ); expect(result.port).toBe(8080); }); @@ -815,7 +815,7 @@ describe('Sandbox - Automatic Session Management', () => { const result = await sandbox.exposePort(4000, { hostname: 'my-app.dev' }); expect(result.url).toMatch( - /^https:\/\/4000-myproject-123-[a-z0-9_-]{16}\.my-app\.dev\/?$/ + /^https:\/\/4000-myproject-123-[a-z0-9-]{16}\.my-app\.dev\/?$/ ); expect(result.port).toBe(4000); }); @@ -835,7 +835,7 @@ describe('Sandbox - Automatic Session Management', () => { }); expect(result.url).toMatch( - /^http:\/\/8080-test-sandbox-[a-z0-9_-]{16}\.localhost:3000\/?$/ + /^http:\/\/8080-test-sandbox-[a-z0-9-]{16}\.localhost:3000\/?$/ ); }); @@ -855,6 +855,39 @@ describe('Sandbox - Automatic Session Management', () => { /getSandbox\(ns, "MyProject-ABC", \{ normalizeId: true \}\)/ ); }); + + it('should generate DNS-valid tokens without underscores (RFC 952/1123)', async () => { + await sandbox.setSandboxName('test-sandbox', false); + + vi.spyOn(sandbox.client.ports, 'exposePort').mockResolvedValue({ + success: true, + port: 8080, + url: '', + timestamp: '2023-01-01T00:00:00Z' + }); + + // Generate multiple URLs to test token generation + const results = await Promise.all([ + sandbox.exposePort(8080, { hostname: 'example.com' }), + sandbox.exposePort(8081, { hostname: 'example.com' }), + sandbox.exposePort(8082, { hostname: 'example.com' }) + ]); + + for (const result of results) { + const url = result.url; + const hostname = new URL(url).hostname; + + // Extract token from hostname pattern: port-sandboxId-token.domain + const match = hostname.match(/^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9-]{16})\.(.+)$/); + expect(match).toBeTruthy(); + + const token = match![3]; + // RFC 952/1123: hostnames can only contain alphanumeric and hyphens + expect(token).toMatch(/^[a-z0-9-]+$/); + expect(token).not.toContain('_'); + expect(token.length).toBe(16); + } + }); }); describe('timeout configuration validation', () => { From cf54f937066e186c24d2b83aec4b8bf38c619ab3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:26:04 +0000 Subject: [PATCH 2/3] Add token migration and DNS compliance fixes - Migrate old tokens with underscores to DNS-compliant format - Document entropy reduction from base64url to ~base36 - Ensure tokens don't start/end with hyphens per RFC 952/1123 - Add comprehensive DNS hostname validation in tests - Update changeset to document breaking change Co-authored-by: whoiskatrin --- .changeset/fix-preview-url-underscores.md | 2 ++ packages/sandbox/src/sandbox.ts | 30 ++++++++++++++++++++++- packages/sandbox/tests/sandbox.test.ts | 4 +++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-preview-url-underscores.md b/.changeset/fix-preview-url-underscores.md index 8bfb3d85..a57df07b 100644 --- a/.changeset/fix-preview-url-underscores.md +++ b/.changeset/fix-preview-url-underscores.md @@ -5,3 +5,5 @@ Fix preview URL DNS compliance by removing underscores from tokens Preview URLs generated by `exposePort()` now use only DNS-valid characters (RFC 952/1123). The token generation now replaces both `+` and `/` characters with hyphens instead of using underscores, ensuring all generated hostnames are valid for DNS resolution. + +**Breaking change:** Existing preview URL tokens stored in Durable Objects will be automatically regenerated on next access. Old preview URLs containing underscores will stop working after this update. Users will need to call `exposePort()` again to get new DNS-compliant preview URLs. diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index e3c2e62e..13ff96c8 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -191,6 +191,19 @@ export class Sandbox extends Container implements ISandbox { this.portTokens.set(parseInt(portStr, 10), token); } + // Migrate old tokens with underscores to DNS-compliant format + // Old tokens used underscores which violate RFC 952/1123 DNS hostname requirements + let needsMigration = false; + for (const [port, token] of this.portTokens.entries()) { + if (token.includes('_')) { + this.portTokens.set(port, this.generatePortToken()); + needsMigration = true; + } + } + if (needsMigration) { + await this.persistPortTokens(); + } + // Load saved timeout configuration (highest priority) const storedTimeouts = await this.ctx.storage.get< @@ -1656,12 +1669,27 @@ export class Sandbox extends Container implements ISandbox { // Convert to base64url format (URL-safe, no padding, lowercase) // Use hyphen for both + and / to ensure DNS hostname compatibility (RFC 952/1123) + // Note: This reduces the effective character set from 64 (base64url) to ~36 chars, + // reducing entropy from 96 bits to ~82 bits. Still cryptographically strong for token security. const base64 = btoa(String.fromCharCode(...array)); - return base64 + let token = base64 .replace(/\+/g, '-') .replace(/\//g, '-') .replace(/=/g, '') .toLowerCase(); + + // Ensure token doesn't end with hyphen (RFC 952/1123 requirement) + // Replace trailing hyphens with alphanumeric chars from the token + while (token.endsWith('-')) { + token = token.slice(0, -1) + token.charAt(Math.floor(Math.random() * (token.length - 1))); + } + + // Ensure token doesn't start with hyphen + while (token.startsWith('-')) { + token = token.charAt(Math.floor(Math.random() * (token.length - 1))) + token.slice(1); + } + + return token; } private async persistPortTokens(): Promise { diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index c1c60a38..2cc254f6 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -877,6 +877,10 @@ describe('Sandbox - Automatic Session Management', () => { const url = result.url; const hostname = new URL(url).hostname; + // Validate full hostname RFC 952/1123 compliance + // Labels cannot start or end with hyphens + expect(hostname).toMatch(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/); + // Extract token from hostname pattern: port-sandboxId-token.domain const match = hostname.match(/^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9-]{16})\.(.+)$/); expect(match).toBeTruthy(); From 7846d7c9f077f69118dd127a4013cef6fcd227be Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 20:35:11 +0000 Subject: [PATCH 3/3] Fix token generation infinite loop bug Extract alphanumeric characters only when replacing leading/trailing hyphens in generatePortToken(). Previous implementation could randomly select hyphens when trying to remove them, causing potential infinite loops. - Only select from alphanumeric chars when fixing hyphen positions - Add edge case handling for all-hyphen tokens (regenerate) - Ensures RFC 952/1123 DNS hostname compliance Co-authored-by: whoiskatrin --- packages/sandbox/src/sandbox.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/sandbox/src/sandbox.ts b/packages/sandbox/src/sandbox.ts index 13ff96c8..df59120c 100644 --- a/packages/sandbox/src/sandbox.ts +++ b/packages/sandbox/src/sandbox.ts @@ -1679,14 +1679,21 @@ export class Sandbox extends Container implements ISandbox { .toLowerCase(); // Ensure token doesn't end with hyphen (RFC 952/1123 requirement) - // Replace trailing hyphens with alphanumeric chars from the token + // Replace trailing/leading hyphens with alphanumeric chars only + const alphanumericChars = token.replace(/-/g, '').split(''); + if (alphanumericChars.length === 0) { + // Edge case: token is all hyphens, regenerate + return this.generatePortToken(); + } + while (token.endsWith('-')) { - token = token.slice(0, -1) + token.charAt(Math.floor(Math.random() * (token.length - 1))); + const randomChar = alphanumericChars[Math.floor(Math.random() * alphanumericChars.length)]; + token = token.slice(0, -1) + randomChar; } - // Ensure token doesn't start with hyphen while (token.startsWith('-')) { - token = token.charAt(Math.floor(Math.random() * (token.length - 1))) + token.slice(1); + const randomChar = alphanumericChars[Math.floor(Math.random() * alphanumericChars.length)]; + token = randomChar + token.slice(1); } return token;