diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index b1674b3092a..2137e174a8a 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -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 diff --git a/docs/docs/api/RedirectHandler.md b/docs/docs/api/RedirectHandler.md index d1dd9d993f9..5d49653be0d 100644 --- a/docs/docs/api/RedirectHandler.md +++ b/docs/docs/api/RedirectHandler.md @@ -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` @@ -18,6 +18,9 @@ Returns: `RedirectHandler` - **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandler) => Promise` (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 diff --git a/lib/handler/redirect-handler.js b/lib/handler/redirect-handler.js index 35a4aa4232e..8ead972b742 100644 --- a/lib/handler/redirect-handler.js +++ b/lib/handler/redirect-handler.js @@ -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 = [] @@ -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 @@ -152,26 +154,49 @@ 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]) } } @@ -179,7 +204,7 @@ function cleanRequestHeaders (headers, removeContent, unknownOrigin) { 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) } } diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 83b4c9a7b2f..34e9b321197 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -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) } diff --git a/test/interceptors/redirect.js b/test/interceptors/redirect.js index cabe5c1f47f..cf0f0a815a9 100644 --- a/test/interceptors/redirect.js +++ b/test/interceptors/redirect.js @@ -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 }) diff --git a/test/parser-issues.js b/test/parser-issues.js index 4e398cdba01..3371eda682a 100644 --- a/test/parser-issues.js +++ b/test/parser-issues.js @@ -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('..') diff --git a/test/types/redirect-interceptor.test-d.ts b/test/types/redirect-interceptor.test-d.ts index 42612e0dec7..1d4075a776b 100644 --- a/test/types/redirect-interceptor.test-d.ts +++ b/test/types/redirect-interceptor.test-d.ts @@ -5,6 +5,14 @@ expectAssignable({}) expectAssignable({ maxRedirections: 3 }) expectAssignable({ throwOnMaxRedirect: true }) expectAssignable({ maxRedirections: 3, throwOnMaxRedirect: true }) +expectAssignable({ stripHeadersOnRedirect: ['x-custom'] }) +expectAssignable({ maxRedirections: 3, stripHeadersOnRedirect: ['x-custom'] }) +expectAssignable({ stripHeadersOnCrossOriginRedirect: ['x-custom'] }) +expectAssignable({ maxRedirections: 3, stripHeadersOnCrossOriginRedirect: ['x-custom'] }) expectNotAssignable({ maxRedirections: 'INVALID' }) expectNotAssignable({ throwOnMaxRedirect: 'INVALID' }) +expectNotAssignable({ stripHeadersOnRedirect: 'INVALID' }) +expectNotAssignable({ stripHeadersOnRedirect: [1] }) +expectNotAssignable({ stripHeadersOnCrossOriginRedirect: 'INVALID' }) +expectNotAssignable({ stripHeadersOnCrossOriginRedirect: [1] }) diff --git a/types/interceptors.d.ts b/types/interceptors.d.ts index 3b90a28592f..d21d717cec5 100644 --- a/types/interceptors.d.ts +++ b/types/interceptors.d.ts @@ -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[]