Skip to content

Commit c9655f0

Browse files
authored
fix: detect and prevent redirect loops with Client/Pool (#4361)
1 parent b7513d4 commit c9655f0

File tree

2 files changed

+133
-0
lines changed

2 files changed

+133
-0
lines changed

lib/handler/redirect-handler.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ class RedirectHandler {
133133
const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
134134
const path = search ? `${pathname}${search}` : pathname
135135

136+
// Check for redirect loops by seeing if we've already visited this URL in our history
137+
// This catches the case where Client/Pool try to handle cross-origin redirects but fail
138+
// and keep redirecting to the same URL in an infinite loop
139+
const redirectUrlString = `${origin}${path}`
140+
for (const historyUrl of this.history) {
141+
if (historyUrl.toString() === redirectUrlString) {
142+
throw new InvalidArgumentError(`Redirect loop detected. Cannot redirect to ${origin}. This typically happens when using a Client or Pool with cross-origin redirects. Use an Agent for cross-origin redirects.`)
143+
}
144+
}
145+
136146
// Remove headers referring to the original URL.
137147
// By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
138148
// https://tools.ietf.org/html/rfc7231#section-6.4
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use strict'
2+
3+
const { test, after } = require('node:test')
4+
const { createServer } = require('node:http')
5+
const { once } = require('node:events')
6+
const { tspl } = require('@matteo.collina/tspl')
7+
const undici = require('../..')
8+
9+
const {
10+
interceptors: { redirect }
11+
} = undici
12+
13+
test('Client should throw redirect loop error for cross-origin redirect', async (t) => {
14+
t = tspl(t, { plan: 2 })
15+
16+
const serverA = createServer((req, res) => {
17+
res.writeHead(301, {
18+
Location: 'http://localhost:9999/target' // Different port = cross-origin
19+
})
20+
res.end()
21+
})
22+
23+
serverA.listen(0)
24+
after(() => serverA.close())
25+
await once(serverA, 'listening')
26+
27+
const client = new undici.Client(`http://localhost:${serverA.address().port}`).compose(
28+
redirect({ maxRedirections: 2 }) // Keep low to avoid long waits
29+
)
30+
after(() => client.close())
31+
32+
try {
33+
await client.request({
34+
method: 'GET',
35+
path: '/test'
36+
})
37+
t.fail('Expected error but request succeeded')
38+
} catch (error) {
39+
t.ok(error.message.includes('Redirect loop detected'), 'Error message indicates redirect loop')
40+
t.ok(error.message.includes('Client or Pool'), 'Error message mentions Client or Pool')
41+
}
42+
43+
await t.completed
44+
})
45+
46+
test('Pool should throw redirect loop error for cross-origin redirect', async (t) => {
47+
t = tspl(t, { plan: 2 })
48+
49+
const serverA = createServer((req, res) => {
50+
res.writeHead(301, {
51+
Location: 'http://localhost:9998/target' // Different port = cross-origin
52+
})
53+
res.end()
54+
})
55+
56+
serverA.listen(0)
57+
after(() => serverA.close())
58+
await once(serverA, 'listening')
59+
60+
const pool = new undici.Pool(`http://localhost:${serverA.address().port}`).compose(
61+
redirect({ maxRedirections: 2 }) // Keep low to avoid long waits
62+
)
63+
after(() => pool.close())
64+
65+
try {
66+
await pool.request({
67+
method: 'GET',
68+
path: '/test'
69+
})
70+
t.fail('Expected error but request succeeded')
71+
} catch (error) {
72+
t.ok(error.message.includes('Redirect loop detected'), 'Error message indicates redirect loop')
73+
t.ok(error.message.includes('Client or Pool'), 'Error message mentions Client or Pool')
74+
}
75+
76+
await t.completed
77+
})
78+
79+
test('Agent should successfully follow cross-origin redirect', async (t) => {
80+
t = tspl(t, { plan: 2 })
81+
82+
const serverB = createServer((req, res) => {
83+
res.writeHead(200)
84+
res.end('Cross-origin redirect success')
85+
})
86+
87+
const serverA = createServer((req, res) => {
88+
res.writeHead(301, {
89+
Location: `http://localhost:${serverB.address().port}/success`
90+
})
91+
res.end()
92+
})
93+
94+
serverA.listen(0)
95+
serverB.listen(0)
96+
after(() => {
97+
serverA.close()
98+
serverB.close()
99+
})
100+
101+
await Promise.all([
102+
once(serverA, 'listening'),
103+
once(serverB, 'listening')
104+
])
105+
106+
const agent = new undici.Agent().compose(
107+
redirect({ maxRedirections: 2 })
108+
)
109+
after(() => agent.close())
110+
111+
const response = await agent.request({
112+
origin: `http://localhost:${serverA.address().port}`,
113+
method: 'GET',
114+
path: '/test'
115+
})
116+
117+
const body = await response.body.text()
118+
119+
t.strictEqual(response.statusCode, 200, 'Response has 200 status code')
120+
t.ok(body.includes('Cross-origin redirect success'), 'Response body indicates successful cross-origin redirect')
121+
122+
await t.completed
123+
})

0 commit comments

Comments
 (0)