feat: Add configurable blocked hostnames for outbound requests (#32977)

This commit is contained in:
Lorent Lempereur
2026-06-25 10:11:02 +02:00
committed by GitHub
parent 37bc8230d8
commit b4c4c168f7
11 changed files with 367 additions and 24 deletions
+5 -6
View File
@@ -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
@@ -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);
@@ -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<Error | null>((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 () => {
@@ -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;
}
}
@@ -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';
@@ -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;
}
}
@@ -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<void, Error>;
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<LookupAddress[], Error>;
* 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';
}
@@ -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' };
@@ -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<string> = [];
/**
* 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<string> = [];
/**
* 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
+1
View File
@@ -532,6 +532,7 @@ describe('GlobalConfig', () => {
blockedIpRanges: [...SSRF_DEFAULT_BLOCKED_IP_RANGES],
allowedIpRanges: [],
allowedHostnames: [],
blockedHostnames: [],
dnsCacheMaxSize: 1024 * 1024,
},
httpRequest: {
@@ -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);