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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,4 @@ yarn.lock

#tap files
.tap/
.githuman/
25 changes: 23 additions & 2 deletions lib/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const querystring = require('node:querystring')
const eos = require('end-of-stream')
const { pipeline } = require('node:stream')
const undici = require('undici')
const { stripHttp1ConnectionHeaders } = require('./utils')
const { stripHttp1ConnectionHeaders, getConnectionHeaders } = require('./utils')
const http2 = require('node:http2')

const {
Expand Down Expand Up @@ -120,12 +120,24 @@ function buildRequest (opts) {
}

function handleHttp1Req (opts, done) {
// Strip Connection header and headers listed in it (RFC 7230 Section 6.1)
// Headers are already lowercased by Node.js
const connectionHeaderNames = getConnectionHeaders(opts.headers)
let headers = opts.headers
if (opts.headers.connection || connectionHeaderNames.length > 0) {
headers = { ...opts.headers }
delete headers.connection
for (let i = 0; i < connectionHeaderNames.length; i++) {
delete headers[connectionHeaderNames[i]]
}
}

const req = requests[opts.url.protocol].request({
method: opts.method,
port: opts.url.port,
path: opts.url.pathname + opts.qs,
hostname: opts.url.hostname,
headers: opts.headers,
headers,
agent: agents[opts.url.protocol.replace(/^unix:/, '')],
...httpOpts.requestOptions,
timeout: opts.timeout ?? httpOpts.requestOptions.timeout
Expand Down Expand Up @@ -171,10 +183,19 @@ function buildRequest (opts) {
pool = undiciAgent
}

// Strip headers listed in Connection header (RFC 7230 Section 6.1)
// Headers are already lowercased
const connectionHeaderNames = getConnectionHeaders(req.headers)

// remove forbidden headers
req.headers.connection = undefined
req.headers['transfer-encoding'] = undefined

// Also remove headers listed in Connection header
for (let i = 0; i < connectionHeaderNames.length; i++) {
req.headers[connectionHeaderNames[i]] = undefined
}

pool.request(req, function (err, res) {
if (err) {
done(err)
Expand Down
32 changes: 31 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,36 @@ function copyHeaders (headers, reply) {
}
}

// Parse Connection header and return list of header names to strip (per RFC 7230 Section 6.1)
function getConnectionHeaders (headers) {
const connectionHeader = headers.connection
if (typeof connectionHeader !== 'string') {
return []
}
// Connection header is comma-separated list of header names
const lowerCased = connectionHeader.toLowerCase()
const result = []
let start = 0
let end = 0
for (; end <= lowerCased.length; end++) {
if (lowerCased.charCodeAt(end) === 44 || end === lowerCased.length) { // 44 = ','
const token = lowerCased.slice(start, end).trim()
if (token.length > 0) {
result.push(token)
}
start = end + 1
}
}
return result
}

function stripHttp1ConnectionHeaders (headers) {
const headersKeys = Object.keys(headers)
const dest = {}

// Get headers listed in Connection header that should be stripped (RFC 7230 Section 6.1)
const connectionHeaderNames = getConnectionHeaders(headers)

let header
let i

Expand All @@ -54,7 +80,10 @@ function stripHttp1ConnectionHeaders (headers) {
}
break
default:
dest[header] = headers[header]
// Also skip headers listed in Connection header (RFC 7230 Section 6.1)
if (!connectionHeaderNames.includes(header)) {
dest[header] = headers[header]
}
break
}
}
Expand Down Expand Up @@ -96,6 +125,7 @@ function buildURL (source, reqBase) {
module.exports = {
copyHeaders,
stripHttp1ConnectionHeaders,
getConnectionHeaders,
filterPseudoHeaders,
buildURL
}
4 changes: 2 additions & 2 deletions test/core-with-path-in-base.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ const http = require('node:http')
const instance = Fastify()

t.test('core with path in base', async (t) => {
t.plan(8)
t.plan(7)
t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.method, 'GET')
t.assert.strictEqual(req.url, '/hello')
t.assert.strictEqual(req.headers.connection, 'close')
// Connection header is not forwarded per RFC 7230 Section 6.1
res.statusCode = 205
res.setHeader('Content-Type', 'text/plain')
res.setHeader('x-my-header', 'hello!')
Expand Down
232 changes: 232 additions & 0 deletions test/strip-connection-headers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
'use strict'

const t = require('node:test')
const Fastify = require('fastify')
const From = require('..')
const http = require('node:http')

// RFC 7230 Section 6.1 - Connection header handling
// A proxy MUST parse the Connection header and remove any headers listed within it

// Helper to make HTTP request with Connection header (undici doesn't allow this)
function makeRequest (port, headers) {
return new Promise((resolve, reject) => {
const req = http.request({
method: 'GET',
hostname: 'localhost',
port,
path: '/',
headers
}, (res) => {
let data = ''
res.on('data', (chunk) => { data += chunk })
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }))
})
req.on('error', reject)
req.end()
})
}

t.test('strips headers listed in Connection header (undici)', async (t) => {
t.plan(4)
const instance = Fastify()
instance.register(From)

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Custom-Header': 'some-value',
Connection: 'X-Custom-Header'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})

t.test('strips multiple headers listed in Connection header (undici)', async (t) => {
t.plan(5)
const instance = Fastify()
instance.register(From)

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Custom-One': 'value1',
'X-Custom-Two': 'value2',
Connection: 'X-Custom-One, X-Custom-Two'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})

t.test('preserves headers not listed in Connection header (undici)', async (t) => {
t.plan(5)
const instance = Fastify()
instance.register(From)

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-keep-header'], 'keep-me', 'X-Keep-Header should be preserved')
t.assert.strictEqual(req.headers['x-strip-header'], undefined, 'X-Strip-Header should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Keep-Header': 'keep-me',
'X-Strip-Header': 'strip-me',
Connection: 'X-Strip-Header'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})

t.test('strips headers listed in Connection header (http)', async (t) => {
t.plan(4)
const instance = Fastify()
instance.register(From, { undici: false })

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Custom-Header': 'some-value',
Connection: 'X-Custom-Header'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})

t.test('strips multiple headers listed in Connection header (http)', async (t) => {
t.plan(5)
const instance = Fastify()
instance.register(From, { undici: false })

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Custom-One': 'value1',
'X-Custom-Two': 'value2',
Connection: 'X-Custom-One, X-Custom-Two'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})

t.test('handles Connection header with keep-alive and custom headers (undici)', async (t) => {
t.plan(4)
const instance = Fastify()
instance.register(From)

t.after(() => instance.close())

const target = http.createServer((req, res) => {
t.assert.ok('request proxied')
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
})

instance.get('/', (_request, reply) => {
reply.from(`http://localhost:${target.address().port}`)
})

t.after(() => target.close())

await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
await new Promise((resolve) => target.listen({ port: 0 }, resolve))

const result = await makeRequest(instance.server.address().port, {
'X-Custom-Header': 'some-value',
Connection: 'keep-alive, X-Custom-Header'
})

t.assert.strictEqual(result.statusCode, 200)
t.assert.strictEqual(result.body, 'ok')
})