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:
Félix Malfait
2026-01-24 11:46:46 +01:00
committed by GitHub
parent a07590eba5
commit cd7c2864d2
14 changed files with 1081 additions and 230 deletions
@@ -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 {
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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;
};
@@ -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);
};
};