From e67d542f095903bf9adf2b0d168b37a9d6fde64c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 5 Sep 2026 10:55:30 +0700 Subject: [PATCH] fix(node): parse absolute-form request targets Co-authored-by: Xia Chao <236466140+bun-unsafe@users.noreply.github.com> --- packages/node/src/url.test.ts | 92 ++++++++++++++++++++++++++++++++--- packages/node/src/url.ts | 14 +++++- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/node/src/url.test.ts b/packages/node/src/url.test.ts index 270664d..a52ef16 100644 --- a/packages/node/src/url.test.ts +++ b/packages/node/src/url.test.ts @@ -1,9 +1,89 @@ +import type { AddressInfo } from 'node:net' +import http from 'node:http' +import net from 'node:net' import { toStandardUrl } from './url' -it('toStandardUrl', () => { - expect(toStandardUrl({ } as any)).toBe('/') - expect(toStandardUrl({ url: '/foo' } as any)).toBe('/foo') - expect(toStandardUrl({ url: '/foo?bar=1#baz' } as any)).toBe('/foo?bar=1#baz') - expect(toStandardUrl({ url: '/', originalUrl: '/foo?bar=2#baz' } as any)).toBe('/foo?bar=2#baz') - expect(toStandardUrl({ url: 'base' } as any)).toBe('/base') +describe('toStandardUrl', () => { + it('origin-form', () => { + expect(toStandardUrl({ } as any)).toBe('/') + expect(toStandardUrl({ url: '/' } as any)).toBe('/') + expect(toStandardUrl({ url: '/foo' } as any)).toBe('/foo') + expect(toStandardUrl({ url: '/foo?bar=1#baz' } as any)).toBe('/foo?bar=1#baz') + // asterisk-form (`OPTIONS *`) + expect(toStandardUrl({ url: '*' } as any)).toBe('/*') + }) + + it('prefers originalUrl over url', () => { + expect(toStandardUrl({ url: '/', originalUrl: '/foo?bar=2#baz' } as any)).toBe('/foo?bar=2#baz') + expect(toStandardUrl({ url: '/', originalUrl: 'http://127.0.0.1:80/foo?x=1' } as any)).toBe('/foo?x=1') + }) + + it('absolute-form (RFC 9112 ยง3.2.2, sent by clients that treat the server as a proxy)', () => { + expect(toStandardUrl({ url: 'http://127.0.0.1:3000/ping' } as any)).toBe('/ping') + expect(toStandardUrl({ url: 'http://example.com/foo?bar=1' } as any)).toBe('/foo?bar=1') + expect(toStandardUrl({ url: 'https://example.com/foo#h' } as any)).toBe('/foo#h') + expect(toStandardUrl({ url: 'HTTP://EXAMPLE.COM/Foo' } as any)).toBe('/Foo') + expect(toStandardUrl({ url: 'http://example.com' } as any)).toBe('/') + expect(toStandardUrl({ url: 'http://example.com?x=1' } as any)).toBe('/?x=1') + expect(toStandardUrl({ url: 'http://example.com#f' } as any)).toBe('/#f') + expect(toStandardUrl({ url: 'http://user:pw@example.com:8080/p' } as any)).toBe('/p') + expect(toStandardUrl({ url: 'http://[::1]:3000/p' } as any)).toBe('/p') + }) + + it('reduces non-http schemes to their path', () => { + expect(toStandardUrl({ url: 'ws://example.com/socket' } as any)).toBe('/socket') + expect(toStandardUrl({ url: 'ftp://example.com/file' } as any)).toBe('/file') + }) + + it('treats anything else as a relative path', () => { + expect(toStandardUrl({ url: 'base' } as any)).toBe('/base') + expect(toStandardUrl({ url: '' } as any)).toBe('/') + expect(toStandardUrl({ url: '?x=1' } as any)).toBe('/?x=1') + expect(toStandardUrl({ url: '#frag' } as any)).toBe('/#frag') + }) + + it('malicious or malformed input', () => { + // origin-form is passed through untouched: never resolved as a host, never normalized + expect(toStandardUrl({ url: '//evil.com/ping' } as any)).toBe('//evil.com/ping') + expect(toStandardUrl({ url: '////' } as any)).toBe('////') + expect(toStandardUrl({ url: '/../../etc/passwd' } as any)).toBe('/../../etc/passwd') + expect(toStandardUrl({ url: '/%2e%2e/x' } as any)).toBe('/%2e%2e/x') + expect(toStandardUrl({ url: ':::' } as any)).toBe('/:::') + + // authority tricks in absolute-form never leak into the path + expect(toStandardUrl({ url: 'http://good.com@evil.com/x' } as any)).toBe('/x') + expect(toStandardUrl({ url: 'http://evil.com\\@good.com/x' } as any)).toBe('/@good.com/x') + expect(toStandardUrl({ url: 'http://example.com/../../etc/passwd' } as any)).toBe('/etc/passwd') + expect(toStandardUrl({ url: 'http://example.com/a\tb\n' } as any)).toBe('/ab') + expect(toStandardUrl({ url: 'javascript:alert(1)' } as any)).toBe('/alert(1)') + + // unparseable absolute-form keeps the legacy `/${url}` behavior instead of throwing + expect(toStandardUrl({ url: 'http://' } as any)).toBe('/http://') + expect(toStandardUrl({ url: 'http://[::1' } as any)).toBe('/http://[::1') + expect(toStandardUrl({ url: 'http://example.com:99999/x' } as any)).toBe('/http://example.com:99999/x') + }) + + it('absolute-form from a real node:http server (what `curl -x ` sends)', async ({ onTestFinished }) => { + let url: string | undefined + + const server = http.createServer((req, res) => { + url = toStandardUrl(req) + res.end() + }) + onTestFinished(() => new Promise(r => server.close(r))) + + await new Promise(r => server.listen(0, r)) + const { port } = server.address() as AddressInfo + + await new Promise((resolve, reject) => { + const socket = net.connect(port, '127.0.0.1', () => { + socket.end(`GET http://127.0.0.1:${port}/ping?x=1 HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`) + }) + socket.resume() + socket.on('close', resolve) + socket.on('error', reject) + }) + + expect(url).toBe('/ping?x=1') + }) }) diff --git a/packages/node/src/url.ts b/packages/node/src/url.ts index 6d43b6f..7b0cd39 100644 --- a/packages/node/src/url.ts +++ b/packages/node/src/url.ts @@ -1,8 +1,20 @@ import type { StandardUrl } from '@standardserver/core' import type { NodeHttpRequest } from './types' +import { toStandardUrl as toStandardUrlFetch } from '@standardserver/fetch' export function toStandardUrl(req: NodeHttpRequest): StandardUrl { // prefer originalUrl over url, especially useful in express.js middleware const url = req.originalUrl ?? req.url ?? '/' - return `${url.startsWith('/') ? '' : '/'}${url}` as `/${string}` + + if (url.startsWith('/')) { + return url as `/${string}` + } + + try { + const parsed = new URL(url, 'http://localhost') + return toStandardUrlFetch(parsed) + } + catch { + return `/${url}` + } }