Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,13 @@ The `redirect` interceptor allows you to customize the way your dispatcher handl

It accepts the same arguments as the [`RedirectHandler` constructor](/docs/docs/api/RedirectHandler.md).

Options:

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

**Example - Basic Redirect Interceptor**

```js
Expand Down
5 changes: 4 additions & 1 deletion docs/docs/api/RedirectHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Arguments:

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

Returns: `RedirectHandler`
Expand All @@ -18,6 +18,9 @@ Returns: `RedirectHandler`
- **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandler) => Promise<Dispatch.DispatchResponse>` (required) - Dispatch function to be called after every redirection.
- **maxRedirections** `number` (required) - Maximum number of redirections allowed.
- **opts** `object` (required) - Options for handling redirection.
- **throwOnMaxRedirect** `boolean` - Throw when the maximum number of redirections is reached.
- **stripHeadersOnRedirect** `string[]` - Header names to remove from all redirected requests.
- **stripHeadersOnCrossOriginRedirect** `string[]` - Header names to remove from cross-origin redirected requests.
- **handler** `object` (required) - Handlers for different stages of the request lifecycle.

### Properties
Expand Down
47 changes: 36 additions & 11 deletions lib/handler/redirect-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ class RedirectHandler {

this.dispatch = dispatch
this.location = null
const { maxRedirections: _, ...cleanOpts } = opts
const { maxRedirections: _, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect, ...cleanOpts } = opts
this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
this.opts.body = util.wrapRequestBody(this.opts.body)
this.stripHeadersOnRedirect = normalizeStripHeaders(stripHeadersOnRedirect, 'stripHeadersOnRedirect')
this.stripHeadersOnCrossOriginRedirect = normalizeStripHeaders(stripHeadersOnCrossOriginRedirect, 'stripHeadersOnCrossOriginRedirect')
this.maxRedirections = maxRedirections
this.handler = handler
this.history = []
Expand Down Expand Up @@ -100,7 +102,7 @@ class RedirectHandler {
// Remove headers referring to the original URL.
// By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
// https://tools.ietf.org/html/rfc7231#section-6.4
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin)
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
this.opts.path = path
this.opts.origin = origin
this.opts.query = null
Expand Down Expand Up @@ -152,34 +154,57 @@ class RedirectHandler {
}

// https://tools.ietf.org/html/rfc7231#section-6.4.4
function shouldRemoveHeader (header, removeContent, unknownOrigin) {
if (header.length === 4) {
return util.headerNameToString(header) === 'host'
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
const name = util.headerNameToString(header)
if (name === 'host') {
return true
}
if (stripHeaders?.has(name) || (unknownOrigin && stripHeadersOnCrossOrigin?.has(name))) {
return true
}
if (removeContent && util.headerNameToString(header).startsWith('content-')) {
if (removeContent && name.startsWith('content-')) {
return true
}
if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) {
const name = util.headerNameToString(header)
if (unknownOrigin) {
return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'
}
return false
}

// https://tools.ietf.org/html/rfc7231#section-6.4
function cleanRequestHeaders (headers, removeContent, unknownOrigin) {
function normalizeStripHeaders (headers, optionName) {
if (headers == null) {
return null
}

if (!Array.isArray(headers)) {
throw new InvalidArgumentError(`${optionName} must be an array`)
}

const normalized = new Set()
for (const header of headers) {
if (typeof header !== 'string') {
throw new InvalidArgumentError(`${optionName} must contain header names`)
}

normalized.add(util.headerNameToString(header))
}
return normalized
}

function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
const ret = []
if (Array.isArray(headers)) {
for (let i = 0; i < headers.length; i += 2) {
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) {
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
ret.push(headers[i], headers[i + 1])
}
}
} else if (headers && typeof headers === 'object') {
const entries = util.hasSafeIterator(headers) ? headers : Object.entries(headers)

for (const [key, value] of entries) {
if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
ret.push(key, value)
}
}
Expand Down
6 changes: 3 additions & 3 deletions lib/interceptor/redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

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

function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect } = {}) {
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect, stripHeadersOnRedirect: defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect: defaultStripHeadersOnCrossOriginRedirect } = {}) {
return (dispatch) => {
return function Intercept (opts, handler) {
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, ...rest } = opts
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, stripHeadersOnRedirect = defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect = defaultStripHeadersOnCrossOriginRedirect, ...rest } = opts

if (maxRedirections == null || maxRedirections === 0) {
return dispatch(opts, handler)
}

const dispatchOpts = { ...rest, throwOnMaxRedirect } // Stop sub dispatcher from also redirecting.
const dispatchOpts = { ...rest, throwOnMaxRedirect, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect } // Stop sub dispatcher from also redirecting.
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler)
return dispatch(dispatchOpts, redirectHandler)
}
Expand Down
100 changes: 100 additions & 0 deletions test/interceptors/redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,106 @@ test('same-origin redirect preserves plain object headers with polluted Object.p
}
})

test('same-origin redirects strip configured headers', async (t) => {
const { strictEqual } = tspl(t, { plan: 5 })

const server = createServer((req, res) => {
if (req.url === '/redirect') {
strictEqual(req.headers['x-custom'], 'secret')
strictEqual(req.headers['x-keep'], 'present')

res.writeHead(302, {
Location: '/final'
})
res.end()
return
}

strictEqual(req.headers['x-custom'], undefined)
strictEqual(req.headers['x-keep'], 'present')
res.end('redirected')
}).listen(0)

after(() => server.close())

await once(server, 'listening')

const dispatcher = new undici.Agent({}).compose(redirect({
maxRedirections: 1,
stripHeadersOnRedirect: ['X-Custom']
}))
after(() => dispatcher.close())

const res = await undici.request(`http://localhost:${server.address().port}/redirect`, {
dispatcher,
headers: {
'X-Custom': 'secret',
'X-Keep': 'present'
}
})

const text = await res.body.text()
strictEqual(text, 'redirected')
})

test('cross-origin redirects strip configured headers only across origins', async (t) => {
const { strictEqual } = tspl(t, { plan: 7 })

const server1 = createServer((req, res) => {
strictEqual(req.headers['x-custom'], undefined)
strictEqual(req.headers['x-keep'], 'present')
res.end('redirected')
}).listen(0)

const server2 = createServer((req, res) => {
if (req.url === '/redirect') {
strictEqual(req.headers['x-custom'], 'secret')
strictEqual(req.headers['x-keep'], 'present')

res.writeHead(302, {
Location: '/same-origin'
})
res.end()
return
}

strictEqual(req.headers['x-custom'], 'secret')
strictEqual(req.headers['x-keep'], 'present')

res.writeHead(302, {
Location: `http://localhost:${server1.address().port}`
})
res.end()
}).listen(0)

t.after(() => {
server1.close()
server2.close()
})

await Promise.all([
once(server1, 'listening'),
once(server2, 'listening')
])

const dispatcher = new undici.Agent({}).compose(redirect({
maxRedirections: 2,
stripHeadersOnCrossOriginRedirect: ['X-Custom']
}))
after(() => dispatcher.close())

const res = await undici.request(`http://localhost:${server2.address().port}/redirect`, {
dispatcher,
headers: {
'X-Custom': 'secret',
'X-Keep': 'present'
}
})

const text = await res.body.text()
strictEqual(text, 'redirected')
})

test('Cross-origin redirects clear forbidden headers', async (t) => {
const { strictEqual } = tspl(t, { plan: 6 })

Expand Down
2 changes: 1 addition & 1 deletion test/parser-issues.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test } = require('node:test')
const { test, after } = require('node:test')
const net = require('node:net')
const { Client, errors, fetch } = require('..')

Expand Down
8 changes: 8 additions & 0 deletions test/types/redirect-interceptor.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ expectAssignable<Interceptors.RedirectInterceptorOpts>({})
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3 })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ throwOnMaxRedirect: true })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, throwOnMaxRedirect: true })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: ['x-custom'] })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, stripHeadersOnRedirect: ['x-custom'] })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: ['x-custom'] })
expectAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 3, stripHeadersOnCrossOriginRedirect: ['x-custom'] })

expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ maxRedirections: 'INVALID' })
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ throwOnMaxRedirect: 'INVALID' })
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: 'INVALID' })
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnRedirect: [1] })
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: 'INVALID' })
expectNotAssignable<Interceptors.RedirectInterceptorOpts>({ stripHeadersOnCrossOriginRedirect: [1] })
2 changes: 1 addition & 1 deletion types/interceptors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default Interceptors
declare namespace Interceptors {
export type DumpInterceptorOpts = { maxSize?: number }
export type RetryInterceptorOpts = RetryHandler.RetryOptions
export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean }
export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean, stripHeadersOnRedirect?: string[], stripHeadersOnCrossOriginRedirect?: string[] }
export type DecompressInterceptorOpts = {
skipErrorResponses?: boolean
skipStatusCodes?: number[]
Expand Down
Loading