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:
+113
@@ -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());
|
||||
|
||||
+19
@@ -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(
|
||||
|
||||
+102
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+78
-51
@@ -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));
|
||||
};
|
||||
|
||||
+12
-1
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user