Improve SSRF IP validation and add protocol allowlist (#18518)

## 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)
This commit is contained in:
Félix Malfait
2026-03-10 13:14:49 +01:00
committed by GitHub
parent 6995420b71
commit 05c2da2d0f
5 changed files with 324 additions and 59 deletions
@@ -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());
@@ -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(
@@ -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);
});
});
});
@@ -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));
};
@@ -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'),