From 05c2da2d0fddddab1997fbaf503b883662fb496a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Tue, 10 Mar 2026 13:14:49 +0100 Subject: [PATCH] Improve SSRF IP validation and add protocol allowlist (#18518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace regex-based private IP detection in `isPrivateIp` with Node.js `net.BlockList` for CIDR-based range checking, which properly handles all IPv4-mapped IPv6 representations (both dotted-decimal and hex forms) - Add missing non-routable IP ranges: carrier-grade NAT (`100.64.0.0/10`), IANA special purpose, documentation networks, benchmarking, multicast, and reserved ranges - Add protocol allowlist (http/https only) as an axios request interceptor in `SecureHttpClientService` and as a Zod refinement in the HTTP tool schema ## Test plan - [x] All 100 existing + new tests pass across 4 secure-http-client test suites - [x] New tests cover carrier-grade NAT range boundaries (100.64.0.0 – 100.127.255.255) - [x] New tests cover documentation, benchmarking, multicast, and reserved ranges - [x] New tests cover hex-form IPv4-mapped IPv6 addresses (the form Node.js URL parser actually produces) - [x] New tests verify protocol interceptor blocks `ftp:` and `file:` schemes - [x] New tests verify protocol interceptor is only active when safe mode is enabled Made with [Cursor](https://cursor.com) --- .../secure-http-client.service.spec.ts | 113 +++++++++++++++ .../secure-http-client.service.ts | 19 +++ .../__tests__/is-private-ip.util.spec.ts | 109 ++++++++++++++- .../utils/is-private-ip.util.ts | 129 +++++++++++------- .../tool/tools/http-tool/http-tool.schema.ts | 13 +- 5 files changed, 324 insertions(+), 59 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts index 5bbe6b039f..f065b2ce8b 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/__tests__/secure-http-client.service.spec.ts @@ -147,6 +147,119 @@ describe('SecureHttpClientService', () => { }); }); + describe('protocol validation interceptor', () => { + it('should allow http URLs when safe mode is on', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + const protocolInterceptor = interceptorHandlers[0].fulfilled; + + expect(() => + protocolInterceptor({ url: 'http://example.com/api' }), + ).not.toThrow(); + }); + + it('should allow https URLs when safe mode is on', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + const protocolInterceptor = interceptorHandlers[0].fulfilled; + + expect(() => + protocolInterceptor({ url: 'https://example.com/api' }), + ).not.toThrow(); + }); + + it('should reject ftp URLs when safe mode is on', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + const protocolInterceptor = interceptorHandlers[0].fulfilled; + + expect(() => + protocolInterceptor({ url: 'ftp://internal-server/data' }), + ).toThrow('Protocol ftp: is not allowed'); + }); + + it('should reject file URLs when safe mode is on', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + const protocolInterceptor = interceptorHandlers[0].fulfilled; + + expect(() => protocolInterceptor({ url: 'file:///etc/passwd' })).toThrow( + 'Protocol file: is not allowed', + ); + }); + + it('should reject non-http baseURL when url is empty string', () => { + const service = new SecureHttpClientService( + createMockConfigService({ OUTBOUND_HTTP_SAFE_MODE_ENABLED: true }), + ); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + const protocolInterceptor = interceptorHandlers[0].fulfilled; + + expect(() => + protocolInterceptor({ + url: '', + baseURL: 'ftp://internal-server/data', + }), + ).toThrow('Protocol ftp: is not allowed'); + }); + + it('should not add protocol interceptor when safe mode is off', () => { + const service = new SecureHttpClientService(createMockConfigService()); + const client = service.getHttpClient(); + + const interceptorHandlers = ( + client.interceptors.request as unknown as { + handlers: Array<{ fulfilled: Function }>; + } + ).handlers; + + expect(interceptorHandlers.length).toBe(0); + }); + }); + describe('logging interceptor', () => { it('should add a request interceptor when context is provided', () => { const service = new SecureHttpClientService(createMockConfigService()); diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts index 638a7f8e7c..a2e0b8cc69 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/secure-http-client.service.ts @@ -11,6 +11,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent import { type OutboundRequestContext } from './outbound-request-context.type'; const MAX_REDIRECTS = 5; +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); type SecureHttpClientConfig = CreateAxiosDefaults & { retries?: number; @@ -61,6 +62,24 @@ export class SecureHttpClientService { }); } + if (isSafeModeEnabled) { + client.interceptors.request.use((requestConfig) => { + const url = requestConfig.url || requestConfig.baseURL; + + if (url) { + const parsed = new URL(url, requestConfig.baseURL); + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Protocol ${parsed.protocol} is not allowed. Only HTTP and HTTPS are permitted.`, + ); + } + } + + return requestConfig; + }); + } + if (context) { client.interceptors.request.use((requestConfig) => { this.logger.log( diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/is-private-ip.util.spec.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/is-private-ip.util.spec.ts index 3debd8c386..9a421a5f85 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/is-private-ip.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/__tests__/is-private-ip.util.spec.ts @@ -81,7 +81,7 @@ describe('isPrivateIp', () => { }); }); - describe('IPv4-mapped IPv6 private addresses', () => { + describe('IPv4-mapped IPv6 in dotted-decimal form', () => { it('should detect ::ffff:10.x.x.x as private', () => { expect(isPrivateIp('::ffff:10.0.0.1')).toBe(true); }); @@ -97,6 +97,40 @@ describe('isPrivateIp', () => { it('should detect ::ffff:169.254.x.x as private', () => { expect(isPrivateIp('::ffff:169.254.169.254')).toBe(true); }); + + it('should allow ::ffff:8.8.8.8 (public) through', () => { + expect(isPrivateIp('::ffff:8.8.8.8')).toBe(false); + }); + }); + + describe('IPv4-mapped IPv6 in hex form (Node.js URL-normalized)', () => { + it('should detect ::ffff:7f00:1 (127.0.0.1) as private', () => { + expect(isPrivateIp('::ffff:7f00:1')).toBe(true); + }); + + it('should detect ::ffff:a9fe:a9fe (169.254.169.254) as private', () => { + expect(isPrivateIp('::ffff:a9fe:a9fe')).toBe(true); + }); + + it('should detect ::ffff:a00:1 (10.0.0.1) as private', () => { + expect(isPrivateIp('::ffff:a00:1')).toBe(true); + }); + + it('should detect ::ffff:c0a8:101 (192.168.1.1) as private', () => { + expect(isPrivateIp('::ffff:c0a8:101')).toBe(true); + }); + + it('should detect ::ffff:ac10:1 (172.16.0.1) as private', () => { + expect(isPrivateIp('::ffff:ac10:1')).toBe(true); + }); + + it('should detect ::ffff:0:0 (0.0.0.0) as private', () => { + expect(isPrivateIp('::ffff:0:0')).toBe(true); + }); + + it('should allow ::ffff:808:808 (8.8.8.8, public) through', () => { + expect(isPrivateIp('::ffff:808:808')).toBe(false); + }); }); describe('private IPv6 ranges', () => { @@ -110,6 +144,63 @@ describe('isPrivateIp', () => { }); }); + describe('carrier-grade NAT (100.64.0.0/10)', () => { + it('should detect 100.64.0.0 as private', () => { + expect(isPrivateIp('100.64.0.0')).toBe(true); + }); + + it('should detect 100.127.255.255 (end of range) as private', () => { + expect(isPrivateIp('100.127.255.255')).toBe(true); + }); + + it('should not detect 100.63.255.255 (just below range) as private', () => { + expect(isPrivateIp('100.63.255.255')).toBe(false); + }); + + it('should not detect 100.128.0.0 (just above range) as private', () => { + expect(isPrivateIp('100.128.0.0')).toBe(false); + }); + }); + + describe('IANA special purpose and documentation ranges', () => { + it('should detect 192.0.0.0/24 (IANA special purpose) as private', () => { + expect(isPrivateIp('192.0.0.1')).toBe(true); + }); + + it('should detect 192.0.2.0/24 (TEST-NET-1) as private', () => { + expect(isPrivateIp('192.0.2.1')).toBe(true); + }); + + it('should detect 198.51.100.0/24 (TEST-NET-2) as private', () => { + expect(isPrivateIp('198.51.100.1')).toBe(true); + }); + + it('should detect 203.0.113.0/24 (TEST-NET-3) as private', () => { + expect(isPrivateIp('203.0.113.1')).toBe(true); + }); + + it('should detect 198.18.0.0/15 (benchmarking) as private', () => { + expect(isPrivateIp('198.18.0.1')).toBe(true); + expect(isPrivateIp('198.19.255.255')).toBe(true); + }); + + it('should not detect 198.20.0.0 (outside benchmarking) as private', () => { + expect(isPrivateIp('198.20.0.0')).toBe(false); + }); + }); + + describe('multicast and reserved ranges', () => { + it('should detect 224.0.0.0/4 (multicast) as private', () => { + expect(isPrivateIp('224.0.0.1')).toBe(true); + expect(isPrivateIp('239.255.255.255')).toBe(true); + }); + + it('should detect 240.0.0.0/4 (reserved) as private', () => { + expect(isPrivateIp('240.0.0.1')).toBe(true); + expect(isPrivateIp('255.255.255.254')).toBe(true); + }); + }); + describe('public IP addresses', () => { it('should not detect public IPv4 addresses as private', () => { expect(isPrivateIp('8.8.8.8')).toBe(false); @@ -124,16 +215,18 @@ describe('isPrivateIp', () => { it('should not detect 11.x.x.x as private', () => { expect(isPrivateIp('11.0.0.1')).toBe(false); }); + + it('should not detect public IPv6 as private', () => { + expect(isPrivateIp('2001:4860:4860::8888')).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); }); @@ -142,12 +235,10 @@ describe('isPrivateIp', () => { }); 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); }); }); @@ -157,8 +248,12 @@ describe('isPrivateIp', () => { expect(isPrivateIp('169.254.169.254')).toBe(true); }); - it('should block Azure metadata endpoint', () => { - expect(isPrivateIp('169.254.169.254')).toBe(true); + it('should block metadata via hex-form IPv4-mapped IPv6', () => { + expect(isPrivateIp('::ffff:a9fe:a9fe')).toBe(true); + }); + + it('should block metadata via dotted IPv4-mapped IPv6', () => { + expect(isPrivateIp('::ffff:169.254.169.254')).toBe(true); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/is-private-ip.util.ts b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/is-private-ip.util.ts index 0d607beb45..11410d094d 100644 --- a/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/is-private-ip.util.ts +++ b/packages/twenty-server/src/engine/core-modules/secure-http-client/utils/is-private-ip.util.ts @@ -1,30 +1,34 @@ -// Based on code from node-ip by indutny -// Licensed under MIT License -// https://github.com/indutny/node-ip +import { BlockList } from 'net'; -const ipv6Regex = - /^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i; +const PRIVATE_RANGES = new BlockList(); -const fromLong = (ipl: number) => { +PRIVATE_RANGES.addSubnet('0.0.0.0', 8); +PRIVATE_RANGES.addSubnet('10.0.0.0', 8); +PRIVATE_RANGES.addSubnet('100.64.0.0', 10); +PRIVATE_RANGES.addSubnet('127.0.0.0', 8); +PRIVATE_RANGES.addSubnet('169.254.0.0', 16); +PRIVATE_RANGES.addSubnet('172.16.0.0', 12); +PRIVATE_RANGES.addSubnet('192.0.0.0', 24); +PRIVATE_RANGES.addSubnet('192.0.2.0', 24); +PRIVATE_RANGES.addSubnet('192.168.0.0', 16); +PRIVATE_RANGES.addSubnet('198.18.0.0', 15); +PRIVATE_RANGES.addSubnet('198.51.100.0', 24); +PRIVATE_RANGES.addSubnet('203.0.113.0', 24); +PRIVATE_RANGES.addSubnet('224.0.0.0', 4); +PRIVATE_RANGES.addSubnet('240.0.0.0', 4); + +PRIVATE_RANGES.addSubnet('::1', 128, 'ipv6'); +PRIVATE_RANGES.addSubnet('::', 128, 'ipv6'); +PRIVATE_RANGES.addSubnet('fc00::', 7, 'ipv6'); +PRIVATE_RANGES.addSubnet('fe80::', 10, 'ipv6'); + +const fromLong = (ipl: number): string => { return `${ipl >>> 24}.${(ipl >> 16) & 255}.${(ipl >> 8) & 255}.${ipl & 255}`; }; -const isLoopback = (addr: string) => { - if (!/\./.test(addr) && !/:/.test(addr)) { - addr = fromLong(Number(addr)); - } - - return ( - /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) || - /^0177\./.test(addr) || - /^0x7f\./i.test(addr) || - /^fe80::1$/i.test(addr) || - /^::1$/.test(addr) || - /^::$/.test(addr) - ); -}; - -const normalizeToLong = (addr: string) => { +// Parses IPv4 in any encoding (dotted decimal, octal, hex, bare integer) +// into a 32-bit unsigned integer. Returns -1 for invalid input. +const normalizeToLong = (addr: string): number => { const parts = addr.split('.').map((part) => { if (part.startsWith('0x') || part.startsWith('0X')) { return parseInt(part, 16); @@ -65,37 +69,60 @@ const normalizeToLong = (addr: string) => { return val >>> 0; }; -// IPv6 addresses always contain colons; the colon check prevents the -// loose regex from false-positiving on bare decimal/hex IPv4 like '0'. -const isIpV6 = (hostname: string) => - hostname.includes(':') && ipv6Regex.test(hostname); +// Extracts the embedded IPv4 from an IPv4-mapped IPv6 address in hex +// notation (e.g. ::ffff:a9fe:a9fe → 169.254.169.254). Returns null +// if the address is not in this form. +const HEX_MAPPED_RE = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i; -export const isPrivateIp = (addr: string) => { - if (isLoopback(addr)) { - return true; +const extractIpv4FromHexMappedIpv6 = (addr: string): string | null => { + const match = addr.match(HEX_MAPPED_RE); + + if (!match) { + return null; } - if (!isIpV6(addr)) { - const ipl = normalizeToLong(addr); + const hi = parseInt(match[1], 16); + const lo = parseInt(match[2], 16); - if (ipl < 0) { - throw new Error('invalid ipv4 address'); - } - addr = fromLong(ipl); - } - - return ( - // 0.0.0.0/8 — "this host on this network" (RFC 1122), reaches localhost - /^(::f{4}:)?0\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || - /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || - /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || - /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i.test( - addr, - ) || - /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || - /^f[cd][0-9a-f]{2}:/i.test(addr) || - /^fe80:/i.test(addr) || - /^::1$/.test(addr) || - /^::$/.test(addr) - ); + return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; +}; + +// Extracts the embedded IPv4 from an IPv4-mapped IPv6 address in +// dotted-decimal notation (e.g. ::ffff:127.0.0.1 → 127.0.0.1). +const DOTTED_MAPPED_RE = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i; + +const extractIpv4FromDottedMappedIpv6 = (addr: string): string | null => { + const match = addr.match(DOTTED_MAPPED_RE); + + return match ? match[1] : null; +}; + +export const isPrivateIp = (addr: string): boolean => { + // IPv4-mapped IPv6 in hex form — the form Node.js URL parser produces. + const hexMappedIpv4 = extractIpv4FromHexMappedIpv6(addr); + + if (hexMappedIpv4 !== null) { + return PRIVATE_RANGES.check(hexMappedIpv4); + } + + // IPv4-mapped IPv6 in dotted-decimal form (::ffff:D.D.D.D) + const dottedMappedIpv4 = extractIpv4FromDottedMappedIpv6(addr); + + if (dottedMappedIpv4 !== null) { + return PRIVATE_RANGES.check(dottedMappedIpv4); + } + + // Pure IPv6 (any address containing a colon that isn't IPv4-mapped) + if (addr.includes(':')) { + return PRIVATE_RANGES.check(addr, 'ipv6'); + } + + // IPv4 in any encoding (standard, octal, hex, bare integer) + const ipl = normalizeToLong(addr); + + if (ipl < 0) { + throw new Error('invalid ipv4 address'); + } + + return PRIVATE_RANGES.check(fromLong(ipl)); }; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.schema.ts index 775eab0067..ba6523c4d1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/http-tool/http-tool.schema.ts @@ -1,7 +1,18 @@ import { z } from 'zod'; export const HttpRequestInputZodSchema = z.object({ - url: z.string().describe('The URL to make the request to'), + url: z + .string() + .url() + .refine( + (value) => { + const protocol = new URL(value).protocol; + + return protocol === 'http:' || protocol === 'https:'; + }, + { message: 'Only HTTP and HTTPS URLs are allowed' }, + ) + .describe('The URL to make the request to (HTTP or HTTPS only)'), method: z .enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) .describe('The HTTP method to use'),