From b4c4c168f725a6da2aa2108bf9cedf50d52e2346 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Thu, 25 Jun 2026 10:11:02 +0200 Subject: [PATCH] feat: Add configurable blocked hostnames for outbound requests (#32977) --- packages/@n8n/backend-network/README.md | 11 +- .../ssrf/__tests__/hostname-matcher.test.ts | 17 ++ .../__tests__/ssrf-protection.service.test.ts | 170 ++++++++++++++++++ .../src/ssrf/hostname-matcher.ts | 8 +- .../@n8n/backend-network/src/ssrf/index.ts | 1 + .../src/ssrf/ssrf-blocked-hostname.error.ts | 24 +++ .../src/ssrf/ssrf-protection.service.ts | 54 ++++-- .../__tests__/ssrf-protection.config.test.ts | 20 +++ .../src/configs/ssrf-protection.config.ts | 32 +++- packages/@n8n/config/test/config.test.ts | 1 + .../utils/__tests__/ssrf-integration.test.ts | 53 ++++++ 11 files changed, 367 insertions(+), 24 deletions(-) create mode 100644 packages/@n8n/backend-network/src/ssrf/ssrf-blocked-hostname.error.ts diff --git a/packages/@n8n/backend-network/README.md b/packages/@n8n/backend-network/README.md index 8abb1914b83..54ec59449f4 100644 --- a/packages/@n8n/backend-network/README.md +++ b/packages/@n8n/backend-network/README.md @@ -88,12 +88,11 @@ destination really not user-controlled?". `SsrfProtectionConfig` (env-driven) configures *how* the guard behaves once it runs — the blocked/allowed IP ranges (`N8N_SSRF_BLOCKED_IP_RANGES`, -`N8N_SSRF_ALLOWED_IP_RANGES`), the allowed hostnames -(`N8N_SSRF_ALLOWED_HOSTNAMES`), and the DNS-cache size. Its `enabled` flag -(`N8N_SSRF_PROTECTION_ENABLED`) is the **instance-wide gate that high-risk call -sites consult** to decide whether to turn the guard on (see below). The config -sets the policy; the call site decides whether that policy applies to *this* -destination. +`N8N_SSRF_ALLOWED_IP_RANGES`), the allowed and blocked hostnames +(`N8N_SSRF_ALLOWED_HOSTNAMES`, `N8N_SSRF_BLOCKED_HOSTNAMES`), and the DNS-cache +size. Its `enabled` flag (`N8N_SSRF_PROTECTION_ENABLED`) is the **instance-wide gate that high-risk call sites consult** to decide whether to turn the guard on (see below). + +The config sets the policy; the call site decides whether that policy applies to *this* destination. ### Choosing an SSRF level: low-risk vs high-risk calls diff --git a/packages/@n8n/backend-network/src/ssrf/__tests__/hostname-matcher.test.ts b/packages/@n8n/backend-network/src/ssrf/__tests__/hostname-matcher.test.ts index f23549995f4..487d384e31e 100644 --- a/packages/@n8n/backend-network/src/ssrf/__tests__/hostname-matcher.test.ts +++ b/packages/@n8n/backend-network/src/ssrf/__tests__/hostname-matcher.test.ts @@ -45,6 +45,23 @@ describe('HostnameMatcher', () => { }); }); + describe('trailing dot (FQDN) normalization', () => { + it('should match a hostname with a trailing dot against an exact pattern', () => { + const matcher = new HostnameMatcher(['internal.n8n.io']); + expect(matcher.matches('internal.n8n.io.')).toBe(true); + }); + + it('should match a hostname with a trailing dot against a wildcard pattern', () => { + const matcher = new HostnameMatcher(['*.example.com']); + expect(matcher.matches('api.example.com.')).toBe(true); + }); + + it('should match when the configured pattern carries a trailing dot', () => { + const matcher = new HostnameMatcher(['internal.n8n.io.']); + expect(matcher.matches('internal.n8n.io')).toBe(true); + }); + }); + it('should trim configured patterns', () => { const matcher = new HostnameMatcher([' *.example.com ', ' exact.example.com ']); expect(matcher.matches('api.example.com')).toBe(true); diff --git a/packages/@n8n/backend-network/src/ssrf/__tests__/ssrf-protection.service.test.ts b/packages/@n8n/backend-network/src/ssrf/__tests__/ssrf-protection.service.test.ts index 5246f3e0d49..e77ac95fb22 100644 --- a/packages/@n8n/backend-network/src/ssrf/__tests__/ssrf-protection.service.test.ts +++ b/packages/@n8n/backend-network/src/ssrf/__tests__/ssrf-protection.service.test.ts @@ -4,6 +4,7 @@ import type { LookupAddress } from 'node:dns'; import { mock } from 'vitest-mock-extended'; import type { DnsResolver } from '../../dns'; +import { SsrfBlockedHostnameError } from '../ssrf-blocked-hostname.error'; import { SsrfBlockedIpError } from '../ssrf-blocked-ip.error'; import { SsrfProtectionService } from '../ssrf-protection.service'; @@ -42,6 +43,13 @@ const expectAllowed = (result: unknown) => { expect(result).toEqual({ ok: true, result: undefined }); }; +const expectBlockedHostname = (result: unknown) => { + expect(result).toEqual({ ok: false, error: expect.any(SsrfBlockedHostnameError) as Error }); +}; + +const asHostnames = (values: string[]) => + values as unknown as SsrfProtectionConfig['blockedHostnames']; + describe('SsrfProtectionService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -526,6 +534,168 @@ describe('SsrfProtectionService', () => { }); }); + describe('blocked hostnames', () => { + it('should block a request whose hostname is on the deny-list', async () => { + const dnsResolver = createMockDnsResolver(); + const { service } = createService( + { blockedHostnames: asHostnames(['exfil.example.com']) }, + dnsResolver, + ); + + const result = await service.validateUrl('http://exfil.example.com/data'); + + expectBlockedHostname(result); + }); + + it('should deny by name before DNS resolution runs', async () => { + const dnsResolver = createMockDnsResolver(); + const { service } = createService( + { blockedHostnames: asHostnames(['exfil.example.com']) }, + dnsResolver, + ); + + await service.validateUrl('http://exfil.example.com/data'); + + expect(dnsResolver.lookup).not.toHaveBeenCalled(); + }); + + it('should deny even when the hostname resolves to a public IP', async () => { + const dnsResolver = createMockDnsResolver(); + dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + const { service } = createService( + { blockedHostnames: asHostnames(['exfil.example.com']) }, + dnsResolver, + ); + + expectBlockedHostname(await service.validateUrl('http://exfil.example.com/data')); + }); + + it('should match the deny-list case-insensitively', async () => { + const { service } = createService({ + blockedHostnames: asHostnames(['EXFIL.Example.COM']), + }); + + expectBlockedHostname(await service.validateUrl('http://exfil.example.com/data')); + }); + + describe('wildcard patterns', () => { + it('should block subdomains of a wildcard pattern', async () => { + const { service } = createService({ + blockedHostnames: asHostnames(['*.tracker.example']), + }); + + expectBlockedHostname(await service.validateUrl('http://a.tracker.example/')); + }); + + it('should not block the bare domain of a wildcard pattern', async () => { + const dnsResolver = createMockDnsResolver(); + dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + const { service } = createService( + { blockedHostnames: asHostnames(['*.tracker.example']) }, + dnsResolver, + ); + + expectAllowed(await service.validateUrl('http://tracker.example/')); + }); + }); + + describe('allow-list wins over deny-list', () => { + it('should allow a hostname present in both lists', async () => { + const dnsResolver = createMockDnsResolver(); + dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + const { service } = createService( + { + allowedHostnames: asHostnames(['api.example.com']), + blockedHostnames: asHostnames(['*.example.com']), + }, + dnsResolver, + ); + + expectAllowed(await service.validateUrl('http://api.example.com/')); + }); + + it('should still block siblings not carved out by the allow-list', async () => { + const { service } = createService({ + allowedHostnames: asHostnames(['api.example.com']), + blockedHostnames: asHostnames(['*.example.com']), + }); + + expectBlockedHostname(await service.validateUrl('http://other.example.com/')); + }); + }); + + describe('connect-time secure lookup', () => { + it('should reject a deny-listed hostname during lookup', async () => { + const dnsResolver = createMockDnsResolver(); + dnsResolver.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]); + const { service } = createService( + { blockedHostnames: asHostnames(['exfil.example.com']) }, + dnsResolver, + ); + const lookup = service.createSecureLookup(); + + const error = await new Promise((resolve) => + lookup('exfil.example.com', { all: false }, (lookupError) => resolve(lookupError)), + ); + + expect(error).toBeInstanceOf(SsrfBlockedHostnameError); + // Denied by name before DNS resolution, even on the TOCTOU-critical path. + expect(dnsResolver.lookup).not.toHaveBeenCalled(); + }); + }); + + describe('redirects', () => { + it('should block a redirect to a deny-listed hostname', () => { + const { service } = createService({ + blockedHostnames: asHostnames(['exfil.example.com']), + }); + + expect(() => service.validateRedirectSync('http://exfil.example.com/data')).toThrow( + SsrfBlockedHostnameError, + ); + }); + + it('should allow a redirect to a hostname carved out by the allow-list', () => { + const { service } = createService({ + allowedHostnames: asHostnames(['api.example.com']), + blockedHostnames: asHostnames(['*.example.com']), + }); + + expect(() => service.validateRedirectSync('http://api.example.com/')).not.toThrow(); + }); + }); + + describe('events', () => { + it('should emit ssrf.blocked with reason blocked_hostname on pre-flight', async () => { + const { service } = createService({ + blockedHostnames: asHostnames(['exfil.example.com']), + }); + const blocked = vi.fn(); + service.events.on('ssrf.blocked', blocked); + + await service.validateUrl('http://exfil.example.com/data'); + + expect(blocked).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'pre_flight', reason: 'blocked_hostname' }), + ); + }); + + it('should emit ssrf.blocked with reason blocked_hostname on redirect', () => { + const { service } = createService({ + blockedHostnames: asHostnames(['exfil.example.com']), + }); + const blocked = vi.fn(); + service.events.on('ssrf.blocked', blocked); + + expect(() => service.validateRedirectSync('http://exfil.example.com/')).toThrow(); + + expect(blocked).toHaveBeenCalledWith( + expect.objectContaining({ phase: 'redirect', reason: 'blocked_hostname' }), + ); + }); + }); + }); + describe('bypass prevention', () => { describe('URL encoding tricks', () => { it('should handle percent-encoded hostnames', async () => { diff --git a/packages/@n8n/backend-network/src/ssrf/hostname-matcher.ts b/packages/@n8n/backend-network/src/ssrf/hostname-matcher.ts index 299ec2ea3c9..d3b1618b451 100644 --- a/packages/@n8n/backend-network/src/ssrf/hostname-matcher.ts +++ b/packages/@n8n/backend-network/src/ssrf/hostname-matcher.ts @@ -12,8 +12,9 @@ type ParsedPattern = WildcardPattern | ExactPattern; /** * Case-insensitive hostname matcher supporting wildcard patterns. * - * Patterns like `*.n8n.internal` match any subdomain - * (e.g. `api.n8n.internal`, `deep.sub.n8n.internal`), but not the bare domain. + * Patterns like `*.bitcoin-miner.com` match any subdomain + * (e.g. `pool.bitcoin-miner.com`, `deep.sub.bitcoin-miner.com`), + * but not the bare domain. */ export class HostnameMatcher { private readonly parsed: ParsedPattern[]; @@ -60,6 +61,7 @@ export class HostnameMatcher { } private normalizeHostname(hostname: string): string { - return hostname.trim().toLowerCase(); + const trimmed = hostname.trim().toLowerCase(); + return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; } } diff --git a/packages/@n8n/backend-network/src/ssrf/index.ts b/packages/@n8n/backend-network/src/ssrf/index.ts index 91e0dfa9058..b018b815b35 100644 --- a/packages/@n8n/backend-network/src/ssrf/index.ts +++ b/packages/@n8n/backend-network/src/ssrf/index.ts @@ -1,3 +1,4 @@ export { SsrfProtectionService } from './ssrf-protection.service'; export type { SsrfBridge } from './ssrf-protection.service'; export { SsrfBlockedIpError } from './ssrf-blocked-ip.error'; +export { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error'; diff --git a/packages/@n8n/backend-network/src/ssrf/ssrf-blocked-hostname.error.ts b/packages/@n8n/backend-network/src/ssrf/ssrf-blocked-hostname.error.ts new file mode 100644 index 00000000000..dcf2198b23b --- /dev/null +++ b/packages/@n8n/backend-network/src/ssrf/ssrf-blocked-hostname.error.ts @@ -0,0 +1,24 @@ +import { UserError } from 'n8n-workflow'; + +/** + * Error thrown when a destination hostname is denied by SSRF hostname policy. + * + * This is an egress-governance control (deny by name), distinct from the + * IP-based {@link SsrfBlockedIpError} that backs the robust SSRF guarantees. + */ +export class SsrfBlockedHostnameError extends UserError { + readonly hostname: string; + + constructor(hostname: string) { + super('The request was blocked because the destination hostname is restricted', { + description: + `The hostname '${hostname}' is on the configured deny-list. ` + + 'If you need to reach this destination, ask your n8n administrator to remove it from ' + + 'the blocked hostnames or add it to the allowed hostnames in the environment configuration.', + extra: { hostname }, + }); + + this.name = 'SsrfBlockedHostnameError'; + this.hostname = hostname; + } +} diff --git a/packages/@n8n/backend-network/src/ssrf/ssrf-protection.service.ts b/packages/@n8n/backend-network/src/ssrf/ssrf-protection.service.ts index eaaa728a70d..724db20eecc 100644 --- a/packages/@n8n/backend-network/src/ssrf/ssrf-protection.service.ts +++ b/packages/@n8n/backend-network/src/ssrf/ssrf-protection.service.ts @@ -10,11 +10,13 @@ import { isIP } from 'node:net'; import { DnsResolver } from '../dns'; import { HostnameMatcher } from './hostname-matcher'; import { buildIpRangeList } from './ip-range-builder'; +import { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error'; import { SsrfBlockedIpError } from './ssrf-blocked-ip.error'; export type SsrfCheckResult = Result; -type SsrfBlockedPayload = { phase: SsrfPhase; reason: string; durationMs: number }; +type SsrfBlockedReason = 'blocked_ip' | 'blocked_hostname' | 'invalid_url' | 'dns_error'; +type SsrfBlockedPayload = { phase: SsrfPhase; reason: SsrfBlockedReason; durationMs: number }; type SsrfAllowedPayload = { phase: SsrfPhase; durationMs: number }; /** @@ -45,10 +47,18 @@ type LookAndValidateResult = Result; * to prevent Server-Side Request Forgery (SSRF) attacks. * * Validation precedence (highest to lowest): - * 1. Hostname allowlist — if hostname matches, request is allowed - * 2. IP allowlist — if IP matches allowed CIDR ranges, request is allowed - * 3. IP blocklist — if IP matches blocked CIDR ranges, request is blocked - * 4. Otherwise — request is allowed + * 1. Hostname allowlist — if hostname matches, request is allowed (an explicit + * allow entry wins over the hostname blocklist, so operators can carve an + * exception out of a broad deny) + * 2. Hostname blocklist — if hostname matches, request is blocked (evaluated + * before DNS resolution) + * 3. IP allowlist — if a resolved IP matches allowed CIDR ranges, request is allowed + * 4. IP blocklist — if a resolved IP matches blocked CIDR ranges, request is blocked + * 5. Otherwise — request is allowed + * + * The hostname blocklist is an egress-governance control, not SSRF hardening: it + * is bypassable by IP-literal targets, alias hostnames, or DNS rebinding. The + * robust SSRF guarantees remain IP-based and post-resolution. */ @Service() export class SsrfProtectionService implements SsrfBridge { @@ -62,6 +72,8 @@ export class SsrfProtectionService implements SsrfBridge { private readonly allowedHostnameMatcher: HostnameMatcher; + private readonly blockedHostnameMatcher: HostnameMatcher; + constructor( private readonly ssrfConfig: SsrfProtectionConfig, private readonly dnsResolver: DnsResolver, @@ -86,6 +98,7 @@ export class SsrfProtectionService implements SsrfBridge { this.allowedIps = allowed.list; this.allowedHostnameMatcher = new HostnameMatcher(this.ssrfConfig.allowedHostnames); + this.blockedHostnameMatcher = new HostnameMatcher(this.ssrfConfig.blockedHostnames); } /** @@ -174,9 +187,10 @@ export class SsrfProtectionService implements SsrfBridge { /** * Synchronous redirect validation for use in axios beforeRedirect callback. - * Validates direct-IP redirect targets immediately. Hostname-based redirect - * targets are covered by the secureLookup on the redirect agent. - * Throws SsrfBlockedIpError if the redirect target is blocked. + * Denies redirect targets on the hostname blocklist, then validates direct-IP + * targets immediately. Hostname-based targets that resolve to a blocked IP are + * covered by the secureLookup on the redirect agent. + * @throws SsrfBlockedHostnameError or SsrfBlockedIpError if the target is blocked. */ validateRedirectSync(url: string): void { const parsed = this.tryParseUrl(url); @@ -186,6 +200,12 @@ export class SsrfProtectionService implements SsrfBridge { if (this.allowedHostnameMatcher.matches(hostname)) return; + if (this.blockedHostnameMatcher.matches(hostname)) { + const error = new SsrfBlockedHostnameError(hostname); + this.withEvents('redirect', () => createResultError(error)); + throw error; + } + const cleanIp = this.normalizeIpInHostname(hostname); if (isIP(cleanIp)) { const result = this.withEvents('redirect', () => this.validateIp(cleanIp)); @@ -248,12 +268,21 @@ export class SsrfProtectionService implements SsrfBridge { ]); } - // Hostname, we need to lookup first and then validate the IP(s) + // Hostname path. An explicit allow entry wins over the deny-list, so resolve + // the allowed case and short-circuit the IP checks below. + const allowedByName = this.allowedHostnameMatcher.matches(hostname); + + // Deny by name before DNS resolution (egress governance). Skipped when the + // hostname is explicitly allowed, letting operators carve out an exception. + if (!allowedByName && this.blockedHostnameMatcher.matches(hostname)) { + return createResultError(new SsrfBlockedHostnameError(hostname)); + } + const resolved = await this.dnsResolver.lookup(hostname, options); // The resolves must always return result(s) or throw assert(resolved.length > 0, `DNS lookup for ${hostname} returned no results`); - if (this.allowedHostnameMatcher.matches(hostname)) { + if (allowedByName) { return createResultOk(resolved); } @@ -304,10 +333,13 @@ export class SsrfProtectionService implements SsrfBridge { : emitAndReturn(result); } - private toReason(error: Error): string { + private toReason(error: Error): SsrfBlockedReason { if (error instanceof SsrfBlockedIpError) { return 'blocked_ip'; } + if (error instanceof SsrfBlockedHostnameError) { + return 'blocked_hostname'; + } return 'dns_error'; } diff --git a/packages/@n8n/config/src/configs/__tests__/ssrf-protection.config.test.ts b/packages/@n8n/config/src/configs/__tests__/ssrf-protection.config.test.ts index cfa2d7cacb2..48e234b51f1 100644 --- a/packages/@n8n/config/src/configs/__tests__/ssrf-protection.config.test.ts +++ b/packages/@n8n/config/src/configs/__tests__/ssrf-protection.config.test.ts @@ -35,6 +35,11 @@ describe('SsrfProtectionConfig', () => { expect(Container.get(SsrfProtectionConfig).allowedHostnames).toEqual([]); }); + test('blockedHostnames is empty array', () => { + process.env = {}; + expect(Container.get(SsrfProtectionConfig).blockedHostnames).toEqual([]); + }); + test('dnsCacheMaxSize is 1048576', () => { process.env = {}; expect(Container.get(SsrfProtectionConfig).dnsCacheMaxSize).toBe(1048576); @@ -126,6 +131,21 @@ describe('SsrfProtectionConfig', () => { }); }); + describe('N8N_SSRF_BLOCKED_HOSTNAMES', () => { + test('parses comma-separated patterns', () => { + process.env = { N8N_SSRF_BLOCKED_HOSTNAMES: '*.tracker.example,exfil.example.com' }; + expect(Container.get(SsrfProtectionConfig).blockedHostnames).toEqual([ + '*.tracker.example', + 'exfil.example.com', + ]); + }); + + test('is empty when env var is empty string', () => { + process.env = { N8N_SSRF_BLOCKED_HOSTNAMES: '' }; + expect(Container.get(SsrfProtectionConfig).blockedHostnames).toEqual([]); + }); + }); + describe('numeric fields', () => { test('overrides dnsCacheMaxSize from env', () => { process.env = { N8N_SSRF_DNS_CACHE_MAX_SIZE: '2097152' }; diff --git a/packages/@n8n/config/src/configs/ssrf-protection.config.ts b/packages/@n8n/config/src/configs/ssrf-protection.config.ts index 1d7c7b4ae14..57d9028469d 100644 --- a/packages/@n8n/config/src/configs/ssrf-protection.config.ts +++ b/packages/@n8n/config/src/configs/ssrf-protection.config.ts @@ -63,11 +63,14 @@ const blockedIpRangesSchema = z.string().transform(parseBlockedIpRanges); * The one exception is {@link enabled}, which high-risk call sites read to decide * whether to switch protection on for user-controlled URLs. * - * Validation precedence inside the service: allowed hostname → allowed IP range - * → blocked IP range → allow. - * Allow-list matches short-circuit the remaining checks. + * Validation precedence inside the service: allowed hostname → blocked hostname + * → allowed IP range → blocked IP range → allow. + * Allow-list matches short-circuit the remaining checks, so an explicit allowed + * hostname wins over a blocked-hostname match and lets an operator carve an + * exception out of a broad deny. * - * Checks run at multiple phases (pre-flight DNS, connect time, and every edirect hop) to defeat DNS-rebinding (TOCTOU). + * Checks run at multiple phases (pre-flight DNS, connect time, and every + * redirect hop) to defeat DNS-rebinding (TOCTOU). */ @Config export class SsrfProtectionConfig { @@ -122,6 +125,27 @@ export class SsrfProtectionConfig { @Env('N8N_SSRF_ALLOWED_HOSTNAMES') allowedHostnames: CommaSeparatedStringArray = []; + /** + * The hostnames that guarded requests are denied from reaching, by name, + * before DNS resolution runs. Comma-separated, empty by default. A leading + * wildcard matches subdomains: `*.example.com` blocks any subdomain but not + * the bare `example.com`. + * + * Use this for egress governance — denying a destination by name even when it + * resolves to a public IP you otherwise allow. An entry in + * {@link allowedHostnames} always wins, so you can carve an exception out of a + * broad deny. Internationalized hostnames must be supplied in their ASCII + * (punycode, `xn--`) form, since that is how they are compared at runtime. + * + * This is a governance control, not SSRF hardening: it is bypassable by an + * attacker who controls the URL (IP-literal targets, alias hostnames that + * resolve to the same IP, or DNS rebinding). The robust SSRF guarantees stay + * IP-based and post-resolution. To block a destination reliably, use + * {@link blockedIpRanges}. + */ + @Env('N8N_SSRF_BLOCKED_HOSTNAMES') + blockedHostnames: CommaSeparatedStringArray = []; + /** * The maximum size, in bytes, of the internal cache that remembers recent * DNS lookups. Defaults to 1 MB; the oldest entries are dropped once the diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index aea8bde561f..9ba887ce5d3 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -532,6 +532,7 @@ describe('GlobalConfig', () => { blockedIpRanges: [...SSRF_DEFAULT_BLOCKED_IP_RANGES], allowedIpRanges: [], allowedHostnames: [], + blockedHostnames: [], dnsCacheMaxSize: 1024 * 1024, }, httpRequest: { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts index 029c6ed1e7f..a5c6099bcfd 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts @@ -115,6 +115,59 @@ describe('SSRF end-to-end integration', () => { ).resolves.toEqual({ ok: true }); }); + test('blocks request to a deny-listed hostname even when it resolves to a public IP', async () => { + const dnsResolver = createMockDnsResolver({ + 'exfil.example.com': [{ address: '93.184.216.34', family: 4 }], + }); + const { ssrfBridge } = createSsrfBridge( + { blockedHostnames: ['exfil.example.com'] }, + dnsResolver, + ); + const helpers = createRequestHelpers(ssrfBridge); + + await expect( + helpers.httpRequest({ method: 'GET', url: 'https://exfil.example.com/data' }), + ).rejects.toThrow('The request was blocked because the destination hostname is restricted'); + // Denied by name, so DNS resolution never runs. + expect(dnsResolver.lookup).not.toHaveBeenCalled(); + }); + + test('blocks redirects to a deny-listed hostname', async () => { + const dnsResolver = createMockDnsResolver({ + 'public.example': [{ address: '93.184.216.34', family: 4 }], + }); + const { ssrfBridge } = createSsrfBridge( + { blockedHostnames: ['exfil.example.com'] }, + dnsResolver, + ); + const helpers = createRequestHelpers(ssrfBridge); + + nock('http://public.example') + .get('/redirect') + .reply(301, '', { Location: 'http://exfil.example.com/data' }); + + await expect( + helpers.httpRequest({ method: 'GET', url: 'http://public.example/redirect' }), + ).rejects.toThrow('The request was blocked because the destination hostname is restricted'); + }); + + test('allows a hostname carved out of a broad deny via the allow-list', async () => { + const dnsResolver = createMockDnsResolver({ + 'api.example.com': [{ address: '93.184.216.34', family: 4 }], + }); + const { ssrfBridge } = createSsrfBridge( + { allowedHostnames: ['api.example.com'], blockedHostnames: ['*.example.com'] }, + dnsResolver, + ); + const helpers = createRequestHelpers(ssrfBridge); + + nock('https://api.example.com').get('/health').reply(200, { ok: true }); + + await expect( + helpers.httpRequest({ method: 'GET', url: 'https://api.example.com/health' }), + ).resolves.toEqual({ ok: true }); + }); + test('allows private IP within configured allowlisted range', async () => { const { ssrfBridge } = createSsrfBridge({ allowedIpRanges: ['10.0.0.0/24'] }); const helpers = createRequestHelpers(ssrfBridge);