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:
+120
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type OutboundRequestSource } from './outbound-request-source.type';
|
||||
|
||||
export type OutboundRequestContext = {
|
||||
workspaceId: string;
|
||||
source: OutboundRequestSource;
|
||||
userId?: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type OutboundRequestSource =
|
||||
| 'webhook'
|
||||
| 'workflow-http'
|
||||
| 'logic-function';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SecureHttpClientService } from './secure-http-client.service';
|
||||
|
||||
@Module({
|
||||
providers: [SecureHttpClientService],
|
||||
exports: [SecureHttpClientService],
|
||||
})
|
||||
export class SecureHttpClientModule {}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
+443
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+145
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
@@ -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);
|
||||
};
|
||||
};
|
||||
+96
@@ -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)
|
||||
);
|
||||
};
|
||||
+8
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user