Skip to content

Commit 2a4c676

Browse files
committed
[TCP Proxy] Produce the correct request path in parseHttpRequest()
Solves a TCP<->fetch() issue where query strings were being double-encoded and duplicated. When PHP tried to request `/core/version-check/1.7/?channel=beta` via file_get_contents(), parseHttpRequest() would source an invalid URL for fetch(): `/core/version-check/1.7/%3Fchannel=beta?channel=beta` This is caused by a redundant and incorrect assignment `url.pathname = parsedHeaders.path;` that this PR removes. ## Testing instructions CI
1 parent 24ed901 commit 2a4c676

File tree

2 files changed

+193
-1
lines changed

2 files changed

+193
-1
lines changed

packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.spec.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,199 @@ describe('RawBytesFetch', () => {
368368
);
369369
expect(decodedRequestBody).toEqual(encodedBodyBytes);
370370
});
371+
372+
it('parseHttpRequest should handle a path and query string', async () => {
373+
const requestBytes = `GET /core/version-check/1.7/?channel=beta HTTP/1.1\r\nHost: playground.internal\r\n\r\n`;
374+
const request = await RawBytesFetch.parseHttpRequest(
375+
new ReadableStream({
376+
start(controller) {
377+
controller.enqueue(new TextEncoder().encode(requestBytes));
378+
controller.close();
379+
},
380+
}),
381+
'playground.internal',
382+
'http'
383+
);
384+
expect(request.url).toEqual(
385+
'http://playground.internal/core/version-check/1.7/?channel=beta'
386+
);
387+
});
388+
389+
it('parseHttpRequest should handle a simple path without query string', async () => {
390+
const requestBytes = `GET /api/users HTTP/1.1\r\nHost: example.com\r\n\r\n`;
391+
const request = await RawBytesFetch.parseHttpRequest(
392+
new ReadableStream({
393+
start(controller) {
394+
controller.enqueue(new TextEncoder().encode(requestBytes));
395+
controller.close();
396+
},
397+
}),
398+
'example.com',
399+
'http'
400+
);
401+
expect(request.url).toEqual('http://example.com/api/users');
402+
});
403+
404+
it('parseHttpRequest should handle root path', async () => {
405+
const requestBytes = `GET / HTTP/1.1\r\nHost: example.com\r\n\r\n`;
406+
const request = await RawBytesFetch.parseHttpRequest(
407+
new ReadableStream({
408+
start(controller) {
409+
controller.enqueue(new TextEncoder().encode(requestBytes));
410+
controller.close();
411+
},
412+
}),
413+
'example.com',
414+
'https'
415+
);
416+
expect(request.url).toEqual('https://example.com/');
417+
});
418+
419+
it('parseHttpRequest should handle multiple query parameters', async () => {
420+
const requestBytes = `GET /search?q=test&page=2&sort=asc HTTP/1.1\r\nHost: api.example.com\r\n\r\n`;
421+
const request = await RawBytesFetch.parseHttpRequest(
422+
new ReadableStream({
423+
start(controller) {
424+
controller.enqueue(new TextEncoder().encode(requestBytes));
425+
controller.close();
426+
},
427+
}),
428+
'api.example.com',
429+
'https'
430+
);
431+
expect(request.url).toEqual(
432+
'https://api.example.com/search?q=test&page=2&sort=asc'
433+
);
434+
});
435+
436+
it('parseHttpRequest should handle path with trailing slash', async () => {
437+
const requestBytes = `GET /api/users/ HTTP/1.1\r\nHost: example.com\r\n\r\n`;
438+
const request = await RawBytesFetch.parseHttpRequest(
439+
new ReadableStream({
440+
start(controller) {
441+
controller.enqueue(new TextEncoder().encode(requestBytes));
442+
controller.close();
443+
},
444+
}),
445+
'example.com',
446+
'http'
447+
);
448+
expect(request.url).toEqual('http://example.com/api/users/');
449+
});
450+
451+
it('parseHttpRequest should handle nested paths', async () => {
452+
const requestBytes = `GET /api/v1/users/123/posts/456 HTTP/1.1\r\nHost: example.com\r\n\r\n`;
453+
const request = await RawBytesFetch.parseHttpRequest(
454+
new ReadableStream({
455+
start(controller) {
456+
controller.enqueue(new TextEncoder().encode(requestBytes));
457+
controller.close();
458+
},
459+
}),
460+
'example.com',
461+
'https'
462+
);
463+
expect(request.url).toEqual(
464+
'https://example.com/api/v1/users/123/posts/456'
465+
);
466+
});
467+
468+
it('parseHttpRequest should handle URL-encoded characters in path', async () => {
469+
const requestBytes = `GET /search/hello%20world HTTP/1.1\r\nHost: example.com\r\n\r\n`;
470+
const request = await RawBytesFetch.parseHttpRequest(
471+
new ReadableStream({
472+
start(controller) {
473+
controller.enqueue(new TextEncoder().encode(requestBytes));
474+
controller.close();
475+
},
476+
}),
477+
'example.com',
478+
'http'
479+
);
480+
expect(request.url).toEqual('http://example.com/search/hello%20world');
481+
});
482+
483+
it('parseHttpRequest should handle special characters in query string', async () => {
484+
const requestBytes = `GET /search?q=hello+world&filter=a%26b HTTP/1.1\r\nHost: example.com\r\n\r\n`;
485+
const request = await RawBytesFetch.parseHttpRequest(
486+
new ReadableStream({
487+
start(controller) {
488+
controller.enqueue(new TextEncoder().encode(requestBytes));
489+
controller.close();
490+
},
491+
}),
492+
'example.com',
493+
'http'
494+
);
495+
expect(request.url).toEqual(
496+
'http://example.com/search?q=hello+world&filter=a%26b'
497+
);
498+
});
499+
500+
it('parseHttpRequest should handle empty query parameter values', async () => {
501+
const requestBytes = `GET /api?key1=&key2=value2 HTTP/1.1\r\nHost: example.com\r\n\r\n`;
502+
const request = await RawBytesFetch.parseHttpRequest(
503+
new ReadableStream({
504+
start(controller) {
505+
controller.enqueue(new TextEncoder().encode(requestBytes));
506+
controller.close();
507+
},
508+
}),
509+
'example.com',
510+
'http'
511+
);
512+
expect(request.url).toEqual('http://example.com/api?key1=&key2=value2');
513+
});
514+
515+
it('parseHttpRequest should handle path with hash fragment', async () => {
516+
// Note: Hash fragments are typically not sent in HTTP requests,
517+
// but if they are, the URL constructor should handle them
518+
const requestBytes = `GET /page#section HTTP/1.1\r\nHost: example.com\r\n\r\n`;
519+
const request = await RawBytesFetch.parseHttpRequest(
520+
new ReadableStream({
521+
start(controller) {
522+
controller.enqueue(new TextEncoder().encode(requestBytes));
523+
controller.close();
524+
},
525+
}),
526+
'example.com',
527+
'http'
528+
);
529+
expect(request.url).toEqual('http://example.com/page#section');
530+
});
531+
532+
it('parseHttpRequest should handle path with query and hash', async () => {
533+
const requestBytes = `GET /page?param=value#section HTTP/1.1\r\nHost: example.com\r\n\r\n`;
534+
const request = await RawBytesFetch.parseHttpRequest(
535+
new ReadableStream({
536+
start(controller) {
537+
controller.enqueue(new TextEncoder().encode(requestBytes));
538+
controller.close();
539+
},
540+
}),
541+
'example.com',
542+
'http'
543+
);
544+
expect(request.url).toEqual(
545+
'http://example.com/page?param=value#section'
546+
);
547+
});
548+
549+
it('parseHttpRequest should preserve Host header over default host', async () => {
550+
const requestBytes = `GET /api HTTP/1.1\r\nHost: custom.host.com\r\n\r\n`;
551+
const request = await RawBytesFetch.parseHttpRequest(
552+
new ReadableStream({
553+
start(controller) {
554+
controller.enqueue(new TextEncoder().encode(requestBytes));
555+
controller.close();
556+
},
557+
}),
558+
'default.host.com', // Different from Host header
559+
'https'
560+
);
561+
// Should use the Host header, not the default host parameter
562+
expect(request.url).toEqual('https://custom.host.com/api');
563+
});
371564
});
372565

373566
type MakeRequestOptions = {

packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,6 @@ export class RawBytesFetch {
686686
*/
687687
const hostname = parsedHeaders.headers.get('Host') ?? host;
688688
const url = new URL(parsedHeaders.path, protocol + '://' + hostname);
689-
url.pathname = parsedHeaders.path;
690689

691690
return new Request(url.toString(), {
692691
method: parsedHeaders.method,

0 commit comments

Comments
 (0)