Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-preview-url-underscores.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset should document the breaking change. Existing tokens stored in Durable Objects will become invalid, breaking active preview URLs until they're regenerated.

Suggest adding:

**Breaking change:** Existing exposed ports will need to be re-exposed to generate new DNS-compliant tokens. Old preview URLs with underscores in tokens will stop working after this update.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The breaking change section was added but the explanation is technically incorrect - tokens regenerate when DOs wake from sleep, not on "next access" of exposePort(). See new review comment for details.


**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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"On next access" is misleading. Tokens regenerate when the Durable Object wakes from sleep (constructor runs), not when users call exposePort().

What actually happens:

  1. Deploy update
  2. Existing DOs continue with old tokens until evicted from memory
  3. When DO wakes (any request to that sandbox), migration runs
  4. Old preview URLs stop working immediately

Suggest:

**Breaking change:** Existing preview URL tokens will be automatically regenerated when Durable Objects wake from sleep after this update. Old preview URLs containing underscores will stop working immediately when the sandbox wakes, without requiring a new `exposePort()` call. Users should regenerate preview URLs proactively by calling `exposePort()` again for critical services.

3 changes: 2 additions & 1 deletion packages/sandbox/src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ export async function proxyToSandbox<E extends SandboxEnv>(
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) {
Expand Down
40 changes: 38 additions & 2 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,19 @@ export class Sandbox<Env = unknown> extends Container<Env> 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<
Expand Down Expand Up @@ -1655,12 +1668,35 @@ export class Sandbox<Env = unknown> extends Container<Env> 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<void> {
Expand Down
43 changes: 40 additions & 3 deletions packages/sandbox/tests/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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\/?$/
);
});

Expand All @@ -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])?)*$/);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex validates the entire hostname, not the token specifically. The test passes even if the token has leading/trailing hyphens, as long as other hostname labels are valid.

Example false positive: hostname 8080-sandbox--token--.example.com passes because 8080-sandbox starts with alphanumeric.

Validate the extracted token directly:

const token = match![3];
expect(token).toMatch(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/); // RFC 952/1123 single label
expect(token).not.toMatch(/--/); // No consecutive hyphens


// Extract token from hostname pattern: port-sandboxId-token.domain
const match = hostname.match(/^(\d{4,5})-([^.-][^.]*?[^.-]|[^.-])-([a-z0-9-]{16})\.(.+)$/);
expect(match).toBeTruthy();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test validates token character set but doesn't verify actual DNS hostname validity. RFC 952/1123 has additional rules (e.g., labels can't start/end with hyphens).

Consider adding a comprehensive hostname validation:

expect(hostname).toMatch(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/);

This ensures the entire hostname (not just the token) meets DNS requirements.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test at line 859-894 still has this issue - it validates the entire hostname rather than the token component specifically. The test would pass even if tokens have leading/trailing hyphens.


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', () => {
Expand Down
Loading