@fastify/reply-from 12.6.1 → 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.
@@ -27,7 +27,7 @@ jobs:
27
27
  permissions:
28
28
  contents: write
29
29
  pull-requests: write
30
- uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5
30
+ uses: fastify/workflows/.github/workflows/plugins-ci.yml@v6
31
31
  with:
32
32
  license-check: true
33
33
  lint: true
@@ -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/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 = ''
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
- if (decodeURIComponent(source).includes('..')) {
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.1",
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",
@@ -68,9 +68,9 @@
68
68
  "fastify": "^5.0.0",
69
69
  "form-data": "^4.0.0",
70
70
  "h2url": "^0.2.0",
71
- "neostandard": "^0.12.0",
71
+ "neostandard": "^0.13.0",
72
72
  "nock": "^14.0.0",
73
- "proxy": "^2.1.1",
73
+ "proxy": "^4.0.0",
74
74
  "proxyquire": "^2.1.3",
75
75
  "split2": "^4.2.0",
76
76
  "tsd": "^0.33.0"
@@ -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'),
@@ -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
+ })