From 848ef2f4ff90e7fe19fce8b20c2be07c6f8dbec3 Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:01:18 -0800 Subject: [PATCH 1/8] Fix: Fix DNS Rebinding/TOCTOU Vulernability Part of advisory https://github.com/FlowiseAI/Flowise/security/advisories/GHSA-2x8m-83vc-6wv4 Ensures that the validated host/ip is the same one used when resolving later on. Refactoring methods secureFetch and secureAxiosRequest to use resolver helper methods --- packages/components/src/httpSecurity.ts | 206 +++++++++++++----------- 1 file changed, 113 insertions(+), 93 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index d729f8da5e6..8e7d7569f7e 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -2,6 +2,8 @@ import * as ipaddr from 'ipaddr.js' import dns from 'dns/promises' import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import fetch, { RequestInit, Response } from 'node-fetch' +import http from 'http' +import https from 'https' /** * Checks if an IP address is in the deny list @@ -60,98 +62,66 @@ export async function checkDenyList(url: string): Promise { * @returns Promise * @throws Error if any URL in the redirect chain is denied */ -export async function secureAxiosRequest(config: AxiosRequestConfig, maxRedirects: number = 5): Promise { - let currentUrl = config.url - let redirectCount = 0 - let currentConfig = { ...config, maxRedirects: 0 } // Disable automatic redirects +export async function secureAxiosRequest( + config: AxiosRequestConfig, + maxRedirects: number = 5 +): Promise { - // Validate the initial URL - if (currentUrl) { - await checkDenyList(currentUrl) + let currentUrl = config.url + if (!currentUrl) { + throw new Error('secureAxiosRequest: url is required') } - while (redirectCount <= maxRedirects) { - try { - // Update the URL in config for subsequent requests - currentConfig.url = currentUrl - - const response = await axios(currentConfig) - - // If it's a successful response (not a redirect), return it - if (response.status < 300 || response.status >= 400) { - return response - } - - // Handle redirect - const location = response.headers.location - if (!location) { - // No location header, but it's a redirect status - return the response - return response - } - - redirectCount++ + let redirects = 0 + let currentConfig = { ...config, maxRedirects: 0 } // Disable automatic redirects - if (redirectCount > maxRedirects) { - throw new Error('Too many redirects') + while (redirects <= maxRedirects) { + const target = await resolveAndValidate(currentUrl) + const agent = createPinnedAgent(target) + + currentConfig = { + ...currentConfig, + url: currentUrl, + ...(target.protocol === 'http' + ? { httpAgent: agent } + : { httpsAgent: agent }), + headers: { + ...currentConfig.headers, + Host: target.hostname } + } - // Resolve the redirect URL (handle relative URLs) - const redirectUrl = new URL(location, currentUrl).toString() - - // Validate the redirect URL against the deny list - await checkDenyList(redirectUrl) + const response = await axios(currentConfig) - // Update current URL for next iteration - currentUrl = redirectUrl + // If it's a successful response (not a redirect), return it + if (response.status < 300 || response.status >= 400) { + return response + } - // For redirects, we only need to preserve certain headers and change method if needed - if (response.status === 301 || response.status === 302 || response.status === 303) { - // For 303, or when redirecting POST requests, change to GET - if ( - response.status === 303 || - (currentConfig.method && ['POST', 'PUT', 'PATCH'].includes(currentConfig.method.toUpperCase())) - ) { - currentConfig.method = 'GET' - delete currentConfig.data - } - } - } catch (error) { - // If it's not a redirect-related error from axios, propagate it - if (error.response && error.response.status >= 300 && error.response.status < 400) { - // This is a redirect response that axios couldn't handle automatically - // Continue with our manual redirect handling - const response = error.response - const location = response.headers.location - - if (!location) { - return response - } + // Handle redirect + const location = response.headers.location + if (!location) { + // No location header, but it's a redirect status - return the response + return response + } - redirectCount++ + redirects++ + if (redirects > maxRedirects) { + throw new Error('Too many redirects') + } - if (redirectCount > maxRedirects) { - throw new Error('Too many redirects') - } + currentUrl = new URL(location, currentUrl).toString() - const redirectUrl = new URL(location, currentUrl).toString() - await checkDenyList(redirectUrl) - currentUrl = redirectUrl - - // Handle method changes for redirects - if (response.status === 301 || response.status === 302 || response.status === 303) { - if ( - response.status === 303 || - (currentConfig.method && ['POST', 'PUT', 'PATCH'].includes(currentConfig.method.toUpperCase())) - ) { - currentConfig.method = 'GET' - delete currentConfig.data - } - } - continue + // For redirects, we only need to preserve certain headers and change method if needed + if (response.status === 301 || response.status === 302 || response.status === 303) { + // For 303, or when redirecting POST requests, change to GET + if ( + response.status === 303 || + (currentConfig.method && ['POST', 'PUT', 'PATCH'].includes(currentConfig.method.toUpperCase())) + ) { + currentConfig.method = 'GET' + delete currentConfig.data } - - // For other errors, re-throw - throw error } } @@ -166,16 +136,20 @@ export async function secureAxiosRequest(config: AxiosRequestConfig, maxRedirect * @returns Promise * @throws Error if any URL in the redirect chain is denied */ -export async function secureFetch(url: string, init?: RequestInit, maxRedirects: number = 5): Promise { +export async function secureFetch( + url: string, + init?: RequestInit, + maxRedirects: number = 5 +): Promise { let currentUrl = url - let redirectCount = 0 + let redirectCount = 0; let currentInit = { ...init, redirect: 'manual' as const } // Disable automatic redirects - // Validate the initial URL - await checkDenyList(currentUrl) - while (redirectCount <= maxRedirects) { - const response = await fetch(currentUrl, currentInit) + const resolved = await resolveAndValidate(currentUrl) + const agent = createPinnedAgent(resolved) + + const response = await fetch(currentUrl, { ...currentInit, agent: () => agent}) // If it's a successful response (not a redirect), return it if (response.status < 300 || response.status >= 400) { @@ -196,13 +170,7 @@ export async function secureFetch(url: string, init?: RequestInit, maxRedirects: } // Resolve the redirect URL (handle relative URLs) - const redirectUrl = new URL(location, currentUrl).toString() - - // Validate the redirect URL against the deny list - await checkDenyList(redirectUrl) - - // Update current URL for next iteration - currentUrl = redirectUrl + currentUrl = new URL(location, currentUrl).toString() // Handle method changes for redirects according to HTTP specs if (response.status === 301 || response.status === 302 || response.status === 303) { @@ -219,3 +187,55 @@ export async function secureFetch(url: string, init?: RequestInit, maxRedirects: throw new Error('Too many redirects') } + +type ResolvedTarget = { + hostname: string + ip: string + family: 4 | 6 + protocol: 'http' | 'https' +} + +async function resolveAndValidate(url: string): Promise { + const denyListString = process.env.HTTP_DENY_LIST + if (!denyListString) { + throw new Error('HTTP_DENY_LIST must be set for secureAxiosRequest') + } + + const denyList = denyListString.split(',').map(s => s.trim()) + const u = new URL(url) + const hostname = u.hostname + const protocol = u.protocol === 'https:' ? 'https' : 'http' + + if (ipaddr.isValid(hostname)) { + isDeniedIP(hostname, denyList) + return { hostname, ip: hostname, family: hostname.includes(':') ? 6 : 4, protocol } + } + + const records = await dns.lookup(hostname, { all: true }) + if (records.length === 0) { + throw new Error(`DNS resolution failed for ${hostname}`) + } + + for (const r of records) { + isDeniedIP(r.address, denyList) + } + + const chosen = records.find(r => r.family === 4) ?? records[0] + + return { + hostname, + ip: chosen.address, + family: chosen.family as 4 | 6, + protocol + } +} + +function createPinnedAgent(target: ResolvedTarget): http.Agent | https.Agent { + const Agent = target.protocol === 'https' ? https.Agent : http.Agent + + return new Agent({ + lookup: (_host, _opts, cb) => { + cb(null, target.ip, target.family) + } + }) +} From a5545659d3686d8610a878d14da29709828af751 Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:15:26 -0800 Subject: [PATCH 2/8] Update packages/components/src/httpSecurity.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/components/src/httpSecurity.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index 8e7d7569f7e..ac77f616123 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -82,9 +82,9 @@ export async function secureAxiosRequest( currentConfig = { ...currentConfig, url: currentUrl, - ...(target.protocol === 'http' - ? { httpAgent: agent } - : { httpsAgent: agent }), + ...(target.protocol === 'http' + ? { httpAgent: agent } + : { httpsAgent: agent }), headers: { ...currentConfig.headers, Host: target.hostname From 578da56313e290dd80a871ff52d9d2d88dec3d14 Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:15:39 -0800 Subject: [PATCH 3/8] Update packages/components/src/httpSecurity.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/components/src/httpSecurity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index ac77f616123..0b9a710a6b2 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -142,7 +142,7 @@ export async function secureFetch( maxRedirects: number = 5 ): Promise { let currentUrl = url - let redirectCount = 0; + let redirectCount = 0 let currentInit = { ...init, redirect: 'manual' as const } // Disable automatic redirects while (redirectCount <= maxRedirects) { From f12b6ee839656a09733eb9c7320bb6d2ab6e9db8 Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:15:58 -0800 Subject: [PATCH 4/8] Update packages/components/src/httpSecurity.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/components/src/httpSecurity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index 0b9a710a6b2..c6c9b8dc653 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -198,7 +198,7 @@ type ResolvedTarget = { async function resolveAndValidate(url: string): Promise { const denyListString = process.env.HTTP_DENY_LIST if (!denyListString) { - throw new Error('HTTP_DENY_LIST must be set for secureAxiosRequest') + throw new Error('HTTP_DENY_LIST must be set for secure requests') } const denyList = denyListString.split(',').map(s => s.trim()) From 4d412322e273fc65c6d8a90ef2027766318911ca Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:24:00 -0800 Subject: [PATCH 5/8] Update httpSecurity.ts --- packages/components/src/httpSecurity.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index 8e7d7569f7e..acaa9c80c31 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -62,11 +62,7 @@ export async function checkDenyList(url: string): Promise { * @returns Promise * @throws Error if any URL in the redirect chain is denied */ -export async function secureAxiosRequest( - config: AxiosRequestConfig, - maxRedirects: number = 5 -): Promise { - +export async function secureAxiosRequest(config: AxiosRequestConfig,maxRedirects: number = 5): Promise { let currentUrl = config.url if (!currentUrl) { throw new Error('secureAxiosRequest: url is required') @@ -82,9 +78,7 @@ export async function secureAxiosRequest( currentConfig = { ...currentConfig, url: currentUrl, - ...(target.protocol === 'http' - ? { httpAgent: agent } - : { httpsAgent: agent }), + ...(target.protocol === 'http' ? { httpAgent: agent } : { httpsAgent: agent }), headers: { ...currentConfig.headers, Host: target.hostname @@ -142,7 +136,7 @@ export async function secureFetch( maxRedirects: number = 5 ): Promise { let currentUrl = url - let redirectCount = 0; + let redirectCount = 0 let currentInit = { ...init, redirect: 'manual' as const } // Disable automatic redirects while (redirectCount <= maxRedirects) { @@ -198,10 +192,10 @@ type ResolvedTarget = { async function resolveAndValidate(url: string): Promise { const denyListString = process.env.HTTP_DENY_LIST if (!denyListString) { - throw new Error('HTTP_DENY_LIST must be set for secureAxiosRequest') + throw new Error('HTTP_DENY_LIST must be set for secure requests') } - const denyList = denyListString.split(',').map(s => s.trim()) + const denyList = denyListString.split(',').map((s) => s.trim()) const u = new URL(url) const hostname = u.hostname const protocol = u.protocol === 'https:' ? 'https' : 'http' @@ -220,7 +214,7 @@ async function resolveAndValidate(url: string): Promise { isDeniedIP(r.address, denyList) } - const chosen = records.find(r => r.family === 4) ?? records[0] + const chosen = records.find((r) => r.family === 4) ?? records[0] return { hostname, From 14f1d7276459ba8534aaf422e05084a15ae0d83c Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Mon, 19 Jan 2026 16:30:47 -0800 Subject: [PATCH 6/8] Update httpSecurity.ts --- packages/components/src/httpSecurity.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index acaa9c80c31..d319060db29 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -62,7 +62,7 @@ export async function checkDenyList(url: string): Promise { * @returns Promise * @throws Error if any URL in the redirect chain is denied */ -export async function secureAxiosRequest(config: AxiosRequestConfig,maxRedirects: number = 5): Promise { +export async function secureAxiosRequest(config: AxiosRequestConfig, maxRedirects: number = 5): Promise { let currentUrl = config.url if (!currentUrl) { throw new Error('secureAxiosRequest: url is required') @@ -78,7 +78,7 @@ export async function secureAxiosRequest(config: AxiosRequestConfig,maxRedirects currentConfig = { ...currentConfig, url: currentUrl, - ...(target.protocol === 'http' ? { httpAgent: agent } : { httpsAgent: agent }), + ...(target.protocol === 'http' ? { httpAgent: agent } : { httpsAgent: agent }), headers: { ...currentConfig.headers, Host: target.hostname @@ -130,11 +130,7 @@ export async function secureAxiosRequest(config: AxiosRequestConfig,maxRedirects * @returns Promise * @throws Error if any URL in the redirect chain is denied */ -export async function secureFetch( - url: string, - init?: RequestInit, - maxRedirects: number = 5 -): Promise { +export async function secureFetch(url: string, init?: RequestInit, maxRedirects: number = 5): Promise { let currentUrl = url let redirectCount = 0 let currentInit = { ...init, redirect: 'manual' as const } // Disable automatic redirects @@ -143,7 +139,7 @@ export async function secureFetch( const resolved = await resolveAndValidate(currentUrl) const agent = createPinnedAgent(resolved) - const response = await fetch(currentUrl, { ...currentInit, agent: () => agent}) + const response = await fetch(currentUrl, { ...currentInit, agent: () => agent }) // If it's a successful response (not a redirect), return it if (response.status < 300 || response.status >= 400) { From 8dda5a1ab4eb71e6243191af668e283f0544078d Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Tue, 20 Jan 2026 10:05:46 -0800 Subject: [PATCH 7/8] Update httpSecurity.ts --- packages/components/src/httpSecurity.ts | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index d319060db29..c8af7551b5d 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -187,18 +187,25 @@ type ResolvedTarget = { async function resolveAndValidate(url: string): Promise { const denyListString = process.env.HTTP_DENY_LIST - if (!denyListString) { - throw new Error('HTTP_DENY_LIST must be set for secure requests') - } + const denyList = denyListString + ? denyListString.split(',').map((s) => s.trim()) + : null - const denyList = denyListString.split(',').map((s) => s.trim()) const u = new URL(url) const hostname = u.hostname - const protocol = u.protocol === 'https:' ? 'https' : 'http' + const protocol: 'http' | 'https' = u.protocol === 'https:' ? 'https' : 'http' if (ipaddr.isValid(hostname)) { - isDeniedIP(hostname, denyList) - return { hostname, ip: hostname, family: hostname.includes(':') ? 6 : 4, protocol } + if (denyList) { + isDeniedIP(hostname, denyList) + } + + return { + hostname, + ip: hostname, + family: hostname.includes(':') ? 6 : 4, + protocol + } } const records = await dns.lookup(hostname, { all: true }) @@ -206,8 +213,10 @@ async function resolveAndValidate(url: string): Promise { throw new Error(`DNS resolution failed for ${hostname}`) } - for (const r of records) { - isDeniedIP(r.address, denyList) + if (denyList) { + for (const r of records) { + isDeniedIP(r.address, denyList) + } } const chosen = records.find((r) => r.family === 4) ?? records[0] From 73b5c24a3057983ec4b06ea9dba2928876972260 Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Tue, 20 Jan 2026 10:08:56 -0800 Subject: [PATCH 8/8] Update httpSecurity.ts --- packages/components/src/httpSecurity.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/components/src/httpSecurity.ts b/packages/components/src/httpSecurity.ts index c8af7551b5d..7a72d5ec012 100644 --- a/packages/components/src/httpSecurity.ts +++ b/packages/components/src/httpSecurity.ts @@ -187,9 +187,7 @@ type ResolvedTarget = { async function resolveAndValidate(url: string): Promise { const denyListString = process.env.HTTP_DENY_LIST - const denyList = denyListString - ? denyListString.split(',').map((s) => s.trim()) - : null + const denyList = denyListString ? denyListString.split(',').map((s) => s.trim()) : null const u = new URL(url) const hostname = u.hostname