@fastify/reply-from 12.4.0 → 12.5.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 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 multipart/form-data without @fastify/multipart
527
+ ### Proxying specific content types without parsing
528
528
 
529
- If you need to proxy `multipart/form-data` requests without parsing them, you can use a custom content type parser instead of `@fastify/multipart`:
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 a custom content type parser for multipart/form-data
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 `multipart/form-data` to be proxied correctly while avoiding the incompatibility with `@fastify/multipart`.
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
- source = req.url
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/utils.js CHANGED
@@ -63,6 +63,12 @@ function stripHttp1ConnectionHeaders (headers) {
63
63
 
64
64
  // issue ref: https://github.com/fastify/fast-proxy/issues/42
65
65
  function buildURL (source, reqBase) {
66
+ if (decodeURIComponent(source).includes('..')) {
67
+ const err = new Error('source/request contain invalid characters')
68
+ err.statusCode = 400
69
+ throw err
70
+ }
71
+
66
72
  if (Array.isArray(reqBase)) reqBase = reqBase[0]
67
73
  let baseOrigin = reqBase ? new URL(reqBase).href : undefined
68
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/reply-from",
3
- "version": "12.4.0",
3
+ "version": "12.5.0",
4
4
  "description": "forward your HTTP request to another server, for fastify",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -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
+ })
@@ -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,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
+ })