@fastify/reply-from 12.6.0 → 12.6.2
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +19 -0
- package/LICENSE +1 -3
- package/README.md +3 -3
- package/index.js +17 -2
- package/lib/utils.js +6 -1
- package/package.json +4 -4
- package/test/build-url.test.js +9 -0
- package/test/full-querystring-rewrite-option-empty-object.test.js +41 -0
- package/test/full-querystring-rewrite-option-function-empty.test.js +43 -0
- package/test/strip-connection-headers.test.js +76 -0
- package/.github/stale.yml +0 -21
- package/.taprc +0 -3
- package/CLAUDE.md +0 -66
package/.github/workflows/ci.yml
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: Lock Threads
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '0 0 1 * *'
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: lock
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
lock-threads:
|
|
16
|
+
permissions:
|
|
17
|
+
issues: write
|
|
18
|
+
pull-requests: write
|
|
19
|
+
uses: fastify/workflows/.github/workflows/lock-threads.yml@v6
|
package/LICENSE
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
3
|
Copyright (c) 2017-present Matteo Collina
|
|
4
|
-
Copyright (c) 2017-present The Fastify team
|
|
5
|
-
|
|
6
|
-
The Fastify team members are listed at https://github.com/fastify/fastify#team.
|
|
4
|
+
Copyright (c) 2017-present The Fastify team <https://github.com/fastify/fastify#team>
|
|
7
5
|
|
|
8
6
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
7
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -66,10 +66,10 @@ target.listen({ port: 3001 }, (err) => {
|
|
|
66
66
|
Set the base URL for all the forwarded requests. Will be required if `http2` is set to `true`
|
|
67
67
|
Note that _every path will be discarded_.
|
|
68
68
|
|
|
69
|
-
Set the base URL for all the forwarded requests.
|
|
70
|
-
*String or String[]*:
|
|
69
|
+
Set the base URL for all the forwarded requests.
|
|
70
|
+
*String or String[]*:
|
|
71
71
|
|
|
72
|
-
* **Single string** → a normal `undici.Pool` / `http.request` client is used.
|
|
72
|
+
* **Single string** → a normal `undici.Pool` / `http.request` client is used.
|
|
73
73
|
* **Array with ≥ 2 elements** → **[`undici.BalancedPool`](https://undici.nodejs.org/#/docs/api/BalancedPool)** is automatically selected and requests are load-balanced round-robin across the given origins.
|
|
74
74
|
|
|
75
75
|
When you provide an array, only the *origin* (`protocol://host:port`) part of each URL is considered; any path component is ignored.
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
filterPseudoHeaders,
|
|
12
12
|
copyHeaders,
|
|
13
13
|
stripHttp1ConnectionHeaders,
|
|
14
|
+
getConnectionHeaders,
|
|
14
15
|
buildURL
|
|
15
16
|
} = require('./lib/utils')
|
|
16
17
|
|
|
@@ -89,6 +90,18 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
89
90
|
|
|
90
91
|
const sourceHttp2 = req.httpVersionMajor === 2
|
|
91
92
|
const headers = sourceHttp2 ? filterPseudoHeaders(req.headers) : { ...req.headers }
|
|
93
|
+
|
|
94
|
+
// Strip client-supplied Connection header and all header names listed in it
|
|
95
|
+
// before rewriteRequestHeaders runs. This prevents clients from stripping
|
|
96
|
+
// headers added by the proxy itself.
|
|
97
|
+
const connectionHeaderNames = getConnectionHeaders(headers)
|
|
98
|
+
if (headers.connection || connectionHeaderNames.length > 0) {
|
|
99
|
+
delete headers.connection
|
|
100
|
+
for (let i = 0; i < connectionHeaderNames.length; i++) {
|
|
101
|
+
delete headers[connectionHeaderNames[i]]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
headers.host = url.host
|
|
93
106
|
const qs = getQueryString(url.search, req.url, opts, this.request)
|
|
94
107
|
let body = ''
|
|
@@ -250,11 +263,13 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
|
|
|
250
263
|
|
|
251
264
|
function getQueryString (search, reqUrl, opts, request) {
|
|
252
265
|
if (typeof opts.queryString === 'function') {
|
|
253
|
-
|
|
266
|
+
const qs = opts.queryString(search, reqUrl, request)
|
|
267
|
+
return qs ? '?' + qs : ''
|
|
254
268
|
}
|
|
255
269
|
|
|
256
270
|
if (opts.queryString) {
|
|
257
|
-
|
|
271
|
+
const qs = querystring.stringify(opts.queryString)
|
|
272
|
+
return qs ? '?' + qs : ''
|
|
258
273
|
}
|
|
259
274
|
|
|
260
275
|
if (search.length > 0) {
|
package/lib/utils.js
CHANGED
|
@@ -92,7 +92,12 @@ function stripHttp1ConnectionHeaders (headers) {
|
|
|
92
92
|
|
|
93
93
|
// issue ref: https://github.com/fastify/fast-proxy/issues/42
|
|
94
94
|
function buildURL (source, reqBase) {
|
|
95
|
-
|
|
95
|
+
// Check for path traversal: '..' must be a complete path segment to be an
|
|
96
|
+
// attack. A bare substring match incorrectly rejects legitimate URLs that
|
|
97
|
+
// contain '...' (e.g. Next.js catch-all routes like '[...slug]').
|
|
98
|
+
// See: https://github.com/fastify/fastify-reply-from/issues/460
|
|
99
|
+
const decoded = decodeURIComponent(source)
|
|
100
|
+
if (decoded === '..' || decoded.includes('/..') || decoded.includes('../')) {
|
|
96
101
|
const err = new Error('source/request contain invalid characters')
|
|
97
102
|
err.statusCode = 400
|
|
98
103
|
throw err
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/reply-from",
|
|
3
|
-
"version": "12.6.
|
|
3
|
+
"version": "12.6.2",
|
|
4
4
|
"description": "forward your HTTP request to another server, for fastify",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -63,14 +63,14 @@
|
|
|
63
63
|
"@sinonjs/fake-timers": "^15.0.0",
|
|
64
64
|
"@types/node": "^25.0.3",
|
|
65
65
|
"@types/tap": "^18.0.0",
|
|
66
|
-
"c8": "^
|
|
66
|
+
"c8": "^11.0.0",
|
|
67
67
|
"eslint": "^9.17.0",
|
|
68
68
|
"fastify": "^5.0.0",
|
|
69
69
|
"form-data": "^4.0.0",
|
|
70
70
|
"h2url": "^0.2.0",
|
|
71
|
-
"neostandard": "^0.
|
|
71
|
+
"neostandard": "^0.13.0",
|
|
72
72
|
"nock": "^14.0.0",
|
|
73
|
-
"proxy": "^
|
|
73
|
+
"proxy": "^4.0.0",
|
|
74
74
|
"proxyquire": "^2.1.3",
|
|
75
75
|
"split2": "^4.2.0",
|
|
76
76
|
"tsd": "^0.33.0"
|
package/test/build-url.test.js
CHANGED
|
@@ -77,6 +77,15 @@ test('should throw when trying to override base', async (t) => {
|
|
|
77
77
|
await Promise.all(promises)
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
test('should not throw on URLs containing "..." (e.g. Next.js catch-all routes)', (t) => {
|
|
81
|
+
t.plan(2)
|
|
82
|
+
// Next.js catch-all routes like [...slug] appear percent-encoded in static chunk URLs.
|
|
83
|
+
// These contain '...' as a substring but are not path traversal.
|
|
84
|
+
// See: https://github.com/fastify/fastify-reply-from/issues/460
|
|
85
|
+
t.assert.doesNotThrow(() => buildURL('/_next/static/chunks/pages/%5B...slug%5D.js', 'http://localhost'))
|
|
86
|
+
t.assert.doesNotThrow(() => buildURL('/_next/static/chunks/pages/%5B%5B...slug%5D%5D.js', 'http://localhost'))
|
|
87
|
+
})
|
|
88
|
+
|
|
80
89
|
test('should throw on path traversal attempts', (t) => {
|
|
81
90
|
t.assert.throws(
|
|
82
91
|
() => buildURL('/foo/bar/../', 'http://localhost'),
|
|
@@ -0,0 +1,41 @@
|
|
|
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('queryString empty object should not append trailing ?', async (t) => {
|
|
12
|
+
t.plan(4)
|
|
13
|
+
t.after(() => instance.close())
|
|
14
|
+
|
|
15
|
+
const target = http.createServer((req, res) => {
|
|
16
|
+
t.assert.ok('request proxied')
|
|
17
|
+
t.assert.strictEqual(req.url, '/world')
|
|
18
|
+
res.statusCode = 200
|
|
19
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
20
|
+
res.end('hello world')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
instance.get('/hello', (_request, reply) => {
|
|
24
|
+
reply.from(`http://localhost:${target.address().port}/world`, {
|
|
25
|
+
queryString: {}
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
t.after(() => target.close())
|
|
30
|
+
|
|
31
|
+
await new Promise(resolve => target.listen({ port: 0 }, resolve))
|
|
32
|
+
|
|
33
|
+
instance.register(From)
|
|
34
|
+
|
|
35
|
+
await new Promise(resolve => instance.listen({ port: 0 }, resolve))
|
|
36
|
+
|
|
37
|
+
const result = await request(`http://localhost:${instance.server.address().port}/hello`)
|
|
38
|
+
|
|
39
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
40
|
+
t.assert.strictEqual(await result.body.text(), 'hello world')
|
|
41
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
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('queryString function returning empty string should not append trailing ?', async (t) => {
|
|
12
|
+
t.plan(4)
|
|
13
|
+
t.after(() => instance.close())
|
|
14
|
+
|
|
15
|
+
const target = http.createServer((req, res) => {
|
|
16
|
+
t.assert.ok('request proxied')
|
|
17
|
+
t.assert.strictEqual(req.url, '/world')
|
|
18
|
+
res.statusCode = 200
|
|
19
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
20
|
+
res.end('hello world')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
instance.get('/hello', (_request, reply) => {
|
|
24
|
+
reply.from(`http://localhost:${target.address().port}/world`, {
|
|
25
|
+
queryString () {
|
|
26
|
+
return ''
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
t.after(() => target.close())
|
|
32
|
+
|
|
33
|
+
await new Promise(resolve => target.listen({ port: 0 }, resolve))
|
|
34
|
+
|
|
35
|
+
instance.register(From)
|
|
36
|
+
|
|
37
|
+
await new Promise(resolve => instance.listen({ port: 0 }, resolve))
|
|
38
|
+
|
|
39
|
+
const result = await request(`http://localhost:${instance.server.address().port}/hello`)
|
|
40
|
+
|
|
41
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
42
|
+
t.assert.strictEqual(await result.body.text(), 'hello world')
|
|
43
|
+
})
|
|
@@ -230,3 +230,79 @@ t.test('handles Connection header with keep-alive and custom headers (undici)',
|
|
|
230
230
|
t.assert.strictEqual(result.statusCode, 200)
|
|
231
231
|
t.assert.strictEqual(result.body, 'ok')
|
|
232
232
|
})
|
|
233
|
+
|
|
234
|
+
t.test('does not strip headers added by rewriteRequestHeaders (undici)', async (t) => {
|
|
235
|
+
t.plan(4)
|
|
236
|
+
const instance = Fastify()
|
|
237
|
+
instance.register(From)
|
|
238
|
+
|
|
239
|
+
t.after(() => instance.close())
|
|
240
|
+
|
|
241
|
+
let seenForwardedBy
|
|
242
|
+
const target = http.createServer((req, res) => {
|
|
243
|
+
seenForwardedBy = req.headers['x-forwarded-by']
|
|
244
|
+
t.assert.ok('request proxied')
|
|
245
|
+
res.statusCode = 200
|
|
246
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
247
|
+
res.end('ok')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
instance.get('/', (_request, reply) => {
|
|
251
|
+
reply.from(`http://localhost:${target.address().port}`, {
|
|
252
|
+
rewriteRequestHeaders: (_request, headers) => {
|
|
253
|
+
return { ...headers, 'x-forwarded-by': 'fastify-proxy' }
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
t.after(() => target.close())
|
|
259
|
+
|
|
260
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
261
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
262
|
+
|
|
263
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
264
|
+
Connection: 'X-Forwarded-By'
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
268
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
269
|
+
t.assert.strictEqual(seenForwardedBy, 'fastify-proxy', 'X-Forwarded-By should not be stripped')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
t.test('does not strip headers added by rewriteRequestHeaders (http)', async (t) => {
|
|
273
|
+
t.plan(4)
|
|
274
|
+
const instance = Fastify()
|
|
275
|
+
instance.register(From, { undici: false })
|
|
276
|
+
|
|
277
|
+
t.after(() => instance.close())
|
|
278
|
+
|
|
279
|
+
let seenForwardedBy
|
|
280
|
+
const target = http.createServer((req, res) => {
|
|
281
|
+
seenForwardedBy = req.headers['x-forwarded-by']
|
|
282
|
+
t.assert.ok('request proxied')
|
|
283
|
+
res.statusCode = 200
|
|
284
|
+
res.setHeader('Content-Type', 'text/plain')
|
|
285
|
+
res.end('ok')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
instance.get('/', (_request, reply) => {
|
|
289
|
+
reply.from(`http://localhost:${target.address().port}`, {
|
|
290
|
+
rewriteRequestHeaders: (_request, headers) => {
|
|
291
|
+
return { ...headers, 'x-forwarded-by': 'fastify-proxy' }
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
t.after(() => target.close())
|
|
297
|
+
|
|
298
|
+
await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
|
|
299
|
+
await new Promise((resolve) => target.listen({ port: 0 }, resolve))
|
|
300
|
+
|
|
301
|
+
const result = await makeRequest(instance.server.address().port, {
|
|
302
|
+
Connection: 'X-Forwarded-By'
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
t.assert.strictEqual(result.statusCode, 200)
|
|
306
|
+
t.assert.strictEqual(result.body, 'ok')
|
|
307
|
+
t.assert.strictEqual(seenForwardedBy, 'fastify-proxy', 'X-Forwarded-By should not be stripped')
|
|
308
|
+
})
|
package/.github/stale.yml
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Number of days of inactivity before an issue becomes stale
|
|
2
|
-
daysUntilStale: 15
|
|
3
|
-
# Number of days of inactivity before a stale issue is closed
|
|
4
|
-
daysUntilClose: 7
|
|
5
|
-
# Issues with these labels will never be considered stale
|
|
6
|
-
exemptLabels:
|
|
7
|
-
- "discussion"
|
|
8
|
-
- "feature request"
|
|
9
|
-
- "bug"
|
|
10
|
-
- "help wanted"
|
|
11
|
-
- "plugin suggestion"
|
|
12
|
-
- "good first issue"
|
|
13
|
-
# Label to use when marking an issue as stale
|
|
14
|
-
staleLabel: stale
|
|
15
|
-
# Comment to post when marking an issue as stale. Set to `false` to disable
|
|
16
|
-
markComment: >
|
|
17
|
-
This issue has been automatically marked as stale because it has not had
|
|
18
|
-
recent activity. It will be closed if no further activity occurs. Thank you
|
|
19
|
-
for your contributions.
|
|
20
|
-
# Comment to post when closing a stale issue. Set to `false` to disable
|
|
21
|
-
closeComment: false
|
package/.taprc
DELETED
package/CLAUDE.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
-
|
|
5
|
-
## Development Commands
|
|
6
|
-
|
|
7
|
-
### Testing
|
|
8
|
-
- `npm test` - Run all tests (unit + TypeScript)
|
|
9
|
-
- `npm run test:unit` - Run unit tests with coverage using c8 and Node.js test runner
|
|
10
|
-
- `npm run test:typescript` - Run TypeScript definition tests using tsd
|
|
11
|
-
|
|
12
|
-
### Linting
|
|
13
|
-
- `npm run lint` - Run ESLint to check code style
|
|
14
|
-
- `npm run lint:fix` - Run ESLint with automatic fixes
|
|
15
|
-
- ESLint configuration uses neostandard with TypeScript support
|
|
16
|
-
|
|
17
|
-
### Building
|
|
18
|
-
- No build step required - this is a CommonJS library
|
|
19
|
-
|
|
20
|
-
## Architecture Overview
|
|
21
|
-
|
|
22
|
-
This is a Fastify plugin for proxying HTTP requests to upstream servers. The plugin decorates the Fastify reply with a `from()` method.
|
|
23
|
-
|
|
24
|
-
### Key Components
|
|
25
|
-
|
|
26
|
-
**Main Plugin (`index.js`)**
|
|
27
|
-
- Entry point that registers the plugin using fastify-plugin
|
|
28
|
-
- Decorates `reply.from(source, opts)` method
|
|
29
|
-
- Handles request proxying, retries, caching, and error handling
|
|
30
|
-
- Supports HTTP/1.1, HTTP/2, and Undici client strategies
|
|
31
|
-
|
|
32
|
-
**Request Builder (`lib/request.js`)**
|
|
33
|
-
- Factory for creating request handlers based on transport (HTTP/1.1, HTTP/2, Undici)
|
|
34
|
-
- Handles different agent configurations (HTTP, HTTPS, Unix sockets)
|
|
35
|
-
- Manages timeouts, connection pooling, and retry logic
|
|
36
|
-
- Returns unified interface: `{ request, close, retryOnError }`
|
|
37
|
-
|
|
38
|
-
**Utilities (`lib/utils.js`)**
|
|
39
|
-
- `filterPseudoHeaders()` - Removes HTTP/2 pseudo-headers (starting with :)
|
|
40
|
-
- `copyHeaders()` - Copies headers to Fastify reply
|
|
41
|
-
- `stripHttp1ConnectionHeaders()` - Removes HTTP/1 connection-specific headers for HTTP/2
|
|
42
|
-
- `buildURL()` - Constructs destination URLs with base path validation
|
|
43
|
-
|
|
44
|
-
**Error Definitions (`lib/errors.js`)**
|
|
45
|
-
- Standardized error types using @fastify/error
|
|
46
|
-
- Maps different error conditions to appropriate HTTP status codes
|
|
47
|
-
- Includes timeout, connection reset, and gateway errors
|
|
48
|
-
|
|
49
|
-
### Transport Strategies
|
|
50
|
-
|
|
51
|
-
1. **Undici (default)** - High-performance HTTP/1.1 client with connection pooling
|
|
52
|
-
2. **HTTP/2** - Native Node.js HTTP/2 client for modern protocols
|
|
53
|
-
3. **HTTP/1.1** - Node.js built-in http/https modules with custom agents
|
|
54
|
-
|
|
55
|
-
### Key Features
|
|
56
|
-
|
|
57
|
-
- **Caching**: URL parsing cache using toad-cache LruMap (default 100 entries)
|
|
58
|
-
- **Retries**: Configurable retry logic for socket hangups and 503 errors
|
|
59
|
-
- **Headers**: Smart header rewriting for HTTP/1.1 ↔ HTTP/2 compatibility
|
|
60
|
-
- **Body handling**: Supports streams, JSON encoding, and custom content types
|
|
61
|
-
- **Unix sockets**: Special handling for unix+http: and unix+https: protocols
|
|
62
|
-
- **Timeouts**: Per-request and session-level timeout configuration
|
|
63
|
-
|
|
64
|
-
### Configuration
|
|
65
|
-
|
|
66
|
-
The plugin accepts extensive configuration for different transport methods, retry behavior, caching, and request/response transformation hooks. See README.md for full option documentation.
|