From cd7c2864d2afe7a9093a62cd21d1bc1d665c3ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 24 Jan 2026 11:46:46 +0100 Subject: [PATCH] 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 --- .../webhook/webhook-response.ts | 1 + .../services/secure-http-client.service.ts | 21 + .../engine/core-modules/tool/tool.module.ts | 10 +- .../tool/tools/http-tool/http-tool.ts | 18 +- .../get-secure-axios-adapter.util.spec.ts | 443 ++++++++++++++++++ .../__tests__/is-private-ip.util.spec.ts | 145 ++++++ .../utils/get-secure-axios-adapter.types.ts | 8 + .../utils/get-secure-axios-adapter.util.ts | 56 ++- .../twenty-config/config-variables.ts | 4 +- .../webhook/jobs/call-webhook.job.ts | 17 +- .../webhook/jobs/webhook-job.module.ts | 4 +- .../developers/webhooks.integration-spec.ts | 432 +++++++++-------- .../suites/utils/webhook-test.util.ts | 144 ++++++ .../test/integration/utils/create-app.ts | 8 +- 14 files changed, 1081 insertions(+), 230 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/is-private-ip.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.types.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts diff --git a/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response.ts b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response.ts index f0c4411424..a77561db65 100644 --- a/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response.ts +++ b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response.ts @@ -11,6 +11,7 @@ export const webhookResponseSchema = z.strictObject({ url: z.string(), webhookId: z.string(), eventName: z.string(), + error: z.string().optional(), }), }); diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts new file mode 100644 index 0000000000..8a154af850 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/services/secure-http-client.service.ts @@ -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(); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index c68c026342..09aa5c38b5 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts index 8f985640e3..040049d209 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.ts @@ -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 { diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts new file mode 100644 index 0000000000..ca4e82080b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/get-secure-axios-adapter.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/is-private-ip.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/is-private-ip.util.spec.ts new file mode 100644 index 0000000000..e2ec1bfb09 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/is-private-ip.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.types.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.types.ts new file mode 100644 index 0000000000..e0d105a5ac --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.types.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts index 1215559d76..640ac7dcb4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/get-secure-axios-adapter.util.ts @@ -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); }; }; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index cb0905e966..80d785dd26 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts b/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts index 4f36b06e7c..9bd598e36d 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts +++ b/packages/twenty-server/src/engine/core-modules/webhook/jobs/call-webhook.job.ts @@ -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', + }), }); } } diff --git a/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts b/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts index d5d22888c8..c1292e74c1 100644 --- a/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts +++ b/packages/twenty-server/src/engine/core-modules/webhook/jobs/webhook-job.module.ts @@ -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 {} diff --git a/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts index b9fb515199..ecc89dde85 100644 --- a/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/developers/webhooks.integration-spec.ts @@ -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(); + } + }); + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts new file mode 100644 index 0000000000..37c76b7492 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/utils/webhook-test.util.ts @@ -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; +}; + +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 & { id: string }, +) => { + return makeMetadataAPIRequest({ + query: UPDATE_WEBHOOK_MUTATION, + variables: { input }, + }); +}; + +export const createWebhookReceiver = ( + port: number, +): Promise => { + 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((resolveClose) => + server.close(() => resolveClose()), + ), + }); + }); + }); +}; diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index 4b8cebb331..ea8dfafc03 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -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,