Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/fix-container-fetch-scheme-downgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@cloudflare/containers': patch
---

Fix `containerFetch` rewriting `https:` inside query strings and fragments. The scheme downgrade
applied to container requests used an unanchored string replace, so a URL that was already
`http:` had the first `https:` in its query or fragment downgraded instead of its scheme —
corrupting parameters that carry an absolute URL, such as `/callback?redirect=https://app.example.com`.
9 changes: 7 additions & 2 deletions src/lib/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,8 +1204,13 @@ export class Container<Env = Cloudflare.Env> extends DurableObject<Env> {

const tcpPort = this.container.getTcpPort(port);

// Create URL for the container request
const containerUrl = request.url.replace('https:', 'http:');
// Create URL for the container request. `tcpPort.fetch` opens a raw TCP connection to the
// container, which does not terminate TLS, so an https scheme has to be downgraded.
// The match is anchored on purpose: an unanchored string replace rewrites the first `https:`
// anywhere in the URL, which corrupts query strings and fragments that carry an absolute URL
// (for example `/callback?redirect=https://app.example.com`) whenever the scheme is already
// http.
const containerUrl = request.url.replace(/^https:/, 'http:');

this.inflightRequests++;

Expand Down
36 changes: 36 additions & 0 deletions src/tests/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,42 @@ describe('Container', () => {
expect(tcpPort.fetch).toHaveBeenCalledWith('http://example.com/admin', expect.any(Request));
});

test('containerFetch should preserve https: in query strings and fragments', async ({
mockCtx,
container,
}) => {
mockCtx.container.running = true;
mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() });

await container.containerFetch('/callback?redirect=https://app.example.com#https://fragment');

const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value;
expect(tcpPort.fetch).toHaveBeenCalledWith(
'http://container/callback?redirect=https://app.example.com#https://fragment',
expect.any(Request)
);
});

test('containerFetch should downgrade only the scheme of an https URL', async ({
mockCtx,
container,
}) => {
mockCtx.container.running = true;
mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() });

await container.containerFetch(
'https://example.com/callback?redirect=https://app.example.com',
{ method: 'GET' },
3000
);

const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value;
expect(tcpPort.fetch).toHaveBeenCalledWith(
'http://example.com/callback?redirect=https://app.example.com',
expect.any(Request)
);
});

test('containerFetch should return 429 when startup is rate limited', async ({ container }) => {
const mockRequest = new Request('https://example.com/test', { method: 'GET' });
using startSpy = vi
Expand Down
Loading