@fastify/reply-from 12.3.1 → 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.
@@ -14,6 +14,11 @@ on:
14
14
  - 'docs/**'
15
15
  - '*.md'
16
16
 
17
+ # This allows a subsequently queued workflow run to interrupt previous runs
18
+ concurrency:
19
+ group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
20
+ cancel-in-progress: true
21
+
17
22
  permissions:
18
23
  contents: read
19
24
 
package/CLAUDE.md ADDED
@@ -0,0 +1,66 @@
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.
package/README.md CHANGED
@@ -13,9 +13,6 @@ HTTP2 to HTTP is supported too.
13
13
  npm i @fastify/reply-from
14
14
  ```
15
15
 
16
- ## Compatibility with @fastify/multipart
17
- `@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins that have a parent-child relationship.`<br>` The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.`<br>` This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.`<br>`
18
- 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.
19
16
 
20
17
  ## Usage
21
18
 
@@ -518,14 +515,43 @@ This library has:
518
515
  When a timeout happens, [`504 Gateway Timeout`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
519
516
  will be returned to the client.
520
517
 
521
- ## TODO
518
+ ## Compatibility with @fastify/multipart
519
+
520
+ `@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins that have a parent-child relationship.
521
+
522
+ The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.
523
+ This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.
524
+
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
+
527
+ ### Proxying specific content types without parsing
528
+
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
+
531
+ ```js
532
+ // Register custom content type parsers that pass raw body through
533
+ fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
534
+ done(null, body)
535
+ })
536
+
537
+ fastify.addContentTypeParser('text/event-stream', function (req, body, done) {
538
+ done(null, body)
539
+ })
540
+
541
+ fastify.register(require('@fastify/reply-from'))
542
+
543
+ fastify.post('/upload', (request, reply) => {
544
+ // The multipart data will be proxied as-is to the upstream server
545
+ reply.from('http://upstream-server.com/upload')
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
+ })
552
+ ```
522
553
 
523
- * [ ] support overriding the body with a stream
524
- * [ ] forward the request id to the other peer might require some
525
- refactoring because we have to make the `req.id` unique
526
- (see [hyperid](https://npm.im/hyperid)).
527
- * [ ] Support origin HTTP2 push
528
- * [X] benchmarks
554
+ This approach allows these content types to be proxied correctly while avoiding parsing that would consume the request body.
529
555
 
530
556
  ## License
531
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
@@ -285,7 +287,7 @@ function onErrorDefault (reply, { error }) {
285
287
  }
286
288
 
287
289
  function isFastifyMultipartRegistered (fastify) {
288
- return fastify.hasContentTypeParser('multipart/form-data')
290
+ return fastify.hasPlugin('@fastify/multipart')
289
291
  }
290
292
 
291
293
  function createRequestRetry (requestImpl, reply, retryHandler) {
package/lib/utils.js CHANGED
@@ -42,12 +42,17 @@ function stripHttp1ConnectionHeaders (headers) {
42
42
  case 'connection':
43
43
  case 'upgrade':
44
44
  case 'http2-settings':
45
- case 'te':
46
45
  case 'transfer-encoding':
47
46
  case 'proxy-connection':
48
47
  case 'keep-alive':
49
48
  case 'host':
50
49
  break
50
+ case 'te':
51
+ // see illegal connection specific header handling in Node.js
52
+ if (headers['te'] === 'trailers') {
53
+ dest[header] = headers[header]
54
+ }
55
+ break
51
56
  default:
52
57
  dest[header] = headers[header]
53
58
  break
@@ -58,6 +63,12 @@ function stripHttp1ConnectionHeaders (headers) {
58
63
 
59
64
  // issue ref: https://github.com/fastify/fast-proxy/issues/42
60
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
+
61
72
  if (Array.isArray(reqBase)) reqBase = reqBase[0]
62
73
  let baseOrigin = reqBase ? new URL(reqBase).href : undefined
63
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/reply-from",
3
- "version": "12.3.1",
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",
@@ -60,7 +60,6 @@
60
60
  "devDependencies": {
61
61
  "@fastify/formbody": "^8.0.0",
62
62
  "@fastify/multipart": "^9.0.0",
63
- "@fastify/pre-commit": "^2.1.0",
64
63
  "@sinonjs/fake-timers": "^14.0.0",
65
64
  "@types/node": "^24.0.8",
66
65
  "@types/tap": "^18.0.0",
@@ -74,7 +73,7 @@
74
73
  "proxy": "^2.1.1",
75
74
  "proxyquire": "^2.1.3",
76
75
  "split2": "^4.2.0",
77
- "tsd": "^0.32.0"
76
+ "tsd": "^0.33.0"
78
77
  },
79
78
  "dependencies": {
80
79
  "@fastify/error": "^4.0.0",
@@ -85,10 +84,6 @@
85
84
  "toad-cache": "^3.7.0",
86
85
  "undici": "^7.0.0"
87
86
  },
88
- "pre-commit": [
89
- "lint",
90
- "test"
91
- ],
92
87
  "publishConfig": {
93
88
  "access": "public"
94
89
  }
@@ -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,60 @@
1
+ 'use strict'
2
+ const h2url = require('h2url')
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const From = require('..')
6
+ const fs = require('node:fs')
7
+ const path = require('node:path')
8
+ const certs = {
9
+ key: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.key')),
10
+ cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.cert'))
11
+ }
12
+
13
+ t.test('do not strip te header if set to trailing', async (t) => {
14
+ const instance = Fastify({
15
+ http2: true,
16
+ https: certs
17
+ })
18
+
19
+ t.after(() => instance.close())
20
+
21
+ const target = Fastify({
22
+ http2: true
23
+ })
24
+
25
+ target.get('/', (request, reply) => {
26
+ t.assert.strictEqual(request.headers['te'], 'trailers')
27
+
28
+ reply.send({
29
+ hello: 'world'
30
+ })
31
+ })
32
+
33
+ instance.get('/', (_request, reply) => {
34
+ reply.from()
35
+ })
36
+
37
+ t.after(() => target.close())
38
+
39
+ await target.listen({ port: 0 })
40
+
41
+ instance.register(From, {
42
+ base: `http://localhost:${target.server.address().port}`,
43
+ http2: true,
44
+ rejectUnauthorized: false
45
+ })
46
+
47
+ await instance.listen({ port: 0 })
48
+
49
+ const { headers } = await h2url.concat({
50
+ url: `https://localhost:${instance.server.address().port}`,
51
+ headers: {
52
+ te: 'trailers'
53
+ }
54
+ })
55
+
56
+ t.assert.strictEqual(headers[':status'], 200)
57
+
58
+ instance.close()
59
+ target.close()
60
+ })
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const t = require('node:test')
6
+ const Fastify = require('fastify')
7
+ const { request } = require('undici')
8
+ const From = require('..')
9
+ const http = require('node:http')
10
+ const FormData = require('form-data')
11
+
12
+ t.test('multipart/form-data proxying with custom content type parser', async (t) => {
13
+ t.plan(7)
14
+
15
+ const filetPath = path.join(__dirname, 'fixtures', 'file.txt')
16
+
17
+ // Target server that expects multipart/form-data
18
+ const target = http.createServer((req, res) => {
19
+ t.assert.ok('request proxied')
20
+ t.assert.strictEqual(req.method, 'POST')
21
+ t.assert.match(req.headers['content-type'], /^multipart\/form-data/)
22
+
23
+ let data = ''
24
+ req.setEncoding('utf8')
25
+ req.on('data', (chunk) => {
26
+ data += chunk
27
+ })
28
+ req.on('end', () => {
29
+ // Verify the multipart data contains our form fields
30
+ t.assert.match(data, /Content-Disposition: form-data; name="key"/)
31
+ t.assert.match(data, /value/)
32
+ t.assert.match(data, /Content-Disposition: form-data; name="file"/)
33
+
34
+ res.setHeader('content-type', 'application/json')
35
+ res.statusCode = 200
36
+ res.end(JSON.stringify({ received: 'multipart data' }))
37
+ })
38
+ })
39
+
40
+ // Fastify instance with custom multipart parser (not @fastify/multipart)
41
+ const fastify = Fastify()
42
+
43
+ // Register custom content type parser for multipart/form-data
44
+ // This allows the raw body to be passed through without parsing
45
+ fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
46
+ done(null, body)
47
+ })
48
+
49
+ fastify.register(From)
50
+
51
+ fastify.post('/', (request, reply) => {
52
+ reply.from(`http://localhost:${target.address().port}`)
53
+ })
54
+
55
+ t.after(() => fastify.close())
56
+ t.after(() => target.close())
57
+
58
+ await new Promise(resolve => fastify.listen({ port: 0 }, resolve))
59
+ await new Promise(resolve => target.listen({ port: 0 }, resolve))
60
+
61
+ // Create multipart form data
62
+ const form = new FormData()
63
+ form.append('key', 'value')
64
+ form.append('file', fs.createReadStream(filetPath, { encoding: 'utf-8' }))
65
+
66
+ // Send request with multipart data
67
+ const result = await request(`http://localhost:${fastify.server.address().port}`, {
68
+ method: 'POST',
69
+ headers: form.getHeaders(),
70
+ body: form
71
+ })
72
+
73
+ t.assert.deepStrictEqual(await result.body.json(), { received: 'multipart data' })
74
+ })
@@ -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
+ })
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run lint:*)",
5
- "Bash(node:*)"
6
- ],
7
- "deny": []
8
- }
9
- }