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
@@ -11,6 +11,7 @@ export const webhookResponseSchema = z.strictObject({
url: z.string(),
webhookId: z.string(),
eventName: z.string(),
error: z.string().optional(),
}),
});
@@ -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);
};
};
@@ -72,11 +72,11 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.OTHER,
description:
'Enable safe mode for HTTP requests (prevents private IPs and other security risks)',
'Enable safe mode for outbound HTTP requests (prevents private IPs and other security risks). Applies to HTTP workflow actions and webhooks.',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
HTTP_TOOL_SAFE_MODE_ENABLED = true;
OUTBOUND_HTTP_SAFE_MODE_ENABLED = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TOKENS_DURATION,
@@ -1,5 +1,3 @@
import { HttpService } from '@nestjs/axios';
import crypto from 'crypto';
import { getAbsoluteUrl } from 'twenty-shared/utils';
@@ -11,6 +9,7 @@ import { Processor } from 'src/engine/core-modules/message-queue/decorators/proc
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
export type CallWebhookJobData = {
targetUrl: string;
@@ -30,9 +29,9 @@ export type CallWebhookJobData = {
@Processor(MessageQueue.webhookQueue)
export class CallWebhookJob {
constructor(
private readonly httpService: HttpService,
private readonly auditService: AuditService,
private readonly metricsService: MetricsService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private generateSignature(
@@ -84,7 +83,9 @@ export class CallWebhookJob {
.toString('hex');
}
const response = await this.httpService.axiosRef.post(
const axiosClient = this.secureHttpClientService.getHttpClient();
const response = await axiosClient.post(
getAbsoluteUrl(data.targetUrl),
payloadWithoutSecret,
{
@@ -106,10 +107,18 @@ export class CallWebhookJob {
shouldStoreInCache: false,
});
} catch (err) {
const isSSRFBlocked =
err instanceof Error &&
err.message.includes('internal IP address') &&
err.message.includes('is not allowed');
auditService.insertWorkspaceEvent(WEBHOOK_RESPONSE_EVENT, {
success: false,
...commonPayload,
...(err.response && { status: err.response.status }),
...(isSSRFBlocked && {
error: 'Webhook URL resolves to a private/internal IP address',
}),
});
}
}
@@ -1,14 +1,14 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { CallWebhookJobsJob } from 'src/engine/core-modules/webhook/jobs/call-webhook-jobs.job';
import { CallWebhookJob } from 'src/engine/core-modules/webhook/jobs/call-webhook.job';
import { WebhookModule } from 'src/engine/core-modules/webhook/webhook.module';
@Module({
imports: [HttpModule, AuditModule, WebhookModule, MetricsModule],
imports: [AuditModule, WebhookModule, MetricsModule, ToolModule],
providers: [CallWebhookJobsJob, CallWebhookJob],
})
export class WebhookJobModule {}
@@ -1,40 +1,79 @@
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
createWebhook,
createWebhookReceiver,
deleteWebhook,
getWebhook,
getWebhooks,
updateWebhook,
} from 'test/integration/metadata/suites/utils/webhook-test.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { v4 as uuidv4 } from 'uuid';
const CREATE_CONFIG_VARIABLE_MUTATION = gql`
mutation CreateDatabaseConfigVariable($key: String!, $value: JSON!) {
createDatabaseConfigVariable(key: $key, value: $value)
}
`;
const GET_CONFIG_VARIABLE_QUERY = gql`
query GetDatabaseConfigVariable($key: String!) {
getDatabaseConfigVariable(key: $key) {
value
source
}
}
`;
const DELETE_CONFIG_VARIABLE_MUTATION = gql`
mutation DeleteDatabaseConfigVariable($key: String!) {
deleteDatabaseConfigVariable(key: $key)
}
`;
const DESTROY_PERSON_MUTATION = gql`
mutation DestroyPerson($id: ID!) {
destroyPerson(id: $id) {
id
}
}
`;
const CREATE_PERSON_MUTATION = gql`
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) {
id
name {
firstName
lastName
}
}
}
`;
describe('webhooksResolver (e2e)', () => {
let createdWebhookId: string | undefined;
let createdPersonId: string | undefined;
afterEach(async () => {
if (createdWebhookId) {
await makeMetadataAPIRequest({
query: gql`
mutation DeleteWebhook($input: DeleteWebhookInput!) {
deleteWebhook(input: $input)
}
`,
variables: {
input: { id: createdWebhookId },
},
if (createdPersonId) {
await makeGraphqlAPIRequest({
query: DESTROY_PERSON_MUTATION,
variables: { id: createdPersonId },
}).catch(() => {});
createdPersonId = undefined;
}
if (createdWebhookId) {
await deleteWebhook(createdWebhookId).catch(() => {});
createdWebhookId = undefined;
}
});
describe('webhooks query', () => {
it('should find many webhooks', async () => {
const response = await makeMetadataAPIRequest({
query: gql`
query GetWebhooks {
webhooks {
id
targetUrl
operations
description
secret
}
}
`,
});
const response = await getWebhooks();
expect(response.status).toBe(200);
expect(response.body.data).toBeDefined();
@@ -53,37 +92,22 @@ describe('webhooksResolver (e2e)', () => {
secret: 'test-secret',
};
const response = await makeMetadataAPIRequest({
query: gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: webhookInput,
},
});
const response = await createWebhook(webhookInput);
expect(response.status).toBe(200);
expect(response.body.data).toBeDefined();
expect(response.body.errors).toBeUndefined();
const createdWebhook = response.body.data.createWebhook;
const createdWebhookData = response.body.data.createWebhook;
expect(createdWebhook).toBeDefined();
expect(createdWebhook.id).toBeDefined();
expect(createdWebhook.targetUrl).toBe(webhookInput.targetUrl);
expect(createdWebhook.operations).toEqual(webhookInput.operations);
expect(createdWebhook.description).toBe(webhookInput.description);
expect(createdWebhook.secret).toBe(webhookInput.secret);
expect(createdWebhookData).toBeDefined();
expect(createdWebhookData.id).toBeDefined();
expect(createdWebhookData.targetUrl).toBe(webhookInput.targetUrl);
expect(createdWebhookData.operations).toEqual(webhookInput.operations);
expect(createdWebhookData.description).toBe(webhookInput.description);
expect(createdWebhookData.secret).toBe(webhookInput.secret);
createdWebhookId = createdWebhook.id;
createdWebhookId = createdWebhookData.id;
});
it('should fail to create webhook with invalid URL', async () => {
@@ -94,22 +118,7 @@ describe('webhooksResolver (e2e)', () => {
secret: 'test-secret',
};
const response = await makeMetadataAPIRequest({
query: gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: webhookInput,
},
});
const response = await createWebhook(webhookInput);
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
@@ -119,188 +128,89 @@ describe('webhooksResolver (e2e)', () => {
describe('updateWebhook mutation', () => {
it('should update a webhook successfully', async () => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: {
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
const createResponse = await createWebhook({
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
});
const createdWebhook = createResponse.body.data.createWebhook;
const createdWebhookData = createResponse.body.data.createWebhook;
createdWebhookId = createdWebhook.id;
createdWebhookId = createdWebhookData.id;
const updateInput = {
id: createdWebhook.id,
id: createdWebhookData.id,
targetUrl: 'https://updated.com/webhook',
operations: ['person.updated', 'company.created'],
description: 'Updated webhook',
secret: 'updated-secret',
};
const updateResponse = await makeMetadataAPIRequest({
query: gql`
mutation UpdateWebhook($input: UpdateWebhookInput!) {
updateWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: updateInput,
},
});
const updateResponse = await updateWebhook(updateInput);
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.data).toBeDefined();
expect(updateResponse.body.errors).toBeUndefined();
const updatedWebhook = updateResponse.body.data.updateWebhook;
const updatedWebhookData = updateResponse.body.data.updateWebhook;
expect(updatedWebhook.id).toBe(createdWebhook.id);
expect(updatedWebhook.targetUrl).toBe(updateInput.targetUrl);
expect(updatedWebhook.operations).toEqual(updateInput.operations);
expect(updatedWebhook.description).toBe(updateInput.description);
expect(updatedWebhook.secret).toBe(updateInput.secret);
expect(updatedWebhookData.id).toBe(createdWebhookData.id);
expect(updatedWebhookData.targetUrl).toBe(updateInput.targetUrl);
expect(updatedWebhookData.operations).toEqual(updateInput.operations);
expect(updatedWebhookData.description).toBe(updateInput.description);
expect(updatedWebhookData.secret).toBe(updateInput.secret);
});
});
describe('webhook query', () => {
it('should find a specific webhook', async () => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: {
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
const createResponse = await createWebhook({
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
});
const createdWebhook = createResponse.body.data.createWebhook;
const createdWebhookData = createResponse.body.data.createWebhook;
createdWebhookId = createdWebhook.id;
createdWebhookId = createdWebhookData.id;
const queryResponse = await makeMetadataAPIRequest({
query: gql`
query GetWebhook($input: GetWebhookInput!) {
webhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: { id: createdWebhook.id },
},
});
const queryResponse = await getWebhook(createdWebhookData.id);
expect(queryResponse.status).toBe(200);
expect(queryResponse.body.data).toBeDefined();
expect(queryResponse.body.errors).toBeUndefined();
const webhook = queryResponse.body.data.webhook;
const webhookData = queryResponse.body.data.webhook;
expect(webhook).toBeDefined();
expect(webhook.id).toBe(createdWebhook.id);
expect(webhook.targetUrl).toBe(createdWebhook.targetUrl);
expect(webhook.operations).toEqual(createdWebhook.operations);
expect(webhook.description).toBe(createdWebhook.description);
expect(webhook.secret).toBe(createdWebhook.secret);
expect(webhookData).toBeDefined();
expect(webhookData.id).toBe(createdWebhookData.id);
expect(webhookData.targetUrl).toBe(createdWebhookData.targetUrl);
expect(webhookData.operations).toEqual(createdWebhookData.operations);
expect(webhookData.description).toBe(createdWebhookData.description);
expect(webhookData.secret).toBe(createdWebhookData.secret);
});
});
describe('deleteWebhook mutation', () => {
it('should delete a webhook successfully', async () => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: {
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
},
},
const createResponse = await createWebhook({
targetUrl: 'https://example.com/webhook',
operations: ['person.created'],
description: 'Test webhook',
secret: 'test-secret',
});
const createdWebhook = createResponse.body.data.createWebhook;
const createdWebhookData = createResponse.body.data.createWebhook;
const deleteResponse = await makeMetadataAPIRequest({
query: gql`
mutation DeleteWebhook($input: DeleteWebhookInput!) {
deleteWebhook(input: $input)
}
`,
variables: {
input: { id: createdWebhook.id },
},
});
const deleteResponse = await deleteWebhook(createdWebhookData.id);
expect(deleteResponse.status).toBe(200);
expect(deleteResponse.body.data).toBeDefined();
expect(deleteResponse.body.errors).toBeUndefined();
const queryResponse = await makeMetadataAPIRequest({
query: gql`
query GetWebhook($input: GetWebhookInput!) {
webhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`,
variables: {
input: { id: createdWebhook.id },
},
});
const queryResponse = await getWebhook(createdWebhookData.id);
expect(queryResponse.status).toBe(200);
expect(queryResponse.body.data.webhook).toBeNull();
@@ -308,4 +218,124 @@ describe('webhooksResolver (e2e)', () => {
createdWebhookId = undefined;
});
});
describe('webhook delivery', () => {
const WEBHOOK_RECEIVER_PORT = 4317;
it('should block delivery to private IP when safe mode is enabled (SSRF protection)', async () => {
const receiver = await createWebhookReceiver(WEBHOOK_RECEIVER_PORT);
try {
const createWebhookResponse = await createWebhook({
targetUrl: `http://127.0.0.1:${WEBHOOK_RECEIVER_PORT}/webhook`,
operations: ['person.created'],
description: 'SSRF test webhook',
secret: 'test-secret',
});
expect(createWebhookResponse.body.errors).toBeUndefined();
createdWebhookId = createWebhookResponse.body.data.createWebhook.id;
const testId = uuidv4().slice(0, 8);
const createPersonResponse = await makeGraphqlAPIRequest({
query: CREATE_PERSON_MUTATION,
variables: {
data: {
name: {
firstName: 'SSRFTest',
lastName: `User-${testId}`,
},
},
},
});
expect(createPersonResponse.status).toBe(200);
expect(createPersonResponse.body.errors).toBeUndefined();
createdPersonId = createPersonResponse.body.data.createPerson.id;
jest.useRealTimers();
await new Promise((resolve) => setTimeout(resolve, 100));
jest.useFakeTimers();
expect(receiver.receivedPayloads.length).toBe(0);
} finally {
await receiver.close();
}
});
it('should deliver webhook successfully when safe mode is disabled', async () => {
jest.useRealTimers();
const receiver = await createWebhookReceiver(WEBHOOK_RECEIVER_PORT);
try {
const createConfigResponse = await makeAdminPanelAPIRequest({
query: CREATE_CONFIG_VARIABLE_MUTATION,
variables: {
key: 'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
value: false,
},
});
expect(createConfigResponse.body.errors).toBeUndefined();
expect(
createConfigResponse.body.data.createDatabaseConfigVariable,
).toBe(true);
const verifyConfig = await makeAdminPanelAPIRequest({
query: GET_CONFIG_VARIABLE_QUERY,
variables: { key: 'OUTBOUND_HTTP_SAFE_MODE_ENABLED' },
});
expect(verifyConfig.body.data.getDatabaseConfigVariable.value).toBe(
false,
);
expect(verifyConfig.body.data.getDatabaseConfigVariable.source).toBe(
'DATABASE',
);
const createWebhookResponse = await createWebhook({
targetUrl: `http://127.0.0.1:${WEBHOOK_RECEIVER_PORT}/webhook`,
operations: ['person.created'],
description: 'Delivery test webhook',
secret: 'test-secret',
});
expect(createWebhookResponse.body.errors).toBeUndefined();
createdWebhookId = createWebhookResponse.body.data.createWebhook.id;
const testId = uuidv4().slice(0, 8);
const createPersonResponse = await makeGraphqlAPIRequest({
query: CREATE_PERSON_MUTATION,
variables: {
data: {
name: {
firstName: 'WebhookDelivery',
lastName: `Test-${testId}`,
},
},
},
});
expect(createPersonResponse.status).toBe(200);
expect(createPersonResponse.body.errors).toBeUndefined();
createdPersonId = createPersonResponse.body.data.createPerson.id;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(receiver.receivedPayloads.length).toBe(1);
expect(receiver.receivedPayloads[0]).toMatchObject({
targetUrl: `http://127.0.0.1:${WEBHOOK_RECEIVER_PORT}/webhook`,
eventName: 'person.created',
});
} finally {
await receiver.close();
await makeAdminPanelAPIRequest({
query: DELETE_CONFIG_VARIABLE_MUTATION,
variables: { key: 'HTTP_TOOL_SAFE_MODE_ENABLED' },
}).catch(() => {});
jest.useFakeTimers();
}
});
});
});
@@ -0,0 +1,144 @@
import http from 'http';
import { gql } from 'graphql-tag';
import { makeMetadataAPIRequest } from './make-metadata-api-request.util';
const CREATE_WEBHOOK_MUTATION = gql`
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
const DELETE_WEBHOOK_MUTATION = gql`
mutation DeleteWebhook($input: DeleteWebhookInput!) {
deleteWebhook(input: $input)
}
`;
const GET_WEBHOOK_QUERY = gql`
query GetWebhook($input: GetWebhookInput!) {
webhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
const GET_WEBHOOKS_QUERY = gql`
query GetWebhooks {
webhooks {
id
targetUrl
operations
description
secret
}
}
`;
const UPDATE_WEBHOOK_MUTATION = gql`
mutation UpdateWebhook($input: UpdateWebhookInput!) {
updateWebhook(input: $input) {
id
targetUrl
operations
description
secret
}
}
`;
export type WebhookInput = {
targetUrl: string;
operations: string[];
description?: string;
secret?: string;
};
export type WebhookReceiver = {
server: http.Server;
receivedPayloads: object[];
close: () => Promise<void>;
};
export const createWebhook = (input: WebhookInput) => {
return makeMetadataAPIRequest({
query: CREATE_WEBHOOK_MUTATION,
variables: { input },
});
};
export const deleteWebhook = (id: string) => {
return makeMetadataAPIRequest({
query: DELETE_WEBHOOK_MUTATION,
variables: { input: { id } },
});
};
export const getWebhook = (id: string) => {
return makeMetadataAPIRequest({
query: GET_WEBHOOK_QUERY,
variables: { input: { id } },
});
};
export const getWebhooks = () => {
return makeMetadataAPIRequest({
query: GET_WEBHOOKS_QUERY,
});
};
export const updateWebhook = (
input: Partial<WebhookInput> & { id: string },
) => {
return makeMetadataAPIRequest({
query: UPDATE_WEBHOOK_MUTATION,
variables: { input },
});
};
export const createWebhookReceiver = (
port: number,
): Promise<WebhookReceiver> => {
return new Promise((resolve) => {
const receivedPayloads: object[] = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
receivedPayloads.push(JSON.parse(body));
} catch {
receivedPayloads.push({ raw: body });
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
});
});
server.listen(port, '127.0.0.1', () => {
resolve({
server,
receivedPayloads,
close: () =>
new Promise<void>((resolveClose) =>
server.close(() => resolveClose()),
),
});
});
});
};
@@ -19,6 +19,7 @@ import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handl
import { ExceptionHandlerMockService } from 'src/engine/core-modules/exception-handler/mocks/exception-handler-mock.service';
import { MockedUnhandledExceptionFilter } from 'src/engine/core-modules/exception-handler/mocks/mock-unhandled-exception.filter';
import { SyncDriver } from 'src/engine/core-modules/message-queue/drivers/sync.driver';
import { JobsModule } from 'src/engine/core-modules/message-queue/jobs.module';
import { QUEUE_DRIVER } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
@@ -49,7 +50,12 @@ export const createApp = async (
const stripeSDKMockService = new StripeSDKMockService();
const mockExceptionHandlerService = new ExceptionHandlerMockService();
let moduleBuilder: TestingModuleBuilder = Test.createTestingModule({
imports: [AppModule, CommandModule, MessageQueueModule.registerExplorer()],
imports: [
AppModule,
CommandModule,
JobsModule,
MessageQueueModule.registerExplorer(),
],
providers: [
{
provide: APP_FILTER,