diff --git a/src/request.ts b/src/request.ts index e5422ff..7508faf 100644 --- a/src/request.ts +++ b/src/request.ts @@ -160,20 +160,46 @@ export const newRequest = ( const req = Object.create(requestPrototype) req[incomingKey] = incoming + const incomingUrl = incoming.url || '' + + // handle absolute URL in request.url + if ( + incomingUrl[0] !== '/' && // short-circuit for performance. most requests are relative URL. + (incomingUrl.startsWith('http://') || incomingUrl.startsWith('https://')) + ) { + if (incoming instanceof Http2ServerRequest) { + throw new RequestError('Absolute URL for :path is not allowed in HTTP/2') // RFC 9113 8.3.1. + } + + try { + const url = new URL(incomingUrl) + req[urlKey] = url.href + } catch (e) { + throw new RequestError('Invalid absolute URL', { cause: e }) + } + + return req + } + + // Otherwise, relative URL const host = (incoming instanceof Http2ServerRequest ? incoming.authority : incoming.headers.host) || defaultHostname if (!host) { throw new RequestError('Missing host header') } - const url = new URL( - `${ - incoming instanceof Http2ServerRequest || - (incoming.socket && (incoming.socket as TLSSocket).encrypted) - ? 'https' - : 'http' - }://${host}${incoming.url}` - ) + + let scheme: string + if (incoming instanceof Http2ServerRequest) { + scheme = incoming.scheme + if (!(scheme === 'http' || scheme === 'https')) { + throw new RequestError('Unsupported scheme') + } + } else { + scheme = incoming.socket && (incoming.socket as TLSSocket).encrypted ? 'https' : 'http' + } + + const url = new URL(`${scheme}://${host}${incomingUrl}`) // check by length for performance. // if suspicious, check by host. host header sometimes contains port. diff --git a/test/request.test.ts b/test/request.test.ts index 7390b8a..aa5380c 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -1,5 +1,8 @@ import { IncomingMessage } from 'node:http' +import type { ServerHttp2Stream } from 'node:http2' +import { Http2ServerRequest } from 'node:http2' import { Socket } from 'node:net' +import { Duplex } from 'node:stream' import { newRequest, Request as LightweightRequest, @@ -130,7 +133,7 @@ describe('Request', () => { }).toThrow(RequestError) }) - it('Should be create request body from `req.rawBody` if it exists', async () => { + it('Should be created request body from `req.rawBody` if it exists', async () => { const rawBody = Buffer.from('foo') const socket = new Socket() const incomingMessage = new IncomingMessage(socket) @@ -152,6 +155,110 @@ describe('Request', () => { const text = await req.text() expect(text).toBe('foo') }) + + describe('absolute-form for request-target', () => { + it('should be created from valid absolute URL', async () => { + const req = newRequest({ + url: 'http://localhost/path/to/file.html', + } as IncomingMessage) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('http://localhost/path/to/file.html') + }) + + it('should throw error if host header is invalid', async () => { + expect(() => { + newRequest({ + url: 'http://', + } as IncomingMessage) + }).toThrow(RequestError) + }) + + it('should throw error if absolute-form is specified via HTTP/2', async () => { + expect(() => { + newRequest( + new Http2ServerRequest( + new Duplex() as ServerHttp2Stream, + { + ':scheme': 'http', + ':authority': 'localhost', + ':path': 'http://localhost/foo.txt', + }, + {}, + [] + ) + ) + }).toThrow(RequestError) + }) + }) + + describe('HTTP/2', () => { + it('should be created from "http" scheme', async () => { + const req = newRequest( + new Http2ServerRequest( + new Duplex() as ServerHttp2Stream, + { + ':scheme': 'http', + ':authority': 'localhost', + ':path': '/foo.txt', + }, + {}, + [] + ) + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('http://localhost/foo.txt') + }) + + it('should be created from "https" scheme', async () => { + const req = newRequest( + new Http2ServerRequest( + new Duplex() as ServerHttp2Stream, + { + ':scheme': 'https', + ':authority': 'localhost', + ':path': '/foo.txt', + }, + {}, + [] + ) + ) + expect(req).toBeInstanceOf(GlobalRequest) + expect(req.url).toBe('https://localhost/foo.txt') + }) + + it('should throw error if scheme is missing', async () => { + expect(() => { + newRequest( + new Http2ServerRequest( + new Duplex() as ServerHttp2Stream, + { + ':authority': 'localhost', + ':path': '/foo.txt', + }, + {}, + [] + ) + ) + }).toThrow(RequestError) + }) + + it('should throw error if unsupported scheme is specified', async () => { + expect(() => { + newRequest( + new Http2ServerRequest( + new Duplex() as ServerHttp2Stream, + { + ':scheme': 'ftp', + ':authority': 'localhost', + ':path': '/foo.txt', + }, + {}, + [] + ) + ) + }).toThrow(RequestError) + }) + }) }) describe('GlobalRequest', () => { diff --git a/test/server.test.ts b/test/server.test.ts index ee2950d..2cb36b3 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -640,7 +640,11 @@ describe('HTTP2', () => { // Use :authority as the host for the url. it('Should return 200 response - GET /url', async () => { - const res = await request(server, { http2: true }).get('/url').trustLocalhost() + const res = await request(server, { http2: true }) + .get('/url') + .set(':scheme', 'https') + .set(':authority', '127.0.0.1') + .trustLocalhost() expect(res.status).toBe(200) expect(res.headers['content-type']).toMatch('text/plain') const url = new URL(res.text)