Skip to content

Commit 48d84f7

Browse files
committed
add cross-origin redirect header stripping
1 parent d8cb0d1 commit 48d84f7

7 files changed

Lines changed: 91 additions & 26 deletions

File tree

docs/docs/api/Dispatcher.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,8 @@ Options:
995995

996996
- **maxRedirections** `number` - Maximum number of redirections allowed.
997997
- **throwOnMaxRedirect** `boolean` - Throw when the maximum number of redirections is reached.
998-
- **stripHeadersOnRedirect** `string[]` - Header names to remove from redirected requests.
998+
- **stripHeadersOnRedirect** `string[]` - Header names to remove from all redirected requests.
999+
- **stripHeadersOnCrossOriginRedirect** `string[]` - Header names to remove from cross-origin redirected requests.
9991000

10001001
**Example - Basic Redirect Interceptor**
10011002

docs/docs/api/RedirectHandler.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Arguments:
88

99
- **dispatch** `function` - The dispatch function to be called after every retry.
1010
- **maxRedirections** `number` - Maximum number of redirections allowed.
11-
- **opts** `object` - Options for handling redirection. Supports `throwOnMaxRedirect` and `stripHeadersOnRedirect`.
11+
- **opts** `object` - Options for handling redirection. Supports `throwOnMaxRedirect`, `stripHeadersOnRedirect`, and `stripHeadersOnCrossOriginRedirect`.
1212
- **handler** `object` - An object containing handlers for different stages of the request lifecycle.
1313

1414
Returns: `RedirectHandler`
@@ -19,7 +19,8 @@ Returns: `RedirectHandler`
1919
- **maxRedirections** `number` (required) - Maximum number of redirections allowed.
2020
- **opts** `object` (required) - Options for handling redirection.
2121
- **throwOnMaxRedirect** `boolean` - Throw when the maximum number of redirections is reached.
22-
- **stripHeadersOnRedirect** `string[]` - Header names to remove from redirected requests.
22+
- **stripHeadersOnRedirect** `string[]` - Header names to remove from all redirected requests.
23+
- **stripHeadersOnCrossOriginRedirect** `string[]` - Header names to remove from cross-origin redirected requests.
2324
- **handler** `object` (required) - Handlers for different stages of the request lifecycle.
2425

2526
### Properties

lib/handler/redirect-handler.js

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,13 @@ class RedirectHandler {
2727
throw new InvalidArgumentError('throwOnMaxRedirect must be a boolean')
2828
}
2929

30-
if (opts.stripHeadersOnRedirect != null && !Array.isArray(opts.stripHeadersOnRedirect)) {
31-
throw new InvalidArgumentError('stripHeadersOnRedirect must be an array')
32-
}
33-
3430
this.dispatch = dispatch
3531
this.location = null
36-
const { maxRedirections: _, stripHeadersOnRedirect, ...cleanOpts } = opts
32+
const { maxRedirections: _, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect, ...cleanOpts } = opts
3733
this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
3834
this.opts.body = util.wrapRequestBody(this.opts.body)
39-
this.stripHeadersOnRedirect = normalizeStripHeadersOnRedirect(stripHeadersOnRedirect)
35+
this.stripHeadersOnRedirect = normalizeStripHeaders(stripHeadersOnRedirect, 'stripHeadersOnRedirect')
36+
this.stripHeadersOnCrossOriginRedirect = normalizeStripHeaders(stripHeadersOnCrossOriginRedirect, 'stripHeadersOnCrossOriginRedirect')
4037
this.maxRedirections = maxRedirections
4138
this.handler = handler
4239
this.history = []
@@ -105,7 +102,7 @@ class RedirectHandler {
105102
// Remove headers referring to the original URL.
106103
// By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
107104
// https://tools.ietf.org/html/rfc7231#section-6.4
108-
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin, this.stripHeadersOnRedirect)
105+
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
109106
this.opts.path = path
110107
this.opts.origin = origin
111108
this.opts.query = null
@@ -157,53 +154,57 @@ class RedirectHandler {
157154
}
158155

159156
// https://tools.ietf.org/html/rfc7231#section-6.4.4
160-
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders) {
161-
if (header.length === 4) {
162-
return util.headerNameToString(header) === 'host'
157+
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
158+
const name = util.headerNameToString(header)
159+
if (name === 'host') {
160+
return true
163161
}
164-
if (stripHeaders?.has(util.headerNameToString(header))) {
162+
if (stripHeaders?.has(name) || (unknownOrigin && stripHeadersOnCrossOrigin?.has(name))) {
165163
return true
166164
}
167-
if (removeContent && util.headerNameToString(header).startsWith('content-')) {
165+
if (removeContent && name.startsWith('content-')) {
168166
return true
169167
}
170-
if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) {
171-
const name = util.headerNameToString(header)
168+
if (unknownOrigin) {
172169
return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'
173170
}
174171
return false
175172
}
176173

177174
// https://tools.ietf.org/html/rfc7231#section-6.4
178-
function normalizeStripHeadersOnRedirect (headers) {
175+
function normalizeStripHeaders (headers, optionName) {
179176
if (headers == null) {
180177
return null
181178
}
182179

180+
if (!Array.isArray(headers)) {
181+
throw new InvalidArgumentError(`${optionName} must be an array`)
182+
}
183+
183184
const normalized = new Set()
184185
for (const header of headers) {
185186
if (typeof header !== 'string') {
186-
throw new InvalidArgumentError('stripHeadersOnRedirect must contain header names')
187+
throw new InvalidArgumentError(`${optionName} must contain header names`)
187188
}
188189

189190
normalized.add(util.headerNameToString(header))
190191
}
191192
return normalized
192193
}
193194

194-
function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders) {
195+
function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
195196
const ret = []
196197
if (Array.isArray(headers)) {
197198
for (let i = 0; i < headers.length; i += 2) {
198-
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders)) {
199+
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
199200
ret.push(headers[i], headers[i + 1])
200201
}
201202
}
202203
} else if (headers && typeof headers === 'object') {
203204
const entries = util.hasSafeIterator(headers) ? headers : Object.entries(headers)
204205

205206
for (const [key, value] of entries) {
206-
if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders)) {
207+
if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
207208
ret.push(key, value)
208209
}
209210
}

lib/interceptor/redirect.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
const RedirectHandler = require('../handler/redirect-handler')
44

5-
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect, stripHeadersOnRedirect: defaultStripHeadersOnRedirect } = {}) {
5+
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect, stripHeadersOnRedirect: defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect: defaultStripHeadersOnCrossOriginRedirect } = {}) {
66
return (dispatch) => {
77
return function Intercept (opts, handler) {
8-
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, stripHeadersOnRedirect = defaultStripHeadersOnRedirect, ...rest } = opts
8+
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, stripHeadersOnRedirect = defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect = defaultStripHeadersOnCrossOriginRedirect, ...rest } = opts
99

1010
if (maxRedirections == null || maxRedirections === 0) {
1111
return dispatch(opts, handler)
1212
}
1313

14-
const dispatchOpts = { ...rest, throwOnMaxRedirect, stripHeadersOnRedirect } // Stop sub dispatcher from also redirecting.
14+
const dispatchOpts = { ...rest, throwOnMaxRedirect, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect } // Stop sub dispatcher from also redirecting.
1515
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler)
1616
return dispatch(dispatchOpts, redirectHandler)
1717
}

test/interceptors/redirect.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,64 @@ test('same-origin redirects strip configured headers', async (t) => {
898898
strictEqual(text, 'redirected')
899899
})
900900

901+
test('cross-origin redirects strip configured headers only across origins', async (t) => {
902+
const { strictEqual } = tspl(t, { plan: 7 })
903+
904+
const server1 = createServer((req, res) => {
905+
strictEqual(req.headers['x-custom'], undefined)
906+
strictEqual(req.headers['x-keep'], 'present')
907+
res.end('redirected')
908+
}).listen(0)
909+
910+
const server2 = createServer((req, res) => {
911+
if (req.url === '/redirect') {
912+
strictEqual(req.headers['x-custom'], 'secret')
913+
strictEqual(req.headers['x-keep'], 'present')
914+
915+
res.writeHead(302, {
916+
Location: '/same-origin'
917+
})
918+
res.end()
919+
return
920+
}
921+
922+
strictEqual(req.headers['x-custom'], 'secret')
923+
strictEqual(req.headers['x-keep'], 'present')
924+
925+
res.writeHead(302, {
926+
Location: `http://localhost:${server1.address().port}`
927+
})
928+
res.end()
929+
}).listen(0)
930+
931+
t.after(() => {
932+
server1.close()
933+
server2.close()
934+
})
935+
936+
await Promise.all([
937+
once(server1, 'listening'),
938+
once(server2, 'listening')
939+
])
940+
941+
const dispatcher = new undici.Agent({}).compose(redirect({
942+
maxRedirections: 2,
943+
stripHeadersOnCrossOriginRedirect: ['X-Custom']
944+
}))
945+
after(() => dispatcher.close())
946+
947+
const res = await undici.request(`http://localhost:${server2.address().port}/redirect`, {
948+
dispatcher,
949+
headers: {
950+
'X-Custom': 'secret',
951+
'X-Keep': 'present'
952+
}
953+
})
954+
955+
const text = await res.body.text()
956+
strictEqual(text, 'redirected')
957+
})
958+
901959
test('Cross-origin redirects clear forbidden headers', async (t) => {
902960
const { strictEqual } = tspl(t, { plan: 6 })
903961

test/types/redirect-interceptor.test-d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ expectAssignable<Interceptors.RedirectInterceptorOpts>({ throwOnMaxRedirect: tru
77
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, throwOnMaxRedirect: true })
88
expectAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: ['x-custom'] })
99
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, stripHeadersOnRedirect: ['x-custom'] })
10+
expectAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: ['x-custom'] })
11+
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, stripHeadersOnCrossOriginRedirect: ['x-custom'] })
1012

1113
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 'INVALID' })
1214
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ throwOnMaxRedirect: 'INVALID' })
1315
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: 'INVALID' })
1416
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: [1] })
17+
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: 'INVALID' })
18+
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: [1] })

types/interceptors.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default Interceptors
88
declare namespace Interceptors {
99
export type DumpInterceptorOpts = { maxSize?: number }
1010
export type RetryInterceptorOpts = RetryHandler.RetryOptions
11-
export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean, stripHeadersOnRedirect?: string[] }
11+
export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean, stripHeadersOnRedirect?: string[], stripHeadersOnCrossOriginRedirect?: string[] }
1212
export type DecompressInterceptorOpts = {
1313
skipErrorResponses?: boolean
1414
skipStatusCodes?: number[]

0 commit comments

Comments
 (0)