Skip to content
Merged
Changes from 7 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
190 changes: 100 additions & 90 deletions packages/components/src/httpSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
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
Expand Down Expand Up @@ -62,96 +64,58 @@
*/
export async function secureAxiosRequest(config: AxiosRequestConfig, maxRedirects: number = 5): Promise<AxiosResponse> {
let currentUrl = config.url
let redirectCount = 0
let currentConfig = { ...config, maxRedirects: 0 } // Disable automatic redirects

// Validate the initial URL
if (currentUrl) {
await checkDenyList(currentUrl)
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
}
}

Expand All @@ -171,11 +135,11 @@
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) {
Expand All @@ -196,13 +160,7 @@
}

// 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) {
Expand All @@ -219,3 +177,55 @@

throw new Error('Too many redirects')
}

type ResolvedTarget = {
hostname: string
ip: string
family: 4 | 6
protocol: 'http' | 'https'
}

async function resolveAndValidate(url: string): Promise<ResolvedTarget> {
const denyListString = process.env.HTTP_DENY_LIST
if (!denyListString) {
throw new Error('HTTP_DENY_LIST must be set for secure requests')
}

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)
}
})
}