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:
+1
@@ -11,6 +11,7 @@ export const webhookResponseSchema = z.strictObject({
|
||||
url: z.string(),
|
||||
webhookId: z.string(),
|
||||
eventName: z.string(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class SecureHttpClientService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getHttpClient(): AxiosInstance {
|
||||
const isSafeModeEnabled = this.twentyConfigService.get(
|
||||
'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
|
||||
);
|
||||
|
||||
return isSafeModeEnabled
|
||||
? axios.create({ adapter: getSecureAdapter() })
|
||||
: axios.create();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
|
||||
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
|
||||
@@ -24,7 +25,14 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
|
||||
SendEmailTool,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
SecureHttpClientService,
|
||||
],
|
||||
exports: [
|
||||
HttpTool,
|
||||
SendEmailTool,
|
||||
SearchHelpCenterTool,
|
||||
CodeInterpreterTool,
|
||||
SecureHttpClientService,
|
||||
],
|
||||
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool, CodeInterpreterTool],
|
||||
})
|
||||
export class ToolModule {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios, { type AxiosRequestConfig } from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { parseDataFromContentType } from 'twenty-shared/workflow';
|
||||
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/tool/services/secure-http-client.service';
|
||||
import { HttpRequestInputZodSchema } from 'src/engine/core-modules/tool/tools/http-tool/http-tool.schema';
|
||||
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
@@ -12,8 +13,6 @@ import {
|
||||
type Tool,
|
||||
type ToolExecutionContext,
|
||||
} from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class HttpTool implements Tool {
|
||||
@@ -21,7 +20,9 @@ export class HttpTool implements Tool {
|
||||
'Make an HTTP request to any URL with configurable method, headers, and body.';
|
||||
inputSchema = HttpRequestInputZodSchema;
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
constructor(
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
parameters: ToolInput,
|
||||
@@ -47,16 +48,7 @@ export class HttpTool implements Tool {
|
||||
}
|
||||
}
|
||||
|
||||
const isSafeModeEnabled = this.twentyConfigService.get(
|
||||
'HTTP_TOOL_SAFE_MODE_ENABLED',
|
||||
);
|
||||
|
||||
const axiosClient = isSafeModeEnabled
|
||||
? axios.create({
|
||||
adapter: getSecureAdapter(),
|
||||
})
|
||||
: axios.create();
|
||||
|
||||
const axiosClient = this.secureHttpClientService.getHttpClient();
|
||||
const response = await axiosClient(axiosConfig);
|
||||
|
||||
return {
|
||||
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import { type SecureAdapterDependencies } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.types';
|
||||
import { getSecureAdapter } from 'src/engine/core-modules/tool/utils/get-secure-axios-adapter.util';
|
||||
|
||||
describe('getSecureAdapter', () => {
|
||||
let mockDnsLookup: jest.Mock;
|
||||
let mockHttpAdapter: jest.Mock;
|
||||
let dependencies: SecureAdapterDependencies;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDnsLookup = jest.fn();
|
||||
mockHttpAdapter = jest.fn().mockResolvedValue({ data: 'success' });
|
||||
dependencies = {
|
||||
dnsLookup: mockDnsLookup,
|
||||
httpAdapter: mockHttpAdapter,
|
||||
};
|
||||
});
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should throw if URL is not provided', async () => {
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = { url: undefined } as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow('URL is required');
|
||||
});
|
||||
|
||||
it('should throw for non-http/https protocols', async () => {
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'file:///etc/passwd',
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'URL should use http/https protocol',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for ftp protocol', async () => {
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'ftp://example.com/file',
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'URL should use http/https protocol',
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow http protocol', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://example.com',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow https protocol', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('private IP blocking', () => {
|
||||
it('should block requests to 127.0.0.1', async () => {
|
||||
mockDnsLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://localhost',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'Request to internal IP address 127.0.0.1 is not allowed.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should block requests to 10.x.x.x range', async () => {
|
||||
mockDnsLookup.mockResolvedValue({ address: '10.0.0.1', family: 4 });
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://internal.example.com',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'Request to internal IP address 10.0.0.1 is not allowed.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should block requests to 192.168.x.x range', async () => {
|
||||
mockDnsLookup.mockResolvedValue({ address: '192.168.1.1', family: 4 });
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://router.local',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'Request to internal IP address 192.168.1.1 is not allowed.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should block requests to 172.16-31.x.x range', async () => {
|
||||
mockDnsLookup.mockResolvedValue({ address: '172.16.0.1', family: 4 });
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://internal.corp',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'Request to internal IP address 172.16.0.1 is not allowed.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should block requests to cloud metadata endpoint (169.254.169.254)', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '169.254.169.254',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://metadata.google.internal',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await expect(adapter(config)).rejects.toThrow(
|
||||
'Request to internal IP address 169.254.169.254 is not allowed.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS rebinding protection', () => {
|
||||
it('should preserve original hostname in URL for TLS validation', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/api/data',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/api/data',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set httpsAgent with custom lookup for HTTPS requests', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/api/data',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
expect(config.httpAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set httpAgent with custom lookup for HTTP requests', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'http://example.com/api/data',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(config.httpAgent).toBeInstanceOf(http.Agent);
|
||||
expect(config.httpsAgent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use custom lookup that returns validated IP', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/api/data',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
// Verify the agent's lookup returns our pre-validated IP
|
||||
const agent = config.httpsAgent as https.Agent;
|
||||
const lookupFn = (agent.options as { lookup?: Function }).lookup;
|
||||
|
||||
expect(lookupFn).toBeDefined();
|
||||
|
||||
const lookupResult = await new Promise<{
|
||||
address: string;
|
||||
family: number;
|
||||
}>((resolve) => {
|
||||
lookupFn!(
|
||||
'any-hostname',
|
||||
{},
|
||||
(err: unknown, address: string, family: number) => {
|
||||
resolve({ address, family });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(lookupResult.address).toBe('93.184.216.34');
|
||||
expect(lookupResult.family).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle IPv6 addresses correctly', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '2001:4860:4860::8888',
|
||||
family: 6,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/api/data',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
const agent = config.httpsAgent as https.Agent;
|
||||
const lookupFn = (agent.options as { lookup?: Function }).lookup;
|
||||
|
||||
const lookupResult = await new Promise<{
|
||||
address: string;
|
||||
family: number;
|
||||
}>((resolve) => {
|
||||
lookupFn!(
|
||||
'any-hostname',
|
||||
{},
|
||||
(err: unknown, address: string, family: number) => {
|
||||
resolve({ address, family });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(lookupResult.address).toBe('2001:4860:4860::8888');
|
||||
expect(lookupResult.family).toBe(6);
|
||||
});
|
||||
|
||||
it('should preserve query parameters in URL', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/api?foo=bar&baz=qux',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/api?foo=bar&baz=qux',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve port in URL', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com:8443/api',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com:8443/api',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public IP allowlist', () => {
|
||||
it('should allow requests to public IP addresses', async () => {
|
||||
mockDnsLookup.mockResolvedValue({ address: '8.8.8.8', family: 4 });
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://dns.google',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow requests to standard web servers', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle URLs with authentication', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://user:pass@example.com/api',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://user:pass@example.com/api',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle URLs with fragments', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com/page#section',
|
||||
headers: new AxiosHeaders(),
|
||||
} as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work without pre-existing headers', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com',
|
||||
headers: undefined,
|
||||
} as unknown as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
});
|
||||
|
||||
it('should work with plain object headers', async () => {
|
||||
mockDnsLookup.mockResolvedValue({
|
||||
address: '93.184.216.34',
|
||||
family: 4,
|
||||
});
|
||||
|
||||
const adapter = getSecureAdapter(dependencies);
|
||||
const config = {
|
||||
url: 'https://example.com',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
} as unknown as InternalAxiosRequestConfig;
|
||||
|
||||
await adapter(config);
|
||||
|
||||
expect(mockHttpAdapter).toHaveBeenCalled();
|
||||
expect(config.httpsAgent).toBeInstanceOf(https.Agent);
|
||||
});
|
||||
});
|
||||
});
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { isPrivateIp } from 'src/engine/core-modules/tool/utils/is-private-ip.util';
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
describe('loopback addresses', () => {
|
||||
it('should detect 127.0.0.1 as private', () => {
|
||||
expect(isPrivateIp('127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 127.x.x.x range as private', () => {
|
||||
expect(isPrivateIp('127.0.0.2')).toBe(true);
|
||||
expect(isPrivateIp('127.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect IPv6 loopback ::1 as private', () => {
|
||||
expect(isPrivateIp('::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect :: (unspecified) as private', () => {
|
||||
expect(isPrivateIp('::')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect IPv4-mapped loopback as private', () => {
|
||||
expect(isPrivateIp('::ffff:127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect octal-encoded loopback as private', () => {
|
||||
expect(isPrivateIp('0177.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect hex-encoded loopback as private', () => {
|
||||
expect(isPrivateIp('0x7f.0.0.1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private IPv4 ranges', () => {
|
||||
it('should detect 10.x.x.x range as private', () => {
|
||||
expect(isPrivateIp('10.0.0.1')).toBe(true);
|
||||
expect(isPrivateIp('10.255.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 192.168.x.x range as private', () => {
|
||||
expect(isPrivateIp('192.168.0.1')).toBe(true);
|
||||
expect(isPrivateIp('192.168.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect 172.16-31.x.x range as private', () => {
|
||||
expect(isPrivateIp('172.16.0.1')).toBe(true);
|
||||
expect(isPrivateIp('172.31.255.255')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect 172.15.x.x as private', () => {
|
||||
expect(isPrivateIp('172.15.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect 172.32.x.x as private', () => {
|
||||
expect(isPrivateIp('172.32.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect link-local 169.254.x.x as private', () => {
|
||||
expect(isPrivateIp('169.254.0.1')).toBe(true);
|
||||
expect(isPrivateIp('169.254.169.254')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPv4-mapped IPv6 private addresses', () => {
|
||||
it('should detect ::ffff:10.x.x.x as private', () => {
|
||||
expect(isPrivateIp('::ffff:10.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect ::ffff:192.168.x.x as private', () => {
|
||||
expect(isPrivateIp('::ffff:192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect ::ffff:172.16.x.x as private', () => {
|
||||
expect(isPrivateIp('::ffff:172.16.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect ::ffff:169.254.x.x as private', () => {
|
||||
expect(isPrivateIp('::ffff:169.254.169.254')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private IPv6 ranges', () => {
|
||||
it('should detect fc00::/7 (unique local) as private', () => {
|
||||
expect(isPrivateIp('fc00::1')).toBe(true);
|
||||
expect(isPrivateIp('fd00::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect fe80::/10 (link-local) as private', () => {
|
||||
expect(isPrivateIp('fe80::1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public IP addresses', () => {
|
||||
it('should not detect public IPv4 addresses as private', () => {
|
||||
expect(isPrivateIp('8.8.8.8')).toBe(false);
|
||||
expect(isPrivateIp('1.1.1.1')).toBe(false);
|
||||
expect(isPrivateIp('93.184.216.34')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect 192.167.x.x as private', () => {
|
||||
expect(isPrivateIp('192.167.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect 11.x.x.x as private', () => {
|
||||
expect(isPrivateIp('11.0.0.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and bypass attempts', () => {
|
||||
it('should handle decimal notation for 127.0.0.1', () => {
|
||||
// 127.0.0.1 in decimal = 2130706433
|
||||
expect(isPrivateIp('2130706433')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle full decimal notation for loopback', () => {
|
||||
// Standard 4-octet loopback address
|
||||
expect(isPrivateIp('127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw on invalid IP', () => {
|
||||
expect(() => isPrivateIp('invalid')).toThrow('invalid ipv4 address');
|
||||
});
|
||||
|
||||
it('should handle hex-encoded private IPs', () => {
|
||||
// 0x7f = 127, so 0x7f.0.0.1 = 127.0.0.1
|
||||
expect(isPrivateIp('0x7f.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle standard private IP in 10.x range', () => {
|
||||
// Standard 10.x.x.x private range
|
||||
expect(isPrivateIp('10.0.0.1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud metadata endpoints', () => {
|
||||
it('should block AWS/GCP metadata endpoint', () => {
|
||||
expect(isPrivateIp('169.254.169.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block Azure metadata endpoint', () => {
|
||||
expect(isPrivateIp('169.254.169.254')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type AxiosAdapter } from 'axios';
|
||||
|
||||
import type * as dns from 'dns/promises';
|
||||
|
||||
export type SecureAdapterDependencies = {
|
||||
dnsLookup: typeof dns.lookup;
|
||||
httpAdapter: AxiosAdapter;
|
||||
};
|
||||
+50
-6
@@ -1,4 +1,7 @@
|
||||
import dns from 'dns/promises';
|
||||
import * as dns from 'dns/promises';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { type LookupFunction } from 'net';
|
||||
|
||||
import axios, {
|
||||
type AxiosAdapter,
|
||||
@@ -6,9 +9,19 @@ import axios, {
|
||||
} from 'axios';
|
||||
|
||||
import { isPrivateIp } from 'src/engine/core-modules/tool/utils/is-private-ip.util';
|
||||
const httpAdapter = axios.getAdapter('http');
|
||||
|
||||
export const getSecureAdapter = (): AxiosAdapter => {
|
||||
import { type SecureAdapterDependencies } from './get-secure-axios-adapter.types';
|
||||
|
||||
const defaultDependencies: SecureAdapterDependencies = {
|
||||
dnsLookup: dns.lookup,
|
||||
httpAdapter: axios.getAdapter('http'),
|
||||
};
|
||||
|
||||
export const getSecureAdapter = (
|
||||
dependencies: SecureAdapterDependencies = defaultDependencies,
|
||||
): AxiosAdapter => {
|
||||
const { dnsLookup, httpAdapter } = dependencies;
|
||||
|
||||
return async (config: InternalAxiosRequestConfig) => {
|
||||
if (!config.url) {
|
||||
throw new Error('URL is required');
|
||||
@@ -20,9 +33,7 @@ export const getSecureAdapter = (): AxiosAdapter => {
|
||||
throw new Error('URL should use http/https protocol');
|
||||
}
|
||||
|
||||
const { hostname } = url;
|
||||
|
||||
const { address: resolvedIp } = await dns.lookup(hostname);
|
||||
const { address: resolvedIp, family } = await dnsLookup(url.hostname);
|
||||
|
||||
if (isPrivateIp(resolvedIp)) {
|
||||
throw new Error(
|
||||
@@ -30,6 +41,39 @@ export const getSecureAdapter = (): AxiosAdapter => {
|
||||
);
|
||||
}
|
||||
|
||||
// Use a custom lookup function that returns our pre-validated IP.
|
||||
// This prevents DNS rebinding attacks while preserving the original
|
||||
// hostname in the URL for proper TLS certificate validation and SNI.
|
||||
// Note: LookupFunction can be called with 2 or 3 arguments, and when
|
||||
// options.all is true, it expects an array of addresses.
|
||||
const ipFamily = family === 6 ? 6 : 4;
|
||||
const secureLookup: LookupFunction = (
|
||||
_hostname,
|
||||
optionsOrCallback,
|
||||
maybeCallback,
|
||||
) => {
|
||||
const options =
|
||||
typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
|
||||
const callback =
|
||||
typeof optionsOrCallback === 'function'
|
||||
? optionsOrCallback
|
||||
: maybeCallback;
|
||||
|
||||
if (options.all) {
|
||||
(callback as Function)(null, [
|
||||
{ address: resolvedIp, family: ipFamily },
|
||||
]);
|
||||
} else {
|
||||
(callback as Function)(null, resolvedIp, ipFamily);
|
||||
}
|
||||
};
|
||||
|
||||
if (url.protocol === 'https:') {
|
||||
config.httpsAgent = new https.Agent({ lookup: secureLookup });
|
||||
} else {
|
||||
config.httpAgent = new http.Agent({ lookup: secureLookup });
|
||||
}
|
||||
|
||||
return httpAdapter(config);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+231
-201
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user