@fastify/reply-from 12.6.3 → 12.6.4

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/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/index.js CHANGED
@@ -81,7 +81,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
81
81
  const dest = getUpstream(this.request, base)
82
82
  let url
83
83
  if (cache) {
84
- const cacheKey = dest + source
84
+ const cacheKey = JSON.stringify([dest, source])
85
85
  url = cache.get(cacheKey) || buildURL(source, dest)
86
86
  cache.set(cacheKey, url)
87
87
  } else {
@@ -231,10 +231,14 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
231
231
  onError(this, { error: new BadGatewayError() })
232
232
  this.request.log.warn(err, 'response has invalid status code')
233
233
  }
234
- if (this.request.raw.aborted && isHttp2) {
234
+ if (this.request.raw.aborted) {
235
235
  // the request could have been canceled before we got a response from the target
236
- // forward this to the upstream server and close the stream to prevent leaks
237
- res.stream.close(NGHTTP2_CANCEL)
236
+ // clean up the upstream stream to prevent leaks and skip sending a reply
237
+ if (isHttp2) {
238
+ res.stream.close(NGHTTP2_CANCEL)
239
+ } else {
240
+ res.stream.destroy()
241
+ }
238
242
  // no need to send a reply for aborted requests or call the onResponse callback
239
243
  return
240
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/reply-from",
3
- "version": "12.6.3",
3
+ "version": "12.6.4",
4
4
  "description": "forward your HTTP request to another server, for fastify",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,219 @@
1
+ 'use strict'
2
+
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const From = require('..')
6
+
7
+ // GHSA-v574-6498-x57v: Cache key collision via ambiguous concatenation.
8
+ // dest + source was used as the cache key. Different (dest, source) pairs
9
+ // could produce identical concatenations while resolving to different URLs,
10
+ // causing a request to be forwarded to the wrong upstream.
11
+ //
12
+ // dest = http://127.0.0.1:31001 , source = /private
13
+ // dest = http://127.0.0.1:3100 , source = 1/private
14
+ //
15
+ // Both concatenate to "http://127.0.0.1:31001/private" but resolve to
16
+ // different URLs.
17
+
18
+ async function run (t) {
19
+ // victim upstream on port 31001 with a private route and a writable route
20
+ const victim = Fastify({ keepAliveTimeout: 1 })
21
+ victim.get('/private', async () => ({ marker: 'VICTIM_PRIVATE_DATA' }))
22
+ victim.get('/state', async () => ({ written: victim.written }))
23
+ victim.post('/private', async (req, reply) => {
24
+ victim.written = req.body
25
+ return { marker: 'VICTIM_PRIVATE_DATA', written: victim.written }
26
+ })
27
+ t.after(() => victim.close())
28
+ await victim.listen({ port: 31001 })
29
+
30
+ // attacker upstream on port 3100
31
+ const attacker = Fastify({ keepAliveTimeout: 1 })
32
+ attacker.get('/1/private', async () => ({ marker: 'ATTACKER_UPSTREAM' }))
33
+ attacker.post('/1/private', async (req, reply) => {
34
+ return { marker: 'ATTACKER_UPSTREAM', written: req.body }
35
+ })
36
+ t.after(() => attacker.close())
37
+ await attacker.listen({ port: 3100 })
38
+
39
+ // proxy using getUpstream() and default cache
40
+ const proxy = Fastify({ keepAliveTimeout: 1 })
41
+ proxy.register(From, {
42
+ base: 'http://localhost',
43
+ http: true
44
+ })
45
+
46
+ proxy.get('/proxy/victim/private', (req, reply) => {
47
+ reply.from('/private', {
48
+ getUpstream () {
49
+ return 'http://127.0.0.1:31001'
50
+ }
51
+ })
52
+ })
53
+
54
+ proxy.post('/proxy/victim/write', (req, reply) => {
55
+ reply.from('/private', {
56
+ method: 'POST',
57
+ body: JSON.stringify(req.body),
58
+ contentType: 'application/json',
59
+ getUpstream () {
60
+ return 'http://127.0.0.1:31001'
61
+ }
62
+ })
63
+ })
64
+
65
+ proxy.get('/proxy/attacker/private', (req, reply) => {
66
+ reply.from('1/private', {
67
+ getUpstream () {
68
+ return 'http://127.0.0.1:3100'
69
+ }
70
+ })
71
+ })
72
+
73
+ proxy.post('/proxy/attacker/write', (req, reply) => {
74
+ reply.from('1/private', {
75
+ method: 'POST',
76
+ body: JSON.stringify(req.body),
77
+ contentType: 'application/json',
78
+ getUpstream () {
79
+ return 'http://127.0.0.1:3100'
80
+ }
81
+ })
82
+ })
83
+
84
+ proxy.get('/proxy/victim/state', (req, reply) => {
85
+ reply.from('/state', {
86
+ getUpstream () {
87
+ return 'http://127.0.0.1:31001'
88
+ }
89
+ })
90
+ })
91
+
92
+ t.after(() => proxy.close())
93
+ await proxy.listen({ port: 31000 })
94
+
95
+ // prime the cache with the victim entry (dest = "http://127.0.0.1:31001", source = "/private")
96
+ const primeRes = await fetch('http://127.0.0.1:31000/proxy/victim/private')
97
+ t.assert.strictEqual(primeRes.status, 200)
98
+
99
+ // now the attacker pair (dest = "http://127.0.0.1:3100", source = "1/private")
100
+ // should NOT reuse the cached URL — it must resolve to the attacker upstream.
101
+
102
+ // 1. attacker read — should return ATTACKER_UPSTREAM, not VICTIM_PRIVATE_DATA
103
+ const attackerReadRes = await fetch('http://127.0.0.1:31000/proxy/attacker/private')
104
+ t.assert.strictEqual(attackerReadRes.status, 200)
105
+ const attackerReadBody = await attackerReadRes.json()
106
+ t.assert.strictEqual(
107
+ attackerReadBody.marker,
108
+ 'ATTACKER_UPSTREAM',
109
+ 'attacker request must reach attacker upstream, not victim'
110
+ )
111
+
112
+ // 2. attacker write — should write to attacker, not victim
113
+ const attackerWriteRes = await fetch('http://127.0.0.1:31000/proxy/attacker/write', {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ changedBy: 'attacker' })
117
+ })
118
+ t.assert.strictEqual(attackerWriteRes.status, 200)
119
+
120
+ // 3. verify victim state is unchanged (victim.written should be undefined/null)
121
+ const victimStateRes = await fetch('http://127.0.0.1:31000/proxy/victim/state')
122
+ t.assert.strictEqual(victimStateRes.status, 200)
123
+ const victimStateBody = await victimStateRes.json()
124
+ t.assert.strictEqual(
125
+ victimStateBody.written,
126
+ undefined,
127
+ 'victim state must not be affected by attacker write'
128
+ )
129
+ }
130
+
131
+ t.test('GHSA-v574-6498-x57v: cache key collision via ambiguous concatenation', async (t) => {
132
+ t.plan(6)
133
+ await run(t)
134
+ })
135
+
136
+ t.test('GHSA-v574-6498-x57v: cache key delimiter cannot be injected', async (t) => {
137
+ const victim = Fastify()
138
+ victim.get('/*', async (req) => ({ marker: 'VICTIM', path: req.url }))
139
+ t.after(() => victim.close())
140
+ const victimOrigin = await victim.listen({ host: '127.0.0.1', port: 0 })
141
+
142
+ const attacker = Fastify()
143
+ attacker.get('/*', async (req) => ({ marker: 'ATTACKER', path: req.url }))
144
+ t.after(() => attacker.close())
145
+ const attackerOrigin = await attacker.listen({ host: '127.0.0.1', port: 0 })
146
+
147
+ const victimPair = {
148
+ source: 'private',
149
+ dest: `${victimOrigin}/|${attackerOrigin}/`
150
+ }
151
+ const attackerPair = {
152
+ source: `private|${victimOrigin}/`,
153
+ dest: `${attackerOrigin}/`
154
+ }
155
+
156
+ // These pairs collide when the key is source + '|' + dest.
157
+ t.assert.strictEqual(
158
+ victimPair.source + '|' + victimPair.dest,
159
+ attackerPair.source + '|' + attackerPair.dest
160
+ )
161
+
162
+ const proxy = Fastify()
163
+ proxy.register(From, {
164
+ base: 'http://localhost',
165
+ http: true
166
+ })
167
+ proxy.get('/prime', (req, reply) => {
168
+ reply.from(victimPair.source, {
169
+ getUpstream: () => victimPair.dest
170
+ })
171
+ })
172
+ proxy.get('/collide', (req, reply) => {
173
+ reply.from(attackerPair.source, {
174
+ getUpstream: () => attackerPair.dest
175
+ })
176
+ })
177
+ t.after(() => proxy.close())
178
+
179
+ const primeResponse = await proxy.inject('/prime')
180
+ t.assert.strictEqual(primeResponse.statusCode, 200)
181
+ t.assert.strictEqual(primeResponse.json().marker, 'VICTIM')
182
+
183
+ const attackerResponse = await proxy.inject('/collide')
184
+ t.assert.strictEqual(attackerResponse.statusCode, 200)
185
+ t.assert.strictEqual(
186
+ attackerResponse.json().marker,
187
+ 'ATTACKER',
188
+ 'attacker request must not reuse the victim cache entry'
189
+ )
190
+ })
191
+
192
+ t.test('GHSA-v574-6498-x57v: cache key distinguishes an absent base', async (t) => {
193
+ const target = Fastify()
194
+ target.get('/private', async () => ({ marker: 'TARGET' }))
195
+ t.after(() => target.close())
196
+ const targetOrigin = await target.listen({ host: '127.0.0.1', port: 0 })
197
+ const source = `${targetOrigin}/private`
198
+
199
+ const proxy = Fastify()
200
+ proxy.register(From, { http: true })
201
+ proxy.get('/prime', (req, reply) => reply.from(source))
202
+ proxy.get('/invalid', (req, reply) => {
203
+ reply.from(source, {
204
+ getUpstream: () => 'undefined'
205
+ })
206
+ })
207
+ t.after(() => proxy.close())
208
+
209
+ const primeResponse = await proxy.inject('/prime')
210
+ t.assert.strictEqual(primeResponse.statusCode, 200)
211
+ t.assert.strictEqual(primeResponse.json().marker, 'TARGET')
212
+
213
+ const invalidResponse = await proxy.inject('/invalid')
214
+ t.assert.strictEqual(
215
+ invalidResponse.statusCode,
216
+ 500,
217
+ 'invalid upstream must not reuse the entry cached without a base'
218
+ )
219
+ })
@@ -0,0 +1,61 @@
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
+ const { once } = require('node:events')
8
+
9
+ // See https://github.com/fastify/fastify-reply-from/issues/419
10
+ // When a client aborts an HTTP/1 request before the upstream target responds,
11
+ // reply-from must not attempt to forward the (late) upstream response: it should
12
+ // neither invoke the onResponse callback nor pipe the upstream body to the
13
+ // already-closed reply. The equivalent HTTP/2 path was already handled; this
14
+ // covers the HTTP/1 path.
15
+ t.test('does not forward the upstream response when the HTTP/1 request was aborted', async (t) => {
16
+ t.plan(1)
17
+
18
+ // Target that delays its response long enough for the client to abort first.
19
+ const target = http.createServer((_req, res) => {
20
+ setTimeout(() => {
21
+ res.statusCode = 200
22
+ res.end('hello world')
23
+ }, 500)
24
+ })
25
+ t.after(() => target.close())
26
+ target.listen({ port: 0 })
27
+ await once(target, 'listening')
28
+
29
+ const instance = Fastify()
30
+ t.after(() => instance.close())
31
+
32
+ let onResponseCalled = false
33
+ instance.register(From)
34
+ instance.get('/', (_request, reply) => {
35
+ reply.from(`http://localhost:${target.address().port}`, {
36
+ onResponse (_req, replyInner, res) {
37
+ onResponseCalled = true
38
+ replyInner.send(res.stream)
39
+ }
40
+ })
41
+ })
42
+
43
+ instance.listen({ port: 0 })
44
+ await once(instance.server, 'listening')
45
+
46
+ await new Promise((resolve) => {
47
+ const req = http.request({
48
+ host: 'localhost',
49
+ port: instance.server.address().port,
50
+ path: '/'
51
+ }, (res) => { res.resume() })
52
+ req.on('error', () => {})
53
+ req.end()
54
+ // Abort well before the target responds.
55
+ setTimeout(() => req.destroy(), 50)
56
+ // Wait past the target's response so any (buggy) forwarding would have happened.
57
+ setTimeout(resolve, 800)
58
+ })
59
+
60
+ t.assert.strictEqual(onResponseCalled, false, 'onResponse must not run for an aborted request')
61
+ })