@fastify/reply-from 12.4.0 → 12.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/index.js +3 -1
- package/lib/request.js +29 -5
- package/lib/utils.js +37 -1
- package/package.json +3 -3
- package/test/build-url.test.js +32 -0
- package/test/core-with-path-in-base.test.js +2 -2
- package/test/fix-GHSA-2q7r-29rg-6m5h.test.js +32 -0
- package/test/full-querystring-url.test.js +55 -0
- package/test/strip-connection-headers.test.js +232 -0
- package/test/text-event-stream-custom-parser.test.js +68 -0
- package/test/undici-no-destroy.test.js +22 -0
package/README.md
CHANGED
|
@@ -524,26 +524,34 @@ This is due to the fact that `@fastify/multipart` consumes the multipart content
|
|
|
524
524
|
|
|
525
525
|
However, the two plugins may be used within the same fastify instance, at the condition that they belong to disjoint branches of the fastify plugins hierarchy tree.
|
|
526
526
|
|
|
527
|
-
### Proxying
|
|
527
|
+
### Proxying specific content types without parsing
|
|
528
528
|
|
|
529
|
-
If you need to proxy `multipart/form-data`
|
|
529
|
+
If you need to proxy certain content types (like `multipart/form-data` or `text/event-stream`) without parsing them, you can use custom content type parsers:
|
|
530
530
|
|
|
531
531
|
```js
|
|
532
|
-
// Register
|
|
533
|
-
// This passes the raw body through without parsing
|
|
532
|
+
// Register custom content type parsers that pass raw body through
|
|
534
533
|
fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
|
|
535
534
|
done(null, body)
|
|
536
535
|
})
|
|
537
536
|
|
|
537
|
+
fastify.addContentTypeParser('text/event-stream', function (req, body, done) {
|
|
538
|
+
done(null, body)
|
|
539
|
+
})
|
|
540
|
+
|
|
538
541
|
fastify.register(require('@fastify/reply-from'))
|
|
539
542
|
|
|
540
543
|
fastify.post('/upload', (request, reply) => {
|
|
541
544
|
// The multipart data will be proxied as-is to the upstream server
|
|
542
545
|
reply.from('http://upstream-server.com/upload')
|
|
543
546
|
})
|
|
547
|
+
|
|
548
|
+
fastify.post('/events', (request, reply) => {
|
|
549
|
+
// The SSE data will be proxied as-is to the upstream server
|
|
550
|
+
reply.from('http://upstream-server.com/events')
|
|
551
|
+
})
|
|
544
552
|
```
|
|
545
553
|
|
|
546
|
-
This approach allows
|
|
554
|
+
This approach allows these content types to be proxied correctly while avoiding parsing that would consume the request body.
|
|
547
555
|
|
|
548
556
|
## License
|
|
549
557
|
|
package/index.js
CHANGED
|
@@ -71,7 +71,9 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
71
71
|
const retryDelay = opts.retryDelay || undefined
|
|
72
72
|
|
|
73
73
|
if (!source) {
|
|
74
|
-
|
|
74
|
+
const requestUrl = req.url
|
|
75
|
+
const queryIndex = requestUrl.indexOf('?')
|
|
76
|
+
source = queryIndex >= 0 ? requestUrl.substring(0, queryIndex) : requestUrl
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
// we leverage caching to avoid parsing the destination URL
|
package/lib/request.js
CHANGED
|
@@ -5,7 +5,7 @@ const querystring = require('node:querystring')
|
|
|
5
5
|
const eos = require('end-of-stream')
|
|
6
6
|
const { pipeline } = require('node:stream')
|
|
7
7
|
const undici = require('undici')
|
|
8
|
-
const { stripHttp1ConnectionHeaders } = require('./utils')
|
|
8
|
+
const { stripHttp1ConnectionHeaders, getConnectionHeaders } = require('./utils')
|
|
9
9
|
const http2 = require('node:http2')
|
|
10
10
|
|
|
11
11
|
const {
|
|
@@ -53,7 +53,7 @@ function buildRequest (opts) {
|
|
|
53
53
|
const isBalanced = Array.isArray(opts.base) && opts.base.length > 1
|
|
54
54
|
const undiciOpts = opts.undici || {}
|
|
55
55
|
const globalAgent = opts.globalAgent
|
|
56
|
-
const destroyAgent = opts.destroyAgent
|
|
56
|
+
const destroyAgent = opts.destroyAgent || false
|
|
57
57
|
let http2Client
|
|
58
58
|
let undiciAgent
|
|
59
59
|
let undiciInstance
|
|
@@ -104,6 +104,11 @@ function buildRequest (opts) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function close () {
|
|
107
|
+
// Always destroy http2 client (internal implementation detail)
|
|
108
|
+
if (http2Client) {
|
|
109
|
+
http2Client.destroy()
|
|
110
|
+
}
|
|
111
|
+
|
|
107
112
|
if (globalAgent || destroyAgent === false) {
|
|
108
113
|
return
|
|
109
114
|
}
|
|
@@ -114,18 +119,28 @@ function buildRequest (opts) {
|
|
|
114
119
|
} else if (!isHttp2) {
|
|
115
120
|
agents['http:'].destroy()
|
|
116
121
|
agents['https:'].destroy()
|
|
117
|
-
} else if (http2Client) {
|
|
118
|
-
http2Client.destroy()
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function handleHttp1Req (opts, done) {
|
|
126
|
+
// Strip Connection header and headers listed in it (RFC 7230 Section 6.1)
|
|
127
|
+
// Headers are already lowercased by Node.js
|
|
128
|
+
const connectionHeaderNames = getConnectionHeaders(opts.headers)
|
|
129
|
+
let headers = opts.headers
|
|
130
|
+
if (opts.headers.connection || connectionHeaderNames.length > 0) {
|
|
131
|
+
headers = { ...opts.headers }
|
|
132
|
+
delete headers.connection
|
|
133
|
+
for (let i = 0; i < connectionHeaderNames.length; i++) {
|
|
134
|
+
delete headers[connectionHeaderNames[i]]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
const req = requests[opts.url.protocol].request({
|
|
124
139
|
method: opts.method,
|
|
125
140
|
port: opts.url.port,
|
|
126
141
|
path: opts.url.pathname + opts.qs,
|
|
127
142
|
hostname: opts.url.hostname,
|
|
128
|
-
headers
|
|
143
|
+
headers,
|
|
129
144
|
agent: agents[opts.url.protocol.replace(/^unix:/, '')],
|
|
130
145
|
...httpOpts.requestOptions,
|
|
131
146
|
timeout: opts.timeout ?? httpOpts.requestOptions.timeout
|
|
@@ -171,10 +186,19 @@ function buildRequest (opts) {
|
|
|
171
186
|
pool = undiciAgent
|
|
172
187
|
}
|
|
173
188
|
|
|
189
|
+
// Strip headers listed in Connection header (RFC 7230 Section 6.1)
|
|
190
|
+
// Headers are already lowercased
|
|
191
|
+
const connectionHeaderNames = getConnectionHeaders(req.headers)
|
|
192
|
+
|
|
174
193
|
// remove forbidden headers
|
|
175
194
|
req.headers.connection = undefined
|
|
176
195
|
req.headers['transfer-encoding'] = undefined
|
|
177
196
|
|
|
197
|
+
// Also remove headers listed in Connection header
|
|
198
|
+
for (let i = 0; i < connectionHeaderNames.length; i++) {
|
|
199
|
+
req.headers[connectionHeaderNames[i]] = undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
178
202
|
pool.request(req, function (err, res) {
|
|
179
203
|
if (err) {
|
|
180
204
|
done(err)
|
package/lib/utils.js
CHANGED
|
@@ -28,10 +28,36 @@ function copyHeaders (headers, reply) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Parse Connection header and return list of header names to strip (per RFC 7230 Section 6.1)
|
|
32
|
+
function getConnectionHeaders (headers) {
|
|
33
|
+
const connectionHeader = headers.connection
|
|
34
|
+
if (typeof connectionHeader !== 'string') {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
// Connection header is comma-separated list of header names
|
|
38
|
+
const lowerCased = connectionHeader.toLowerCase()
|
|
39
|
+
const result = []
|
|
40
|
+
let start = 0
|
|
41
|
+
let end = 0
|
|
42
|
+
for (; end <= lowerCased.length; end++) {
|
|
43
|
+
if (lowerCased.charCodeAt(end) === 44 || end === lowerCased.length) { // 44 = ','
|
|
44
|
+
const token = lowerCased.slice(start, end).trim()
|
|
45
|
+
if (token.length > 0) {
|
|
46
|
+
result.push(token)
|
|
47
|
+
}
|
|
48
|
+
start = end + 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
function stripHttp1ConnectionHeaders (headers) {
|
|
32
55
|
const headersKeys = Object.keys(headers)
|
|
33
56
|
const dest = {}
|
|
34
57
|
|
|
58
|
+
// Get headers listed in Connection header that should be stripped (RFC 7230 Section 6.1)
|
|
59
|
+
const connectionHeaderNames = getConnectionHeaders(headers)
|
|
60
|
+
|
|
35
61
|
let header
|
|
36
62
|
let i
|
|
37
63
|
|
|
@@ -54,7 +80,10 @@ function stripHttp1ConnectionHeaders (headers) {
|
|
|
54
80
|
}
|
|
55
81
|
break
|
|
56
82
|
default:
|
|
57
|
-
|
|
83
|
+
// Also skip headers listed in Connection header (RFC 7230 Section 6.1)
|
|
84
|
+
if (!connectionHeaderNames.includes(header)) {
|
|
85
|
+
dest[header] = headers[header]
|
|
86
|
+
}
|
|
58
87
|
break
|
|
59
88
|
}
|
|
60
89
|
}
|
|
@@ -63,6 +92,12 @@ function stripHttp1ConnectionHeaders (headers) {
|
|
|
63
92
|
|
|
64
93
|
// issue ref: https://github.com/fastify/fast-proxy/issues/42
|
|
65
94
|
function buildURL (source, reqBase) {
|
|
95
|
+
if (decodeURIComponent(source).includes('..')) {
|
|
96
|
+
const err = new Error('source/request contain invalid characters')
|
|
97
|
+
err.statusCode = 400
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
if (Array.isArray(reqBase)) reqBase = reqBase[0]
|
|
67
102
|
let baseOrigin = reqBase ? new URL(reqBase).href : undefined
|
|
68
103
|
|
|
@@ -90,6 +125,7 @@ function buildURL (source, reqBase) {
|
|
|
90
125
|
module.exports = {
|
|
91
126
|
copyHeaders,
|
|
92
127
|
stripHttp1ConnectionHeaders,
|
|
128
|
+
getConnectionHeaders,
|
|
93
129
|
filterPseudoHeaders,
|
|
94
130
|
buildURL
|
|
95
131
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/reply-from",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.6.0",
|
|
4
4
|
"description": "forward your HTTP request to another server, for fastify",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@fastify/formbody": "^8.0.0",
|
|
62
62
|
"@fastify/multipart": "^9.0.0",
|
|
63
|
-
"@sinonjs/fake-timers": "^
|
|
64
|
-
"@types/node": "^
|
|
63
|
+
"@sinonjs/fake-timers": "^15.0.0",
|
|
64
|
+
"@types/node": "^25.0.3",
|
|
65
65
|
"@types/tap": "^18.0.0",
|
|
66
66
|
"c8": "^10.1.3",
|
|
67
67
|
"eslint": "^9.17.0",
|
package/test/build-url.test.js
CHANGED
|
@@ -76,3 +76,35 @@ test('should throw when trying to override base', async (t) => {
|
|
|
76
76
|
|
|
77
77
|
await Promise.all(promises)
|
|
78
78
|
})
|
|
79
|
+
|
|
80
|
+
test('should throw on path traversal attempts', (t) => {
|
|
81
|
+
t.assert.throws(
|
|
82
|
+
() => buildURL('/foo/bar/../', 'http://localhost'),
|
|
83
|
+
new Error('source/request contain invalid characters')
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
t.assert.throws(
|
|
87
|
+
() => buildURL('/foo/bar/..', 'http://localhost'),
|
|
88
|
+
new Error('source/request contain invalid characters')
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
t.assert.throws(
|
|
92
|
+
() => buildURL('/foo/bar/%2e%2e/', 'http://localhost'),
|
|
93
|
+
new Error('source/request contain invalid characters')
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
t.assert.throws(
|
|
97
|
+
() => buildURL('/foo/bar/%2E%2E/', 'http://localhost'),
|
|
98
|
+
new Error('source/request contain invalid characters')
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
t.assert.throws(
|
|
102
|
+
() => buildURL('/foo/bar/..%2f', 'http://localhost'),
|
|
103
|
+
new Error('source/request contain invalid characters')
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
t.assert.throws(
|
|
107
|
+
() => buildURL('/foo/bar/%2e%2e%2f', 'http://localhost'),
|
|
108
|
+
new Error('source/request contain invalid characters')
|
|
109
|
+
)
|
|
110
|
+
})
|
|
@@ -9,14 +9,14 @@ const http = require('node:http')
|
|
|
9
9
|
const instance = Fastify()
|
|
10
10
|
|
|
11
11
|
t.test('core with path in base', async (t) => {
|
|
12
|
-
t.plan(
|
|
12
|
+
t.plan(7)
|
|
13
13
|
t.after(() => instance.close())
|
|
14
14
|
|
|
15
15
|
const target = http.createServer((req, res) => {
|
|
16
16
|
t.assert.ok('request proxied')
|
|
17
17
|
t.assert.strictEqual(req.method, 'GET')
|
|
18
18
|
t.assert.strictEqual(req.url, '/hello')
|
|
19
|
-
|
|
19
|
+
// Connection header is not forwarded per RFC 7230 Section 6.1
|
|
20
20
|
res.statusCode = 205
|
|
21
21
|
res.setHeader('Content-Type', 'text/plain')
|
|
22
22
|
res.setHeader('x-my-header', 'hello!')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const t = require('node:test')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const { request } = require('undici')
|
|
6
|
+
const From = require('..')
|
|
7
|
+
const http = require('node:http')
|
|
8
|
+
|
|
9
|
+
const instance = Fastify()
|
|
10
|
+
|
|
11
|
+
t.test('fix for GHSA-2q7r-29rg-6m5h vulnerability', async (t) => {
|
|
12
|
+
t.plan(2)
|
|
13
|
+
|
|
14
|
+
const target = http.createServer((_, res) => {
|
|
15
|
+
res.statusCode = 205
|
|
16
|
+
res.end('hi')
|
|
17
|
+
})
|
|
18
|
+
await target.listen({ port: 0 })
|
|
19
|
+
t.after(() => target.close())
|
|
20
|
+
|
|
21
|
+
instance.get('/', (_request, reply) => { reply.from('/ho/%2E%2E/hi') })
|
|
22
|
+
instance.register(From, {
|
|
23
|
+
base: `http://localhost:${target.address().port}/hi/`,
|
|
24
|
+
undici: true
|
|
25
|
+
})
|
|
26
|
+
await instance.listen({ port: 0 })
|
|
27
|
+
t.after(() => instance.close())
|
|
28
|
+
|
|
29
|
+
const { statusCode, body } = await request(`http://localhost:${instance.server.address().port}`)
|
|
30
|
+
t.assert.strictEqual(statusCode, 400)
|
|
31
|
+
t.assert.strictEqual(await body.text(), '{"statusCode":400,"error":"Bad Request","message":"source/request contain invalid characters"}')
|
|
32
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const t = require('node:test')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const { request } = require('undici')
|
|
6
|
+
const From = require('..')
|
|
7
|
+
const http = require('node:http')
|
|
8
|
+
|
|
9
|
+
const instance = Fastify()
|
|
10
|
+
|
|
11
|
+
t.test('full querystring url', async (t) => {
|
|
12
|
+
const target = http.createServer((req, res) => {
|
|
13
|
+
t.assert.ok('request proxied')
|
|
14
|
+
t.assert.strictEqual(req.method, 'GET')
|
|
15
|
+
t.assert.strictEqual(req.url, '/hi?a=/ho/%2E%2E/hi')
|
|
16
|
+
res.statusCode = 205
|
|
17
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
18
|
+
res.setHeader('x-my-header', 'hi!')
|
|
19
|
+
res.end('hi')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await target.listen({ port: 0 })
|
|
23
|
+
t.after(() => target.close())
|
|
24
|
+
|
|
25
|
+
await instance.register(From, {
|
|
26
|
+
base: `http://localhost:${target.address().port}`
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
instance.get('/hi', (_request, reply) => {
|
|
30
|
+
reply.from()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
instance.get('/foo', (_request, reply) => {
|
|
34
|
+
reply.from('/hi')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await instance.listen({ port: 0 })
|
|
38
|
+
t.after(() => instance.close())
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
const result = await request(`http://localhost:${instance.server.address().port}/hi?a=/ho/%2E%2E/hi`)
|
|
42
|
+
t.assert.strictEqual(result.headers['content-type'], 'text/plain')
|
|
43
|
+
t.assert.strictEqual(result.headers['x-my-header'], 'hi!')
|
|
44
|
+
t.assert.strictEqual(result.statusCode, 205)
|
|
45
|
+
t.assert.strictEqual(await result.body.text(), 'hi')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
const result = await request(`http://localhost:${instance.server.address().port}/foo?a=/ho/%2E%2E/hi`)
|
|
50
|
+
t.assert.strictEqual(result.headers['content-type'], 'text/plain')
|
|
51
|
+
t.assert.strictEqual(result.headers['x-my-header'], 'hi!')
|
|
52
|
+
t.assert.strictEqual(result.statusCode, 205)
|
|
53
|
+
t.assert.strictEqual(await result.body.text(), 'hi')
|
|
54
|
+
}
|
|
55
|
+
})
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const t = require('node:test')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const From = require('..')
|
|
6
|
+
const http = require('node:http')
|
|
7
|
+
|
|
8
|
+
// RFC 7230 Section 6.1 - Connection header handling
|
|
9
|
+
// A proxy MUST parse the Connection header and remove any headers listed within it
|
|
10
|
+
|
|
11
|
+
// Helper to make HTTP request with Connection header (undici doesn't allow this)
|
|
12
|
+
function makeRequest (port, headers) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const req = http.request({
|
|
15
|
+
method: 'GET',
|
|
16
|
+
hostname: 'localhost',
|
|
17
|
+
port,
|
|
18
|
+
path: '/',
|
|
19
|
+
headers
|
|
20
|
+
}, (res) => {
|
|
21
|
+
let data = ''
|
|
22
|
+
res.on('data', (chunk) => { data += chunk })
|
|
23
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }))
|
|
24
|
+
})
|
|
25
|
+
req.on('error', reject)
|
|
26
|
+
req.end()
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
t.test('strips headers listed in Connection header (undici)', async (t) => {
|
|
31
|
+
t.plan(4)
|
|
32
|
+
const instance = Fastify()
|
|
33
|
+
instance.register(From)
|
|
34
|
+
|
|
35
|
+
t.after(() => instance.close())
|
|
36
|
+
|
|
37
|
+
const target = http.createServer((req, res) => {
|
|
38
|
+
t.assert.ok('request proxied')
|
|
39
|
+
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
|
|
40
|
+
res.statusCode = 200
|
|
41
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
42
|
+
res.end('ok')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
instance.get('/', (_request, reply) => {
|
|
46
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
t.after(() => target.close())
|
|
50
|
+
|
|
51
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
52
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
53
|
+
|
|
54
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
55
|
+
'X-Custom-Header': 'some-value',
|
|
56
|
+
Connection: 'X-Custom-Header'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
60
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
t.test('strips multiple headers listed in Connection header (undici)', async (t) => {
|
|
64
|
+
t.plan(5)
|
|
65
|
+
const instance = Fastify()
|
|
66
|
+
instance.register(From)
|
|
67
|
+
|
|
68
|
+
t.after(() => instance.close())
|
|
69
|
+
|
|
70
|
+
const target = http.createServer((req, res) => {
|
|
71
|
+
t.assert.ok('request proxied')
|
|
72
|
+
t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
|
|
73
|
+
t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
|
|
74
|
+
res.statusCode = 200
|
|
75
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
76
|
+
res.end('ok')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
instance.get('/', (_request, reply) => {
|
|
80
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
t.after(() => target.close())
|
|
84
|
+
|
|
85
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
86
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
87
|
+
|
|
88
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
89
|
+
'X-Custom-One': 'value1',
|
|
90
|
+
'X-Custom-Two': 'value2',
|
|
91
|
+
Connection: 'X-Custom-One, X-Custom-Two'
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
95
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
t.test('preserves headers not listed in Connection header (undici)', async (t) => {
|
|
99
|
+
t.plan(5)
|
|
100
|
+
const instance = Fastify()
|
|
101
|
+
instance.register(From)
|
|
102
|
+
|
|
103
|
+
t.after(() => instance.close())
|
|
104
|
+
|
|
105
|
+
const target = http.createServer((req, res) => {
|
|
106
|
+
t.assert.ok('request proxied')
|
|
107
|
+
t.assert.strictEqual(req.headers['x-keep-header'], 'keep-me', 'X-Keep-Header should be preserved')
|
|
108
|
+
t.assert.strictEqual(req.headers['x-strip-header'], undefined, 'X-Strip-Header should be stripped')
|
|
109
|
+
res.statusCode = 200
|
|
110
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
111
|
+
res.end('ok')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
instance.get('/', (_request, reply) => {
|
|
115
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
t.after(() => target.close())
|
|
119
|
+
|
|
120
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
121
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
122
|
+
|
|
123
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
124
|
+
'X-Keep-Header': 'keep-me',
|
|
125
|
+
'X-Strip-Header': 'strip-me',
|
|
126
|
+
Connection: 'X-Strip-Header'
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
130
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
t.test('strips headers listed in Connection header (http)', async (t) => {
|
|
134
|
+
t.plan(4)
|
|
135
|
+
const instance = Fastify()
|
|
136
|
+
instance.register(From, { undici: false })
|
|
137
|
+
|
|
138
|
+
t.after(() => instance.close())
|
|
139
|
+
|
|
140
|
+
const target = http.createServer((req, res) => {
|
|
141
|
+
t.assert.ok('request proxied')
|
|
142
|
+
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
|
|
143
|
+
res.statusCode = 200
|
|
144
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
145
|
+
res.end('ok')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
instance.get('/', (_request, reply) => {
|
|
149
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
t.after(() => target.close())
|
|
153
|
+
|
|
154
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
155
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
156
|
+
|
|
157
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
158
|
+
'X-Custom-Header': 'some-value',
|
|
159
|
+
Connection: 'X-Custom-Header'
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
163
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
t.test('strips multiple headers listed in Connection header (http)', async (t) => {
|
|
167
|
+
t.plan(5)
|
|
168
|
+
const instance = Fastify()
|
|
169
|
+
instance.register(From, { undici: false })
|
|
170
|
+
|
|
171
|
+
t.after(() => instance.close())
|
|
172
|
+
|
|
173
|
+
const target = http.createServer((req, res) => {
|
|
174
|
+
t.assert.ok('request proxied')
|
|
175
|
+
t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
|
|
176
|
+
t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
|
|
177
|
+
res.statusCode = 200
|
|
178
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
179
|
+
res.end('ok')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
instance.get('/', (_request, reply) => {
|
|
183
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
t.after(() => target.close())
|
|
187
|
+
|
|
188
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
189
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
190
|
+
|
|
191
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
192
|
+
'X-Custom-One': 'value1',
|
|
193
|
+
'X-Custom-Two': 'value2',
|
|
194
|
+
Connection: 'X-Custom-One, X-Custom-Two'
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
198
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
t.test('handles Connection header with keep-alive and custom headers (undici)', async (t) => {
|
|
202
|
+
t.plan(4)
|
|
203
|
+
const instance = Fastify()
|
|
204
|
+
instance.register(From)
|
|
205
|
+
|
|
206
|
+
t.after(() => instance.close())
|
|
207
|
+
|
|
208
|
+
const target = http.createServer((req, res) => {
|
|
209
|
+
t.assert.ok('request proxied')
|
|
210
|
+
t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
|
|
211
|
+
res.statusCode = 200
|
|
212
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
213
|
+
res.end('ok')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
instance.get('/', (_request, reply) => {
|
|
217
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
t.after(() => target.close())
|
|
221
|
+
|
|
222
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
223
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
224
|
+
|
|
225
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
226
|
+
'X-Custom-Header': 'some-value',
|
|
227
|
+
Connection: 'keep-alive, X-Custom-Header'
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
231
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
232
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const t = require('node:test')
|
|
4
|
+
const Fastify = require('fastify')
|
|
5
|
+
const { request } = require('undici')
|
|
6
|
+
const From = require('..')
|
|
7
|
+
const http = require('node:http')
|
|
8
|
+
|
|
9
|
+
t.test('text/event-stream proxying with custom content type parser', async (t) => {
|
|
10
|
+
t.plan(6)
|
|
11
|
+
|
|
12
|
+
// Target server that sends SSE data
|
|
13
|
+
const target = http.createServer((req, res) => {
|
|
14
|
+
t.assert.ok('request proxied')
|
|
15
|
+
t.assert.strictEqual(req.method, 'POST')
|
|
16
|
+
t.assert.match(req.headers['content-type'], /^text\/event-stream/)
|
|
17
|
+
|
|
18
|
+
let data = ''
|
|
19
|
+
req.setEncoding('utf8')
|
|
20
|
+
req.on('data', (chunk) => {
|
|
21
|
+
data += chunk
|
|
22
|
+
})
|
|
23
|
+
req.on('end', () => {
|
|
24
|
+
// Verify the SSE data is received
|
|
25
|
+
t.assert.match(data, /data: test message/)
|
|
26
|
+
t.assert.match(data, /event: custom/)
|
|
27
|
+
|
|
28
|
+
res.setHeader('content-type', 'application/json')
|
|
29
|
+
res.statusCode = 200
|
|
30
|
+
res.end(JSON.stringify({ received: 'sse data' }))
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Fastify instance with custom text/event-stream parser
|
|
35
|
+
const fastify = Fastify()
|
|
36
|
+
|
|
37
|
+
// Register custom content type parser for text/event-stream
|
|
38
|
+
// This allows the raw body to be passed through without parsing
|
|
39
|
+
fastify.addContentTypeParser('text/event-stream', function (req, body, done) {
|
|
40
|
+
done(null, body)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
fastify.register(From)
|
|
44
|
+
|
|
45
|
+
fastify.post('/', (request, reply) => {
|
|
46
|
+
reply.from(`http://localhost:${target.address().port}`)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
t.after(() => fastify.close())
|
|
50
|
+
t.after(() => target.close())
|
|
51
|
+
|
|
52
|
+
await fastify.listen({ port: 0 })
|
|
53
|
+
await target.listen({ port: 0 })
|
|
54
|
+
|
|
55
|
+
// Create SSE-like data
|
|
56
|
+
const sseData = 'data: test message\nevent: custom\ndata: another line\n\n'
|
|
57
|
+
|
|
58
|
+
// Send request with SSE data
|
|
59
|
+
const result = await request(`http://localhost:${fastify.server.address().port}`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'content-type': 'text/event-stream'
|
|
63
|
+
},
|
|
64
|
+
body: sseData
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
t.assert.deepStrictEqual(await result.body.json(), { received: 'sse data' })
|
|
68
|
+
})
|
|
@@ -27,3 +27,25 @@ test('destroyAgent false', async (t) => {
|
|
|
27
27
|
await instance.ready()
|
|
28
28
|
await instance.close()
|
|
29
29
|
})
|
|
30
|
+
|
|
31
|
+
test('destroyAgent default false', async (t) => {
|
|
32
|
+
const mockAgent = new undici.Agent()
|
|
33
|
+
mockAgent.destroy = () => {
|
|
34
|
+
t.fail()
|
|
35
|
+
}
|
|
36
|
+
const instance = Fastify()
|
|
37
|
+
|
|
38
|
+
t.after(() => instance.close())
|
|
39
|
+
|
|
40
|
+
instance.get('/', (_request, reply) => {
|
|
41
|
+
reply.from()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
instance.register(From, {
|
|
45
|
+
base: 'http://localhost:4242',
|
|
46
|
+
undici: mockAgent
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
await instance.ready()
|
|
50
|
+
await instance.close()
|
|
51
|
+
})
|