fix(twenty-server): add SSRF protection to webhook requests (#17403)
## Summary - Adds SSRF (Server-Side Request Forgery) protection to webhook requests by using the same secure axios adapter already used by HTTP workflow actions - Prevents webhooks from making requests to private/internal IP addresses (10.x, 192.168.x, 172.16-31.x, 169.254.x, localhost) - Adds specific error logging when a webhook fails due to SSRF protection ## Context The HTTP workflow tool (`HTTP_REQUEST` action) already had SSRF protection via `HTTP_TOOL_SAFE_MODE_ENABLED`, but webhooks were using `HttpService` directly without this protection. This inconsistency meant users could potentially configure webhooks to probe internal infrastructure. ### What's protected now: | Feature | Before | After | |---------|--------|-------| | HTTP Workflow Action | Protected (secure adapter) | Protected (secure adapter) | | Webhooks | **Unprotected** | Protected (secure adapter) | ### The secure adapter validates: 1. Protocol must be `http:` or `https:` 2. DNS resolution of hostname 3. Resolved IP must not be in private ranges ## Test plan - [ ] Configure a webhook with an external URL (e.g., `https://webhook.site`) - should work - [ ] Configure a webhook with `http://localhost:3000` - should fail with SSRF error in audit log - [ ] Configure a webhook with `http://10.0.0.1/test` - should fail with SSRF error in audit log - [ ] Configure a webhook with a domain that resolves to a private IP - should fail
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class SecureHttpClientService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getHttpClient(): AxiosInstance {
|
||||
const isSafeModeEnabled = this.twentyConfigService.get(
|
||||
'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
|
||||
);
|
||||
|
||||
return isSafeModeEnabled
|
||||
? axios.create({ adapter: getSecureAdapter() })
|
||||
: axios.create();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
@@ -24,7 +25,14 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
|
||||
SendEmailTool,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
SecureHttpClientService,
|
||||
],
|
||||
exports: [
|
||||
HttpTool,
|
||||
SendEmailTool,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
SecureHttpClientService,
|
||||
],
|
||||
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool, CodeInterpreterTool],
|
||||
})
|
||||
export class ToolModule {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { parseDataFromContentType } from 'twenty-shared/workflow';
|
||||
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
|
||||
import { HttpRequestInputZodSchema } from 'src/engine/core-modules/tool/tools/http-tool/http-tool.schema';
|
||||
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
@@ -12,8 +13,6 @@ import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class HttpTool implements Tool {
|
||||
@@ -21,7 +20,9 @@ export class HttpTool implements Tool {
|
||||
'Make an HTTP request to any URL with configurable method, headers, and body.';
|
||||
inputSchema = HttpRequestInputZodSchema;
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
constructor(
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
@@ -47,16 +48,7 @@ export class HttpTool implements Tool {
|
||||
}
|
||||
}
|
||||
|
||||
const isSafeModeEnabled = this.twentyConfigService.get(
|
||||
'HTTP_TOOL_SAFE_MODE_ENABLED',
|
||||
);
|
||||
|
||||
const axiosClient = isSafeModeEnabled
|
||||
? axios.create({
|
||||
adapter: getSecureAdapter(),
|
||||
})
|
||||
: axios.create();
|
||||
|
||||
const axiosClient = this.secureHttpClientService.getHttpClient();
|
||||
const response = await axiosClient(axiosConfig);
|
||||
|
||||
return {
|
||||
|
||||
+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/tool/utils/get-secure-axios-adapter.types';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
|
||||
describe('getSecureAdapter', () => {
|
||||
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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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 = getSecureAdapter(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/tool/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+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;
|
||||
};
|
||||
+50
-6
@@ -1,4 +1,7 @@
|
||||
import dns from 'dns/promises';
|
||||
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,
|
||||
@@ -6,9 +9,19 @@ import axios, {
|
||||
} from 'axios';
|
||||
|
||||
import { isPrivateIp } from 'src/engine/core-modules/tool/utils/is-private-ip.util';
|
||||
const httpAdapter = axios.getAdapter('http');
|
||||
|
||||
export const getSecureAdapter = (): AxiosAdapter => {
|
||||
import { type SecureAdapterDependencies } from './get-secure-axios-adapter.types';
|
||||
|
||||
const defaultDependencies: SecureAdapterDependencies = {
|
||||
dnsLookup: dns.lookup,
|
||||
httpAdapter: axios.getAdapter('http'),
|
||||
};
|
||||
|
||||
export const getSecureAdapter = (
|
||||
dependencies: SecureAdapterDependencies = defaultDependencies,
|
||||
): AxiosAdapter => {
|
||||
const { dnsLookup, httpAdapter } = dependencies;
|
||||
|
||||
return async (config: InternalAxiosRequestConfig) => {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required');
|
||||
@@ -20,9 +33,7 @@ export const getSecureAdapter = (): AxiosAdapter => {
|
||||
throw new Error('URL should use http/https protocol');
|
||||
}
|
||||
|
||||
const { hostname } = url;
|
||||
|
||||
const { address: resolvedIp } = await dns.lookup(hostname);
|
||||
const { address: resolvedIp, family } = await dnsLookup(url.hostname);
|
||||
|
||||
if (isPrivateIp(resolvedIp)) {
|
||||
throw new Error(
|
||||
@@ -30,6 +41,39 @@ export const getSecureAdapter = (): AxiosAdapter => {
|
||||
);
|
||||
}
|
||||
|
||||
// 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 options =
|
||||
typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
|
||||
const callback =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? optionsOrCallback
|
||||
: maybeCallback;
|
||||
|
||||
if (options.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);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user