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
42 changes: 34 additions & 8 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
109 changes: 108 additions & 1 deletion test/request.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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', () => {
Expand Down
6 changes: 5 additions & 1 deletion test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down