diff --git a/.changeset/fix-preview-url-underscores.md b/.changeset/fix-preview-url-underscores.md new file mode 100644 index 00000000..a57df07b --- /dev/null +++ b/.changeset/fix-preview-url-underscores.md @@ -0,0 +1,9 @@ +--- +'@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. + +**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/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..df59120c 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< @@ -1655,12 +1668,35 @@ 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) + // 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, '-') .replace(/=/g, '') .toLowerCase(); + + // Ensure token doesn't end with hyphen (RFC 952/1123 requirement) + // 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('-')) { + const randomChar = alphanumericChars[Math.floor(Math.random() * alphanumericChars.length)]; + token = token.slice(0, -1) + randomChar; + } + + while (token.startsWith('-')) { + const randomChar = alphanumericChars[Math.floor(Math.random() * alphanumericChars.length)]; + token = randomChar + 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 77033703..2cc254f6 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,43 @@ 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; + + // 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(); + + 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', () => {