From 2674aa48f8202658e19bd926d37a36216a351825 Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Tue, 29 Sep 2026 13:17:24 +0200 Subject: [PATCH 1/7] Release 7.1.0 --- package.json | 4 +- src/httpClient/httpClient.ts | 89 +++++++++++++++++++++++-- tests/httpClient/httpClient.test.ts | 100 ++++++++++++++++++++++++++++ yarn.lock | 81 ++++++++++++++-------- 4 files changed, 236 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index ef5fea3..4604180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lambda-essentials-ts", - "version": "7.0.6", + "version": "7.1.0", "description": "A selection of the finest modules supporting authorization, API routing, error handling, logging and sending HTTP requests.", "main": "lib/index.js", "private": false, @@ -29,7 +29,7 @@ "@aws-sdk/client-kms": "^3.930.0", "@aws-sdk/client-secrets-manager": "^3.930.0", "@types/node": "^25.0.3", - "axios": "1.13.2", + "axios": "1.18.0", "axios-cache-interceptor": "1.11.2", "fast-safe-stringify": "~2.1.1", "is-error": "~2.2.2", diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index f0f3896..9f5d842 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -45,6 +45,8 @@ export default class HttpClient { private readonly clientExceptionStatusCodeMapOverride?: Record; + private readonly whitelistedDomains?: string[]; + /** * Create a new Instance of the HttpClient */ @@ -60,6 +62,7 @@ export default class HttpClient { this.timeout = options?.timeout; this.clientExceptionStatusCodeMapOverride = options?.clientExceptionStatusCodeMapOverride; this.client = options?.client ?? axios.create(); + this.whitelistedDomains = options?.whitelistedDomains; if (this.enableCache) { setupCache(this.client, { @@ -212,6 +215,29 @@ export default class HttpClient { return `${key}${req.data ? md5(req.data) : ''}`; } + /** + * Checks whether the given url's hostname exactly matches or is a subdomain of, one of the whitelistedDomains. + * If no whitelistedDomains were provided, all urls are considered whitelisted. + */ + private isUrlWhitelisted(url: string, baseURL?: string): boolean { + if (!this.whitelistedDomains) { + return true; + } + + let hostname: string; + try { + hostname = new URL(url, baseURL ?? this.client.defaults.baseURL).hostname; + } catch { + return false; + } + + // anchored to the end of the hostname so e.g. "evilcimpress.io" can't spoof "cimpress.io" + return this.whitelistedDomains.some((domain) => { + const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(^|\\.)${escapedDomain}$`, 'i').test(hostname); + }); + } + /** * Resolves the token with the token provider and adds it to the headers */ @@ -240,6 +266,22 @@ export default class HttpClient { }; } + /** + * Removes the Authorization header when the url is not whitelisted (only applies if whitelistedDomains was configured) + */ + private stripTokenIfNotWhitelisted( + url: string, + headers: { [key: string]: AxiosHeaderValue }, + baseURL?: string, + ): { [key: string]: AxiosHeaderValue } { + if (this.isUrlWhitelisted(url, baseURL)) { + return headers; + } + + const { Authorization, ...rest } = headers; + return rest; + } + /** * Get from the given url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ @@ -247,10 +289,15 @@ export default class HttpClient { url: string, config: AxiosRequestConfig = { responseType: 'json' }, ): Promise> { + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.get(url, { responseType: 'json', ...config, - headers: await this.createHeadersWithResolvedToken(config.headers), + headers, }); } @@ -262,7 +309,11 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.post(url, data, { ...config, headers }); } @@ -274,7 +325,11 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.put(url, data, { ...config, headers }); } @@ -286,7 +341,11 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.patch(url, data, { ...config, headers }); } @@ -294,7 +353,11 @@ export default class HttpClient { * Delete the resource on the given url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async delete(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.delete(url, { ...config, headers }); } @@ -302,7 +365,11 @@ export default class HttpClient { * Makes a head call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async head(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.head(url, { ...config, headers }); } @@ -310,7 +377,11 @@ export default class HttpClient { * Makes an options call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async options(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = await this.createHeadersWithResolvedToken(config.headers); + const headers = this.stripTokenIfNotWhitelisted( + url, + await this.createHeadersWithResolvedToken(config.headers), + config.baseURL, + ); return this.client.options(url, { ...config, headers }); } } @@ -364,6 +435,10 @@ export interface HttpClientOptions { * This is useful, when dependent services return incorrect status codes than then drive incorrect behavior upstream (e.g. 403 instead of 503) */ clientExceptionStatusCodeMapOverride?: Record; + /** + * If provided forwards JWT token only to whitelisted domains + */ + whitelistedDomains?: string[]; } /** diff --git a/tests/httpClient/httpClient.test.ts b/tests/httpClient/httpClient.test.ts index fb74f56..8e37f1e 100644 --- a/tests/httpClient/httpClient.test.ts +++ b/tests/httpClient/httpClient.test.ts @@ -64,6 +64,106 @@ describe('HttpClient', () => { }); }); + describe('whitelistedDomains', () => { + const testToken = 'unit-test-token'; + + const buildHttpClient = (whitelistedDomains?: string[]) => { + const axiosInstance = axios.create(); + const mockAdapter = new MockAdapter(axiosInstance); + const httpClient = new HttpClient({ + client: axiosInstance, + logFunction: jest.fn(), + tokenResolver: jest.fn().mockResolvedValue(testToken), + whitelistedDomains, + }); + return { httpClient, mockAdapter }; + }; + + test('passes the token to any domain when whitelistedDomains is not provided', async () => { + const { httpClient, mockAdapter } = buildHttpClient(); + mockAdapter.onGet('https://baddomain.com/resource').reply(200, {}); + mockAdapter.onGet('https://evildomain.com/resource').reply(200, {}); + + await httpClient.get('https://baddomain.com/resource'); + await httpClient.get('https://evildomain.com/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + expect(mockAdapter.history.get[1].headers?.Authorization).toEqual(`Bearer ${testToken}`); + }); + + test('passes the token to each of the whitelisted domains', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io', 'test.com']); + mockAdapter.onGet('https://somethig.something.valid.io/resource').reply(200, {}); + mockAdapter.onGet('http://whatever.test.com/resource').reply(200, {}); + + await httpClient.get('https://somethig.something.valid.io/resource'); + await httpClient.get('http://whatever.test.com/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + expect(mockAdapter.history.get[1].headers?.Authorization).toEqual(`Bearer ${testToken}`); + }); + + test('does not pass the token to a domain that is not whitelisted', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io', 'test.com']); + mockAdapter.onGet('https://baddomain.com/resource').reply(200, {}); + + await httpClient.get('https://baddomain.com/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + }); + + test('does not pass the token to a domain that merely contains the whitelisted domain as a substring', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); + mockAdapter.onGet('https://somethingvalid.io/resource').reply(200, {}); + + await httpClient.get('https://somethingvalid.io/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + }); + + test('does not pass the token to a domain that merely contains the whitelisted domain as a parameter', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); + mockAdapter.onGet('https://evil.io/params?somethig.valid.io').reply(200, {}); + mockAdapter.onGet('https://evil.io/params/somethig.valid.io').reply(200, {}); + mockAdapter.onGet('https://evil.io/params/whatever=somethig.valid.io').reply(200, {}); + + await httpClient.get('https://evil.io/params?somethig.valid.io'); + await httpClient.get('https://evil.io/params/somethig.valid.io'); + await httpClient.get('https://evil.io/params/whatever=somethig.valid.io'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + expect(mockAdapter.history.get[1].headers?.Authorization).toBeUndefined(); + expect(mockAdapter.history.get[2].headers?.Authorization).toBeUndefined(); + }); + + test('passes the token to a subdomain of a whitelisted domain', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['permited.io']); + mockAdapter.onGet('https://valid.permited.io/resource').reply(200, {}); + + await httpClient.get('https://valid.permited.io/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + }); + + test('does not pass the token when trying to smuggle the whitelisted domain via the url path', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); + mockAdapter.onGet('https://baddomain.com/valid.io').reply(200, {}); + + await httpClient.get('https://baddomain.com/valid.io'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + }); + + test('should pass the token when different casing', async () => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); + mockAdapter.onGet('https://Valid.Io/resource').reply(200, {}); + + await httpClient.get('https://Valid.Io/resource'); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + }); + }); + describe('generateCacheKey()', () => { it('key contains URL', () => { const request: AxiosRequestConfig = { diff --git a/yarn.lock b/yarn.lock index d1667d6..a4b3a9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1843,6 +1843,13 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2035,14 +2042,15 @@ axios-mock-adapter@^2.1.0: fast-deep-equal "^3.1.3" is-buffer "^2.0.5" -axios@1.13.2: - version "1.13.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" - integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== +axios@1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.0.tgz#8a7f8854af280fcaae063272df2ed9f3837d2398" + integrity sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.4" - proxy-from-env "^1.1.0" + follow-redirects "^1.16.0" + form-data "^4.0.5" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" babel-jest@^29.7.0: version "29.7.0" @@ -2443,6 +2451,13 @@ data-view-byte-offset@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -2457,13 +2472,6 @@ debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" -debug@^4.3.1, debug@^4.3.2, debug@^4.4.0: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - dedent@^1.0.0: version "1.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.0.tgz#c1f9445335f0175a96587be245a282ff451446ca" @@ -3188,10 +3196,10 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== -follow-redirects@^1.15.6: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== +follow-redirects@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" @@ -3200,16 +3208,16 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" -form-data@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== +form-data@^4.0.5: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" - hasown "^2.0.2" - mime-types "^2.1.12" + hasown "^2.0.4" + mime-types "^2.1.35" fs.realpath@^1.0.0: version "1.0.0" @@ -3477,6 +3485,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -3492,6 +3507,14 @@ http-vary@^1.0.3: resolved "https://registry.yarnpkg.com/http-vary/-/http-vary-1.0.3.tgz#bacf8fccf31294577c1dc97d6cec479dde1e7741" integrity sha512-sx7Y8YTqF3o0mFJJvF66n8dbaE8v3liV1RgCz46XP5xK7dnzyZHvwMWRA115q5kjbCPBV65/nOMlgW54WLyiag== +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -4528,7 +4551,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: +mime-types@^2.1.35: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -4912,10 +4935,10 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -proxy-from-env@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" - integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== punycode@^2.1.0: version "2.1.1" From 0adc2b646f581b351982f7a3e377f9de1edf0656 Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Tue, 29 Sep 2026 13:21:21 +0200 Subject: [PATCH 2/7] Release 7.1.0 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1942555..c271347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +## [7.1.0] - 2026-09-29 + +### Changed + +- Add an option to provide whitelisted domains in httpClient. Auth Bearer token won't be forwarder if the urls don't belong to the whitelisted domains. + ## [7.0.6] - 2026-01-19 ### Changed From 53ae00acc66c41b0e1fab9702b93231e7d562a1b Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Tue, 6 Oct 2026 17:08:29 +0200 Subject: [PATCH 3/7] Release 7.1.0 --- CHANGELOG.md | 4 ++-- src/httpClient/httpClient.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c271347..bf41a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) -## [7.1.0] - 2026-09-29 +## [7.1.0] - 2026-10-07 ### Changed -- Add an option to provide whitelisted domains in httpClient. Auth Bearer token won't be forwarder if the urls don't belong to the whitelisted domains. +- Add an option to provide whitelisted domains in httpClient. Auth Bearer token won't be forwarded if the urls don't belong to the whitelisted domains. ## [7.0.6] - 2026-01-19 diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index 9f5d842..96ed6f3 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -231,7 +231,7 @@ export default class HttpClient { return false; } - // anchored to the end of the hostname so e.g. "evilcimpress.io" can't spoof "cimpress.io" + // anchored to the end of the hostname so e.g. "evilwhitelisteddomain.io" can't spoof "whitelisteddomain.io" return this.whitelistedDomains.some((domain) => { const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(^|\\.)${escapedDomain}$`, 'i').test(hostname); From b0cc0afd3e296a8d55ec871df772a76b22b2a239 Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Tue, 6 Oct 2026 17:49:20 +0200 Subject: [PATCH 4/7] Release 7.1.0 --- src/httpClient/httpClient.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index 96ed6f3..810375e 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -269,7 +269,7 @@ export default class HttpClient { /** * Removes the Authorization header when the url is not whitelisted (only applies if whitelistedDomains was configured) */ - private stripTokenIfNotWhitelisted( + private stripAuthHeaderIfNotWhitelisted( url: string, headers: { [key: string]: AxiosHeaderValue }, baseURL?: string, @@ -289,7 +289,7 @@ export default class HttpClient { url: string, config: AxiosRequestConfig = { responseType: 'json' }, ): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -309,7 +309,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -325,7 +325,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -341,7 +341,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -353,7 +353,7 @@ export default class HttpClient { * Delete the resource on the given url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async delete(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -365,7 +365,7 @@ export default class HttpClient { * Makes a head call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async head(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, @@ -377,7 +377,7 @@ export default class HttpClient { * Makes an options call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async options(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripTokenIfNotWhitelisted( + const headers = this.stripAuthHeaderIfNotWhitelisted( url, await this.createHeadersWithResolvedToken(config.headers), config.baseURL, From 6eb82769150a03827fccd29b1eb32de803fb917c Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Wed, 14 Oct 2026 13:44:38 +0200 Subject: [PATCH 5/7] Release 7.1.0 --- src/httpClient/httpClient.ts | 93 ++++----- tests/httpClient/httpClient.test.ts | 313 ++++++++++++++++++++-------- 2 files changed, 273 insertions(+), 133 deletions(-) diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index 810375e..bdec1da 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -5,6 +5,7 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, + InternalAxiosRequestConfig, RawAxiosRequestHeaders, } from 'axios'; import * as rax from 'retry-axios'; @@ -115,6 +116,7 @@ export default class HttpClient { if (!config.url) { throw new InternalException('HttpClient Error: "url" must be defined'); } + this.stripAuthHeaderIfNotWhitelisted(config); return config; }, (error: AxiosError) => { @@ -219,22 +221,26 @@ export default class HttpClient { * Checks whether the given url's hostname exactly matches or is a subdomain of, one of the whitelistedDomains. * If no whitelistedDomains were provided, all urls are considered whitelisted. */ - private isUrlWhitelisted(url: string, baseURL?: string): boolean { + private isUrlWhitelisted(url: string, config?: AxiosRequestConfig): boolean { if (!this.whitelistedDomains) { return true; } let hostname: string; try { - hostname = new URL(url, baseURL ?? this.client.defaults.baseURL).hostname; + const resolvedUrl = config ? this.client.getUri({ ...config, url, params: null }) : url; + hostname = new URL(resolvedUrl).hostname.toLowerCase(); } catch { return false; } - // anchored to the end of the hostname so e.g. "evilwhitelisteddomain.io" can't spoof "whitelisteddomain.io" return this.whitelistedDomains.some((domain) => { - const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`(^|\\.)${escapedDomain}$`, 'i').test(hostname); + const normalizedDomain = domain.toLowerCase(); + + return ( + normalizedDomain.length > 0 && + (hostname === normalizedDomain || hostname.endsWith(`.${normalizedDomain}`)) + ); }); } @@ -250,7 +256,12 @@ export default class HttpClient { } if (this.tokenResolverFunction) { - if (headers && headers.Authorization) { + if ( + headers && + Object.entries(headers).some( + ([header, value]) => header.toLowerCase() === 'authorization' && value, + ) + ) { throw new InternalException( 'Authorization header already specified, please create a new HttpClient with a different (or without a) tokenResolver', ); @@ -269,17 +280,27 @@ export default class HttpClient { /** * Removes the Authorization header when the url is not whitelisted (only applies if whitelistedDomains was configured) */ - private stripAuthHeaderIfNotWhitelisted( - url: string, - headers: { [key: string]: AxiosHeaderValue }, - baseURL?: string, - ): { [key: string]: AxiosHeaderValue } { - if (this.isUrlWhitelisted(url, baseURL)) { - return headers; + private stripAuthHeaderIfNotWhitelisted(config: InternalAxiosRequestConfig): void { + if (!this.whitelistedDomains) { + return; + } + + if (!this.isUrlWhitelisted(config.url ?? '', config)) { + config.headers.delete('Authorization'); } - const { Authorization, ...rest } = headers; - return rest; + const beforeRedirect = config.beforeRedirect; + config.beforeRedirect = (options, responseDetails, requestDetails) => { + beforeRedirect?.(options, responseDetails, requestDetails); + + if (!this.isUrlWhitelisted(options.href)) { + Object.keys(options.headers ?? {}).forEach((header) => { + if (header.toLowerCase() === 'authorization') { + delete options.headers[header]; + } + }); + } + }; } /** @@ -289,11 +310,7 @@ export default class HttpClient { url: string, config: AxiosRequestConfig = { responseType: 'json' }, ): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.get(url, { responseType: 'json', ...config, @@ -309,11 +326,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.post(url, data, { ...config, headers }); } @@ -325,11 +338,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.put(url, data, { ...config, headers }); } @@ -341,11 +350,7 @@ export default class HttpClient { data: any, config: AxiosRequestConfig = {}, ): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.patch(url, data, { ...config, headers }); } @@ -353,11 +358,7 @@ export default class HttpClient { * Delete the resource on the given url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async delete(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.delete(url, { ...config, headers }); } @@ -365,11 +366,7 @@ export default class HttpClient { * Makes a head call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async head(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.head(url, { ...config, headers }); } @@ -377,11 +374,7 @@ export default class HttpClient { * Makes an options call to the provided url. Bearer token is automatically injected if tokenResolverFunction was provided to the constructor. */ async options(url: string, config: AxiosRequestConfig = {}): Promise> { - const headers = this.stripAuthHeaderIfNotWhitelisted( - url, - await this.createHeadersWithResolvedToken(config.headers), - config.baseURL, - ); + const headers = await this.createHeadersWithResolvedToken(config.headers); return this.client.options(url, { ...config, headers }); } } diff --git a/tests/httpClient/httpClient.test.ts b/tests/httpClient/httpClient.test.ts index 8e37f1e..b1a6ef8 100644 --- a/tests/httpClient/httpClient.test.ts +++ b/tests/httpClient/httpClient.test.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, { AxiosHeaders, AxiosRequestConfig } from 'axios'; import * as uuid from 'uuid'; import md5 from 'md5'; import { InternalException } from '../../src'; @@ -25,22 +25,6 @@ describe('HttpClient', () => { expect(generatedHeader).toEqual(expectedHeaders); }); - test('errors when there is an auth header set', async () => { - const tokenResolverFunctionMock = jest.fn().mockResolvedValue(testToken); - - const httpClient = new HttpClient({ - logFunction: () => {}, - tokenResolver: tokenResolverFunctionMock, - }); - - const headers = { - Authorization: 'Bearer abc', - }; - await expect(httpClient.createHeadersWithResolvedToken(headers)).rejects.toBeInstanceOf( - InternalException, - ); - }); - test("doesn't add auth header if the tokenResolver is not present", async () => { const expectedHeaders = {}; const httpClient = new HttpClient(); @@ -51,6 +35,19 @@ describe('HttpClient', () => { expect(generatedHeader).toEqual(expectedHeaders); }); + test.each(['Authorization', 'authorization', 'AUTHORIZATION', 'aUtHoRiZaTiOn'])( + 'rejects an existing %s header when resolving a token', + async (header) => { + const tokenResolver = jest.fn().mockResolvedValue(testToken); + const httpClient = new HttpClient({ tokenResolver }); + + await expect( + httpClient.createHeadersWithResolvedToken({ [header]: 'Bearer existing-token' }), + ).rejects.toBeInstanceOf(InternalException); + expect(tokenResolver).not.toHaveBeenCalled(); + }, + ); + test('preserves correlation ID passed in constructor', async () => { const expectedHeaders = { 'orion-correlation-id-root': testCorrelationId, @@ -76,91 +73,241 @@ describe('HttpClient', () => { tokenResolver: jest.fn().mockResolvedValue(testToken), whitelistedDomains, }); - return { httpClient, mockAdapter }; + return { httpClient, mockAdapter, axiosInstance }; }; - test('passes the token to any domain when whitelistedDomains is not provided', async () => { - const { httpClient, mockAdapter } = buildHttpClient(); - mockAdapter.onGet('https://baddomain.com/resource').reply(200, {}); - mockAdapter.onGet('https://evildomain.com/resource').reply(200, {}); - - await httpClient.get('https://baddomain.com/resource'); - await httpClient.get('https://evildomain.com/resource'); - - expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); - expect(mockAdapter.history.get[1].headers?.Authorization).toEqual(`Bearer ${testToken}`); - }); - - test('passes the token to each of the whitelisted domains', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['valid.io', 'test.com']); - mockAdapter.onGet('https://somethig.something.valid.io/resource').reply(200, {}); - mockAdapter.onGet('http://whatever.test.com/resource').reply(200, {}); - - await httpClient.get('https://somethig.something.valid.io/resource'); - await httpClient.get('http://whatever.test.com/resource'); - - expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); - expect(mockAdapter.history.get[1].headers?.Authorization).toEqual(`Bearer ${testToken}`); - }); - - test('does not pass the token to a domain that is not whitelisted', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['valid.io', 'test.com']); - mockAdapter.onGet('https://baddomain.com/resource').reply(200, {}); - - await httpClient.get('https://baddomain.com/resource'); - - expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + test.each<[string, string[] | undefined, string, boolean]>([ + ['omitted whitelist', undefined, 'https://evil.com', true], + ['empty whitelist', [], 'https://valid.io', false], + ['empty entry', [''], 'https://valid.io', false], + ['exact match', ['valid.io', 'test.com'], 'https://valid.io', true], + ['second whitelist entry', ['valid.io', 'test.com'], 'https://test.com', true], + ['subdomain', ['valid.io'], 'https://a.valid.io', true], + ['nested subdomain', ['valid.io'], 'https://a.b.valid.io', true], + ['HTTP URL', ['valid.io'], 'http://valid.io', true], + ['explicit port', ['valid.io'], 'https://valid.io:8080/resource', true], + ['unlisted domain', ['valid.io', 'test.com'], 'https://evil.com', false], + ['missing dot boundary', ['valid.io'], 'https://evilvalid.io', false], + ['domain suffix spoof', ['valid.io'], 'https://valid.io.evil.com', false], + ['path spoof', ['valid.io'], 'https://evil.com/valid.io', false], + ['query spoof', ['valid.io'], 'https://evil.com/?domain=valid.io', false], + ['empty entry cannot allow other domains', ['', 'valid.io'], 'https://evil.com.', false], + ['empty entry does not prevent valid matches', ['', 'valid.io'], 'https://valid.io', true], + ['mixed-case entry', ['VaLiD.Io'], 'https://valid.io', true], + ['mixed-case hostname', ['valid.io'], 'https://VaLiD.Io', true], + ['userinfo spoof', ['valid.io'], 'https://valid.io@evil.com', false], + ['fragment spoof', ['valid.io'], 'https://evil.com/#valid.io', false], + ['trailing dot', ['valid.io'], 'https://valid.io.', false], + ['encoded hostname', ['valid.io'], 'https://%76alid.io', true], + ['Unicode dot', ['valid.io'], 'https://sub。valid.io', true], + ['Unicode lookalike', ['valid.io'], 'https://vаlid.io', false], + ['punycode entry', ['xn--bcher-kva.example'], 'https://bücher.example', true], + ['raw Unicode entry', ['bücher.example'], 'https://bücher.example', false], + ['literal wildcard', ['*.io'], 'https://valid.io', false], + ['IPv4 normalization', ['127.0.0.1'], 'http://127.1', true], + ['IPv6', ['[::1]'], 'http://[::1]', true], + ['malformed URL', ['valid.io'], 'https://[', false], + ['relative URL without a base', ['valid.io'], '/resource', false], + ])('%s', async (_name, whitelistedDomains, url, shouldPassToken) => { + const { httpClient, mockAdapter } = buildHttpClient(whitelistedDomains); + mockAdapter.onAny().reply(200, {}); + + await httpClient.get(url); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual( + shouldPassToken ? `Bearer ${testToken}` : undefined, + ); }); - test('does not pass the token to a domain that merely contains the whitelisted domain as a substring', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); - mockAdapter.onGet('https://somethingvalid.io/resource').reply(200, {}); - - await httpClient.get('https://somethingvalid.io/resource'); - - expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); + test.each<[string, string, AxiosRequestConfig, string, boolean]>([ + ['instance base URL', 'https://valid.io/api', {}, '/resource', true], + [ + 'request base URL override', + 'https://evil.com', + { baseURL: 'https://valid.io' }, + '/resource', + true, + ], + [ + 'untrusted base URL override', + 'https://valid.io', + { baseURL: 'https://evil.com' }, + '/resource', + false, + ], + ['absolute URL override', 'https://evil.com', {}, 'https://valid.io/resource', true], + ['untrusted absolute URL', 'https://valid.io', {}, 'https://evil.com/resource', false], + ['protocol-relative URL', 'https://valid.io', {}, '//evil.com/resource', false], + [ + 'forced untrusted base URL', + 'https://evil.com', + { allowAbsoluteUrls: false }, + 'https://valid.io/resource', + false, + ], + [ + 'forced trusted base URL', + 'https://valid.io', + { allowAbsoluteUrls: false }, + 'https://evil.com/resource', + true, + ], + ])('%s', async (_name, baseURL, config, url, shouldPassToken) => { + const { httpClient, mockAdapter, axiosInstance } = buildHttpClient(['valid.io']); + axiosInstance.defaults.baseURL = baseURL; + mockAdapter.onAny().reply(200, {}); + + await httpClient.get(url, config); + + expect(mockAdapter.history.get[0].headers?.Authorization).toEqual( + shouldPassToken ? `Bearer ${testToken}` : undefined, + ); }); - test('does not pass the token to a domain that merely contains the whitelisted domain as a parameter', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); - mockAdapter.onGet('https://evil.io/params?somethig.valid.io').reply(200, {}); - mockAdapter.onGet('https://evil.io/params/somethig.valid.io').reply(200, {}); - mockAdapter.onGet('https://evil.io/params/whatever=somethig.valid.io').reply(200, {}); + test('honors allowAbsoluteUrls from instance defaults', async () => { + const { httpClient, mockAdapter, axiosInstance } = buildHttpClient(['valid.io']); + axiosInstance.defaults.baseURL = 'https://evil.com'; + axiosInstance.defaults.allowAbsoluteUrls = false; + mockAdapter.onAny().reply(200, {}); - await httpClient.get('https://evil.io/params?somethig.valid.io'); - await httpClient.get('https://evil.io/params/somethig.valid.io'); - await httpClient.get('https://evil.io/params/whatever=somethig.valid.io'); + await httpClient.get('https://valid.io/resource'); + await httpClient.get('https://valid.io/resource', { allowAbsoluteUrls: true }); + expect(axiosInstance.getUri(mockAdapter.history.get[0])).toEqual( + 'https://evil.com/https://valid.io/resource', + ); expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); - expect(mockAdapter.history.get[1].headers?.Authorization).toBeUndefined(); - expect(mockAdapter.history.get[2].headers?.Authorization).toBeUndefined(); + expect(mockAdapter.history.get[1].headers?.Authorization).toEqual(`Bearer ${testToken}`); }); - test('passes the token to a subdomain of a whitelisted domain', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['permited.io']); - mockAdapter.onGet('https://valid.permited.io/resource').reply(200, {}); + test.each(['Authorization', 'authorization', 'AUTHORIZATION'])( + 'strips a supplied %s header only for untrusted destinations', + async (header) => { + const axiosInstance = axios.create(); + const mockAdapter = new MockAdapter(axiosInstance); + mockAdapter.onAny().reply(200, {}); + const httpClient = new HttpClient({ + client: axiosInstance, + whitelistedDomains: ['valid.io'], + logFunction: jest.fn(), + }); + const headers = { [header]: `Bearer ${testToken}`, 'X-Custom': 'preserved' }; + + await httpClient.get('https://evil.com', { headers }); + await httpClient.get('https://valid.io', { headers }); + + expect( + (mockAdapter.history.get[0].headers as AxiosHeaders).get('Authorization'), + ).toBeUndefined(); + expect(mockAdapter.history.get[0].headers?.['X-Custom']).toEqual('preserved'); + expect((mockAdapter.history.get[1].headers as AxiosHeaders).get('Authorization')).toEqual( + `Bearer ${testToken}`, + ); + expect(headers[header]).toEqual(`Bearer ${testToken}`); + }, + ); + + test.each(['common', 'get'] as const)( + 'strips authorization inherited from %s defaults without mutating them', + async (scope) => { + const axiosInstance = axios.create(); + axiosInstance.defaults.headers[scope].authorization = `Bearer ${testToken}`; + const mockAdapter = new MockAdapter(axiosInstance); + mockAdapter.onAny().reply(200, {}); + const httpClient = new HttpClient({ + client: axiosInstance, + whitelistedDomains: ['valid.io'], + logFunction: jest.fn(), + }); + + await httpClient.get('https://evil.com'); + await httpClient.get('https://valid.io'); + + expect( + (mockAdapter.history.get[0].headers as AxiosHeaders).get('Authorization'), + ).toBeUndefined(); + expect((mockAdapter.history.get[1].headers as AxiosHeaders).get('Authorization')).toEqual( + `Bearer ${testToken}`, + ); + expect(axiosInstance.defaults.headers[scope].authorization).toEqual(`Bearer ${testToken}`); + }, + ); + + test.each(['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const)( + 'enforces the whitelist for %s requests', + async (method) => { + const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); + mockAdapter.onAny().reply(200, {}); + + await httpClient[method]('https://evil.com', {}); + await httpClient[method]('https://valid.io', {}); + + expect(mockAdapter.history[method][0].headers?.Authorization).toBeUndefined(); + expect(mockAdapter.history[method][1].headers?.Authorization).toEqual( + `Bearer ${testToken}`, + ); + }, + ); + + test.each<[string, string[] | undefined, string, boolean]>([ + ['trusted destination', ['valid.io'], 'https://valid.io/redirected', true], + ['untrusted destination', ['valid.io'], 'https://evil.com/redirected', false], + ['empty whitelist', [], 'https://valid.io/redirected', false], + ['omitted whitelist', undefined, 'https://evil.com/redirected', true], + ['malformed destination', ['valid.io'], 'https://[', false], + ])('checks redirects with %s', async (_name, whitelistedDomains, href, shouldPassToken) => { + const { httpClient, mockAdapter } = buildHttpClient(whitelistedDomains); + mockAdapter.onAny().reply(200, {}); + const beforeRedirect = jest.fn((options) => { + options.headers.Authorization = 'Bearer hook-token'; + options.headers.authorization = 'Bearer hook-token'; + options.headers.AUTHORIZATION = 'Bearer hook-token'; + }); - await httpClient.get('https://valid.permited.io/resource'); + await httpClient.get('https://valid.io/resource', { beforeRedirect }); - expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + const options = { href, headers: { 'X-Custom': 'preserved' } }; + const responseDetails = { headers: { location: href }, statusCode: 302 }; + const requestDetails = { + headers: { Authorization: `Bearer ${testToken}` }, + url: 'https://valid.io/resource', + method: 'GET', + }; + mockAdapter.history.get[0].beforeRedirect?.(options, responseDetails, requestDetails); + + expect(beforeRedirect).toHaveBeenCalledTimes(1); + expect(beforeRedirect).toHaveBeenCalledWith(options, responseDetails, requestDetails); + expect(options.headers).toEqual( + shouldPassToken + ? { + 'X-Custom': 'preserved', + Authorization: 'Bearer hook-token', + authorization: 'Bearer hook-token', + AUTHORIZATION: 'Bearer hook-token', + } + : { 'X-Custom': 'preserved' }, + ); }); - test('does not pass the token when trying to smuggle the whitelisted domain via the url path', async () => { + test('retains the token on a trusted redirect', async () => { const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); - mockAdapter.onGet('https://baddomain.com/valid.io').reply(200, {}); + mockAdapter.onAny().reply(200, {}); - await httpClient.get('https://baddomain.com/valid.io'); + await httpClient.get('https://valid.io/resource'); - expect(mockAdapter.history.get[0].headers?.Authorization).toBeUndefined(); - }); - - test('should pass the token when different casing', async () => { - const { httpClient, mockAdapter } = buildHttpClient(['valid.io']); - mockAdapter.onGet('https://Valid.Io/resource').reply(200, {}); - - await httpClient.get('https://Valid.Io/resource'); + const config = mockAdapter.history.get[0]; + const options = { + href: 'https://valid.io/redirected', + headers: { Authorization: config.headers?.Authorization }, + }; + expect(config.beforeRedirect).toEqual(expect.any(Function)); + config.beforeRedirect?.( + options, + { headers: { location: options.href }, statusCode: 302 }, + { headers: {}, url: 'https://valid.io/resource', method: 'GET' }, + ); - expect(mockAdapter.history.get[0].headers?.Authorization).toEqual(`Bearer ${testToken}`); + expect(options.headers.Authorization).toEqual(`Bearer ${testToken}`); }); }); From 6175b4ca2e3270ac548ca0c8758626e0090ded7d Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Wed, 14 Oct 2026 17:35:56 +0200 Subject: [PATCH 6/7] Release 7.1.0 --- src/httpClient/httpClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index bdec1da..b987042 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -84,7 +84,7 @@ export default class HttpClient { this.client.defaults.raxConfig = { ...options?.retryOptions, instance: this.client, // always attach retry-axios to the private axios client - httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT', 'POST'], // extending the defaults to retry POST calls + httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT', 'POST', 'PATCH'], // extending the defaults to retry POST & PATCH calls onRetryAttempt: (err) => { this.logFunction({ title: 'HTTP Response Error Retry', From def257681751278d0cbfc10aa6674214a1f55ca4 Mon Sep 17 00:00:00 2001 From: "matus.hudec" Date: Thu, 15 Oct 2026 13:00:48 +0200 Subject: [PATCH 7/7] Release 7.1.0 --- src/httpClient/httpClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/httpClient/httpClient.ts b/src/httpClient/httpClient.ts index b987042..bdec1da 100644 --- a/src/httpClient/httpClient.ts +++ b/src/httpClient/httpClient.ts @@ -84,7 +84,7 @@ export default class HttpClient { this.client.defaults.raxConfig = { ...options?.retryOptions, instance: this.client, // always attach retry-axios to the private axios client - httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT', 'POST', 'PATCH'], // extending the defaults to retry POST & PATCH calls + httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT', 'POST'], // extending the defaults to retry POST calls onRetryAttempt: (err) => { this.logFunction({ title: 'HTTP Response Error Retry',