Extract SecureHttpClientService into its own module (#17828)

## Summary

- **Extract `SecureHttpClientService`** from `tool` module into a
dedicated `core-modules/secure-http-client/` module with proper NestJS
module encapsulation
- **Fix module hygiene**: 12 modules that incorrectly listed
`SecureHttpClientService` as a direct provider now properly import
`SecureHttpClientModule`
- **Add structured logging** for outbound HTTP requests with
workspace/user context (for GuardDuty alert correlation)
- **Rename type files** to follow one-export-per-file convention
(`get-secure-axios-adapter.types.ts` ->
`secure-adapter-dependencies.type.ts`, new
`outbound-request-context.type.ts` / `outbound-request-source.type.ts`)

### Why

`SecureHttpClientService` is a cross-cutting concern (used by auth,
captcha, file upload, geo-map, telemetry, admin-panel, REST API, contact
creation, webhooks, and workflow tools) but was bundled inside the
`tool` module. Most consumers worked around this by listing it as a
direct provider instead of importing a module, which is fragile and not
idiomatic NestJS.

## Test plan

- [x] All 60 unit tests pass (`secure-http-client.service.spec.ts`,
`get-secure-axios-adapter.util.spec.ts`, `is-private-ip.util.spec.ts`)
- [x] Related module tests pass (admin-panel, contact-creation, tool)
- [x] `npx nx typecheck twenty-server` passes
- [x] `npx nx lint:diff-with-main twenty-server` passes
- [x] Server compiles and bootstraps successfully


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-10 10:16:51 +01:00
committed by GitHub
parent 3c2aec1894
commit a63b31931f
37 changed files with 260 additions and 85 deletions
@@ -0,0 +1,120 @@
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const createMockConfigService = (
overrides: Record<string, unknown> = {},
): TwentyConfigService => {
const defaults: Record<string, unknown> = {
OUTBOUND_HTTP_SAFE_MODE_ENABLED: false,
};
const config = { ...defaults, ...overrides };
return {
get: jest.fn((key: string) => config[key]),
} as unknown as TwentyConfigService;
};
describe('SecureHttpClientService', () => {
describe('getHttpClient', () => {
it('should return a plain axios instance when safe mode is off', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient();
expect(client).toBeDefined();
expect(client.defaults.httpAgent).toBeUndefined();
expect(client.defaults.httpsAgent).toBeUndefined();
});
it('should return an axios instance with secure adapter when safe mode is on', () => {
const service = new SecureHttpClientService(
createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }),
);
const client = service.getHttpClient();
expect(client).toBeDefined();
expect(typeof client.defaults.adapter).toBe('function');
});
it('should pass through axios config like baseURL', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient({
baseURL: 'https://example.com/api',
});
expect(client.defaults.baseURL).toBe('https://example.com/api');
});
});
describe('getInternalHttpClient', () => {
it('should return a plain axios instance regardless of safe mode', () => {
const service = new SecureHttpClientService(
createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }),
);
const client = service.getInternalHttpClient();
expect(client).toBeDefined();
});
it('should pass through axios config like baseURL', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getInternalHttpClient({
baseURL: 'http://localhost:3000',
});
expect(client.defaults.baseURL).toBe('http://localhost:3000');
});
});
describe('logging interceptor', () => {
it('should add a request interceptor when context is provided', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient(undefined, {
workspaceId: 'ws-123',
source: 'webhook',
});
const interceptorHandlers = (
client.interceptors.request as unknown as {
handlers: Array<{ fulfilled: Function }>;
}
).handlers;
expect(interceptorHandlers.length).toBe(1);
});
it('should not add a request interceptor when context is not provided', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient();
const interceptorHandlers = (
client.interceptors.request as unknown as {
handlers: Array<{ fulfilled: Function }>;
}
).handlers;
expect(interceptorHandlers.length).toBe(0);
});
it('should pass config through the interceptor and return it', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient(undefined, {
workspaceId: 'ws-456',
source: 'workflow-http',
userId: 'user-789',
});
const interceptorHandlers = (
client.interceptors.request as unknown as {
handlers: Array<{ fulfilled: Function }>;
}
).handlers;
const interceptorFn = interceptorHandlers[0].fulfilled;
const mockConfig = { method: 'GET', url: 'https://example.com/api' };
const result = interceptorFn(mockConfig);
expect(result).toBe(mockConfig);
});
});
});
@@ -0,0 +1,7 @@
import { type OutboundRequestSource } from './outbound-request-source.type';
export type OutboundRequestContext = {
workspaceId: string;
source: OutboundRequestSource;
userId?: string;
};
@@ -0,0 +1,4 @@
export type OutboundRequestSource =
| 'webhook'
| 'workflow-http'
| 'logic-function';
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SecureHttpClientService } from './secure-http-client.service';
@Module({
providers: [SecureHttpClientService],
exports: [SecureHttpClientService],
})
export class SecureHttpClientModule {}
@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios';
import { getSecureAxiosAdapter } from 'src/engine/core-modules/secure-http-client/utils/get-secure-axios-adapter.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type OutboundRequestContext } from './outbound-request-context.type';
@Injectable()
export class SecureHttpClientService {
private readonly logger = new Logger(SecureHttpClientService.name);
constructor(private readonly twentyConfigService: TwentyConfigService) {}
// Returns an SSRF-protected HTTP client for external requests.
// When context is provided, outbound requests are logged with
// workspace/user info for GuardDuty correlation.
getHttpClient(
config?: CreateAxiosDefaults,
context?: OutboundRequestContext,
): AxiosInstance {
const isSafeModeEnabled = this.twentyConfigService.get(
'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
);
const client = isSafeModeEnabled
? axios.create({ ...config, adapter: getSecureAxiosAdapter() })
: axios.create(config);
if (context) {
client.interceptors.request.use((requestConfig) => {
this.logger.log(
`Outbound HTTP request: ${requestConfig.method?.toUpperCase()} ${requestConfig.url} ` +
`[workspace=${context.workspaceId}, source=${context.source}` +
`${context.userId ? `, user=${context.userId}` : ''}]`,
);
return requestConfig;
});
}
return client;
}
// Returns a plain HTTP client for requests to trusted internal URLs
// (e.g., the server's own API endpoints). Not SSRF-protected.
getInternalHttpClient(config?: CreateAxiosDefaults): AxiosInstance {
return axios.create(config);
}
}
@@ -0,0 +1,443 @@
import * as http from 'http';
import * as https from 'https';
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios';
import { type SecureAdapterDependencies } from 'src/engine/core-modules/secure-http-client/utils/secure-adapter-dependencies.type';
import { getSecureAxiosAdapter } from 'src/engine/core-modules/secure-http-client/utils/get-secure-axios-adapter.util';
describe('getSecureAxiosAdapter', () => {
let mockDnsLookup: jest.Mock;
let mockHttpAdapter: jest.Mock;
let dependencies: SecureAdapterDependencies;
beforeEach(() => {
mockDnsLookup = jest.fn();
mockHttpAdapter = jest.fn().mockResolvedValue({ data: 'success' });
dependencies = {
dnsLookup: mockDnsLookup,
httpAdapter: mockHttpAdapter,
};
});
describe('URL validation', () => {
it('should throw if URL is not provided', async () => {
const adapter = getSecureAxiosAdapter(dependencies);
const config = { url: undefined } as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow('URL is required');
});
it('should throw for non-http/https protocols', async () => {
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'file:///etc/passwd',
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'URL should use http/https protocol',
);
});
it('should throw for ftp protocol', async () => {
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'ftp://example.com/file',
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'URL should use http/https protocol',
);
});
it('should allow http protocol', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://example.com',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
});
it('should allow https protocol', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
});
});
describe('private IP blocking', () => {
it('should block requests to 127.0.0.1', async () => {
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://localhost',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'Request to internal IP address 127.0.0.1 is not allowed.',
);
});
it('should block requests to 10.x.x.x range', async () => {
mockDnsLookup.mockResolvedValue({ address: '10.0.0.1', family: 4 });
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://internal.example.com',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'Request to internal IP address 10.0.0.1 is not allowed.',
);
});
it('should block requests to 192.168.x.x range', async () => {
mockDnsLookup.mockResolvedValue({ address: '192.168.1.1', family: 4 });
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://router.local',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'Request to internal IP address 192.168.1.1 is not allowed.',
);
});
it('should block requests to 172.16-31.x.x range', async () => {
mockDnsLookup.mockResolvedValue({ address: '172.16.0.1', family: 4 });
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://internal.corp',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'Request to internal IP address 172.16.0.1 is not allowed.',
);
});
it('should block requests to cloud metadata endpoint (169.254.169.254)', async () => {
mockDnsLookup.mockResolvedValue({
address: '169.254.169.254',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://metadata.google.internal',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await expect(adapter(config)).rejects.toThrow(
'Request to internal IP address 169.254.169.254 is not allowed.',
);
});
});
describe('DNS rebinding protection', () => {
it('should preserve original hostname in URL for TLS validation', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/api/data',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/api/data',
}),
);
});
it('should set httpsAgent with custom lookup for HTTPS requests', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/api/data',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
expect(config.httpAgent).toBeUndefined();
});
it('should set httpAgent with custom lookup for HTTP requests', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'http://example.com/api/data',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(config.httpAgent).toBeInstanceOf(http.Agent);
expect(config.httpsAgent).toBeUndefined();
});
it('should use custom lookup that returns validated IP', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/api/data',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
// Verify the agent's lookup returns our pre-validated IP
const agent = config.httpsAgent as https.Agent;
const lookupFn = (agent.options as { lookup?: Function }).lookup;
expect(lookupFn).toBeDefined();
const lookupResult = await new Promise<{
address: string;
family: number;
}>((resolve) => {
lookupFn!(
'any-hostname',
{},
(err: unknown, address: string, family: number) => {
resolve({ address, family });
},
);
});
expect(lookupResult.address).toBe('93.184.216.34');
expect(lookupResult.family).toBe(4);
});
it('should handle IPv6 addresses correctly', async () => {
mockDnsLookup.mockResolvedValue({
address: '2001:4860:4860::8888',
family: 6,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/api/data',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
const agent = config.httpsAgent as https.Agent;
const lookupFn = (agent.options as { lookup?: Function }).lookup;
const lookupResult = await new Promise<{
address: string;
family: number;
}>((resolve) => {
lookupFn!(
'any-hostname',
{},
(err: unknown, address: string, family: number) => {
resolve({ address, family });
},
);
});
expect(lookupResult.address).toBe('2001:4860:4860::8888');
expect(lookupResult.family).toBe(6);
});
it('should preserve query parameters in URL', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/api?foo=bar&baz=qux',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/api?foo=bar&baz=qux',
}),
);
});
it('should preserve port in URL', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com:8443/api',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com:8443/api',
}),
);
});
});
describe('public IP allowlist', () => {
it('should allow requests to public IP addresses', async () => {
mockDnsLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://dns.google',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
});
it('should allow requests to standard web servers', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('should handle URLs with authentication', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://user:pass@example.com/api',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://user:pass@example.com/api',
}),
);
});
it('should handle URLs with fragments', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com/page#section',
headers: new AxiosHeaders(),
} as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
});
it('should work without pre-existing headers', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com',
headers: undefined,
} as unknown as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
});
it('should work with plain object headers', async () => {
mockDnsLookup.mockResolvedValue({
address: '93.184.216.34',
family: 4,
});
const adapter = getSecureAxiosAdapter(dependencies);
const config = {
url: 'https://example.com',
headers: { 'Content-Type': 'application/json' },
} as unknown as InternalAxiosRequestConfig;
await adapter(config);
expect(mockHttpAdapter).toHaveBeenCalled();
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
});
});
});
@@ -0,0 +1,145 @@
import { isPrivateIp } from 'src/engine/core-modules/secure-http-client/utils/is-private-ip.util';
describe('isPrivateIp', () => {
describe('loopback addresses', () => {
it('should detect 127.0.0.1 as private', () => {
expect(isPrivateIp('127.0.0.1')).toBe(true);
});
it('should detect 127.x.x.x range as private', () => {
expect(isPrivateIp('127.0.0.2')).toBe(true);
expect(isPrivateIp('127.255.255.255')).toBe(true);
});
it('should detect IPv6 loopback ::1 as private', () => {
expect(isPrivateIp('::1')).toBe(true);
});
it('should detect :: (unspecified) as private', () => {
expect(isPrivateIp('::')).toBe(true);
});
it('should detect IPv4-mapped loopback as private', () => {
expect(isPrivateIp('::ffff:127.0.0.1')).toBe(true);
});
it('should detect octal-encoded loopback as private', () => {
expect(isPrivateIp('0177.0.0.1')).toBe(true);
});
it('should detect hex-encoded loopback as private', () => {
expect(isPrivateIp('0x7f.0.0.1')).toBe(true);
});
});
describe('private IPv4 ranges', () => {
it('should detect 10.x.x.x range as private', () => {
expect(isPrivateIp('10.0.0.1')).toBe(true);
expect(isPrivateIp('10.255.255.255')).toBe(true);
});
it('should detect 192.168.x.x range as private', () => {
expect(isPrivateIp('192.168.0.1')).toBe(true);
expect(isPrivateIp('192.168.255.255')).toBe(true);
});
it('should detect 172.16-31.x.x range as private', () => {
expect(isPrivateIp('172.16.0.1')).toBe(true);
expect(isPrivateIp('172.31.255.255')).toBe(true);
});
it('should not detect 172.15.x.x as private', () => {
expect(isPrivateIp('172.15.0.1')).toBe(false);
});
it('should not detect 172.32.x.x as private', () => {
expect(isPrivateIp('172.32.0.1')).toBe(false);
});
it('should detect link-local 169.254.x.x as private', () => {
expect(isPrivateIp('169.254.0.1')).toBe(true);
expect(isPrivateIp('169.254.169.254')).toBe(true);
});
});
describe('IPv4-mapped IPv6 private addresses', () => {
it('should detect ::ffff:10.x.x.x as private', () => {
expect(isPrivateIp('::ffff:10.0.0.1')).toBe(true);
});
it('should detect ::ffff:192.168.x.x as private', () => {
expect(isPrivateIp('::ffff:192.168.1.1')).toBe(true);
});
it('should detect ::ffff:172.16.x.x as private', () => {
expect(isPrivateIp('::ffff:172.16.0.1')).toBe(true);
});
it('should detect ::ffff:169.254.x.x as private', () => {
expect(isPrivateIp('::ffff:169.254.169.254')).toBe(true);
});
});
describe('private IPv6 ranges', () => {
it('should detect fc00::/7 (unique local) as private', () => {
expect(isPrivateIp('fc00::1')).toBe(true);
expect(isPrivateIp('fd00::1')).toBe(true);
});
it('should detect fe80::/10 (link-local) as private', () => {
expect(isPrivateIp('fe80::1')).toBe(true);
});
});
describe('public IP addresses', () => {
it('should not detect public IPv4 addresses as private', () => {
expect(isPrivateIp('8.8.8.8')).toBe(false);
expect(isPrivateIp('1.1.1.1')).toBe(false);
expect(isPrivateIp('93.184.216.34')).toBe(false);
});
it('should not detect 192.167.x.x as private', () => {
expect(isPrivateIp('192.167.1.1')).toBe(false);
});
it('should not detect 11.x.x.x as private', () => {
expect(isPrivateIp('11.0.0.1')).toBe(false);
});
});
describe('edge cases and bypass attempts', () => {
it('should handle decimal notation for 127.0.0.1', () => {
// 127.0.0.1 in decimal = 2130706433
expect(isPrivateIp('2130706433')).toBe(true);
});
it('should handle full decimal notation for loopback', () => {
// Standard 4-octet loopback address
expect(isPrivateIp('127.0.0.1')).toBe(true);
});
it('should throw on invalid IP', () => {
expect(() => isPrivateIp('invalid')).toThrow('invalid ipv4 address');
});
it('should handle hex-encoded private IPs', () => {
// 0x7f = 127, so 0x7f.0.0.1 = 127.0.0.1
expect(isPrivateIp('0x7f.0.0.1')).toBe(true);
});
it('should handle standard private IP in 10.x range', () => {
// Standard 10.x.x.x private range
expect(isPrivateIp('10.0.0.1')).toBe(true);
});
});
describe('cloud metadata endpoints', () => {
it('should block AWS/GCP metadata endpoint', () => {
expect(isPrivateIp('169.254.169.254')).toBe(true);
});
it('should block Azure metadata endpoint', () => {
expect(isPrivateIp('169.254.169.254')).toBe(true);
});
});
});
@@ -0,0 +1,89 @@
import * as dns from 'dns/promises';
import * as http from 'http';
import * as https from 'https';
import { type LookupFunction } from 'net';
import axios, {
type AxiosAdapter,
type InternalAxiosRequestConfig,
} from 'axios';
import { isPrivateIp } from 'src/engine/core-modules/secure-http-client/utils/is-private-ip.util';
import { type SecureAdapterDependencies } from './secure-adapter-dependencies.type';
const defaultDependencies: SecureAdapterDependencies = {
dnsLookup: dns.lookup,
httpAdapter: axios.getAdapter('http'),
};
export const getSecureAxiosAdapter = (
dependencies: SecureAdapterDependencies = defaultDependencies,
): AxiosAdapter => {
const { dnsLookup, httpAdapter } = dependencies;
return async (config: InternalAxiosRequestConfig) => {
// Resolve full URL by combining baseURL and url, matching what the
// default axios HTTP adapter does internally. Without this, requests
// that rely on baseURL (e.g. captcha drivers) would fail the check
// below because config.url can be an empty string.
const resolvedUrl = config.url
? config.baseURL
? new URL(config.url, config.baseURL).toString()
: config.url
: config.baseURL;
if (!resolvedUrl) {
throw new Error('URL is required');
}
const url = new URL(resolvedUrl);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('URL should use http/https protocol');
}
const { address: resolvedIp, family } = await dnsLookup(url.hostname);
if (isPrivateIp(resolvedIp)) {
throw new Error(
`Request to internal IP address ${resolvedIp} is not allowed.`,
);
}
// Use a custom lookup function that returns our pre-validated IP.
// This prevents DNS rebinding attacks while preserving the original
// hostname in the URL for proper TLS certificate validation and SNI.
// Note: LookupFunction can be called with 2 or 3 arguments, and when
// options.all is true, it expects an array of addresses.
const ipFamily = family === 6 ? 6 : 4;
const secureLookup: LookupFunction = (
_hostname,
optionsOrCallback,
maybeCallback,
) => {
const lookupOptions =
typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
const callback =
typeof optionsOrCallback === 'function'
? optionsOrCallback
: maybeCallback;
if (lookupOptions.all) {
(callback as Function)(null, [
{ address: resolvedIp, family: ipFamily },
]);
} else {
(callback as Function)(null, resolvedIp, ipFamily);
}
};
if (url.protocol === 'https:') {
config.httpsAgent = new https.Agent({ lookup: secureLookup });
} else {
config.httpAgent = new http.Agent({ lookup: secureLookup });
}
return httpAdapter(config);
};
};
@@ -0,0 +1,96 @@
// Based on code from node-ip by indutny
// Licensed under MIT License
// https://github.com/indutny/node-ip
const ipv6Regex =
/^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i;
const fromLong = (ipl: number) => {
return `${ipl >>> 24}.${(ipl >> 16) & 255}.${(ipl >> 8) & 255}.${ipl & 255}`;
};
const isLoopback = (addr: string) => {
if (!/\./.test(addr) && !/:/.test(addr)) {
addr = fromLong(Number(addr));
}
return (
/^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) ||
/^0177\./.test(addr) ||
/^0x7f\./i.test(addr) ||
/^fe80::1$/i.test(addr) ||
/^::1$/.test(addr) ||
/^::$/.test(addr)
);
};
const normalizeToLong = (addr: string) => {
const parts = addr.split('.').map((part) => {
if (part.startsWith('0x') || part.startsWith('0X')) {
return parseInt(part, 16);
} else if (part.startsWith('0') && part !== '0' && /^[0-7]+$/.test(part)) {
return parseInt(part, 8);
} else if (/^[1-9]\d*$/.test(part) || part === '0') {
return parseInt(part, 10);
} else {
return NaN;
}
});
if (parts.some(isNaN)) return -1;
let val = 0;
const n = parts.length;
switch (n) {
case 1:
val = parts[0];
break;
case 2:
if (parts[0] > 0xff || parts[1] > 0xffffff) return -1;
val = (parts[0] << 24) | (parts[1] & 0xffffff);
break;
case 3:
if (parts[0] > 0xff || parts[1] > 0xff || parts[2] > 0xffff) return -1;
val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] & 0xffff);
break;
case 4:
if (parts.some((part) => part > 0xff)) return -1;
val = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
break;
default:
return -1;
}
return val >>> 0;
};
const isIpV6 = (hostname: string) => ipv6Regex.test(hostname);
export const isPrivateIp = (addr: string) => {
if (isLoopback(addr)) {
return true;
}
if (!isIpV6(addr)) {
const ipl = normalizeToLong(addr);
if (ipl < 0) {
throw new Error('invalid ipv4 address');
}
addr = fromLong(ipl);
}
return (
/^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(
addr,
) ||
/^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) ||
/^f[cd][0-9a-f]{2}:/i.test(addr) ||
/^fe80:/i.test(addr) ||
/^::1$/.test(addr) ||
/^::$/.test(addr)
);
};
@@ -0,0 +1,8 @@
import { type AxiosAdapter } from 'axios';
import type * as dns from 'dns/promises';
export type SecureAdapterDependencies = {
dnsLookup: typeof dns.lookup;
httpAdapter: AxiosAdapter;
};