Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 130 additions & 25 deletions apps/api/src/services/WorkflowExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,39 +824,137 @@ export class WorkflowExecutionService {
};
}

/**
* True if a dotted-quad IPv4 address is in a private/reserved range.
*/
private static isPrivateIpv4(addr: string): boolean {
const parts = addr.split('.').map(Number);
const a = parts[0] ?? -1;
const b = parts[1] ?? -1;

return (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) || // 169.254.0.0/16 link-local / cloud metadata
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space
a === 0 || // 0.0.0.0/8
a >= 224 // 224.0.0.0+ multicast and reserved
);
}

/**
* Expands an IPv6 address into its 8 hextets (16-bit numbers).
*
* Handles `::` compression, an optional zone id, and a trailing dotted-quad
* (e.g. `::ffff:169.254.169.254`). Returns null if the address is malformed —
* callers must treat that as untrusted.
*/
private static expandIpv6(addr: string): number[] | null {
// Drop the zone id (fe80::1%eth0) — it is not part of the address
const bare = addr.split('%')[0]?.toLowerCase() ?? '';

// A trailing dotted-quad occupies the final two hextets
let head = bare;
const dotted = bare.lastIndexOf(':');
const tail = bare.slice(dotted + 1);
let embedded: number[] = [];
if (tail.includes('.')) {
if (!net.isIPv4(tail)) {
return null;
}
const [a = 0, b = 0, c = 0, d = 0] = tail.split('.').map(Number);
embedded = [(a << 8) | b, (c << 8) | d];
head = bare.slice(0, dotted + 1);
// `::1.2.3.4` leaves a trailing `::`, `1::1.2.3.4` a trailing `:`
head = head.endsWith('::') ? head : head.slice(0, -1);
}

const halves = head.split('::');
if (halves.length > 2) {
return null;
}

const toHextets = (part: string) =>
part === '' ? [] : part.split(':').map((h) => (/^[0-9a-f]{1,4}$/.test(h) ? Number.parseInt(h, 16) : Number.NaN));

const left = toHextets(halves[0] ?? '');
const right = halves.length === 2 ? toHextets(halves[1] ?? '') : [];

const fixed = [...left, ...right, ...embedded];
if (fixed.some(Number.isNaN)) {
return null;
}

if (halves.length === 1) {
return fixed.length === 8 ? fixed : null;
}

const gap = 8 - fixed.length;
if (gap < 1) {
return null;
}

return [...left, ...new Array<number>(gap).fill(0), ...right, ...embedded];
}

/**
* Validates that an IP address is not in a private/reserved range to prevent SSRF.
* Blocks loopback, private, link-local, and cloud metadata ranges.
*
* IPv6 is range-checked on the parsed hextets rather than by string prefix, so
* alternate spellings of the same address cannot slip through. Transition
* mechanisms that embed an IPv4 address (IPv4-mapped, IPv4-compatible, 6to4,
* NAT64, Teredo) are unwrapped and the embedded IPv4 re-validated — otherwise
* e.g. `2002:a9fe:a9fe::` would reach 169.254.169.254 unblocked.
*/
private static isPrivateIp(ip: string): boolean {
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:192.168.1.1)
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
if (net.isIPv4(ip)) {
return WorkflowExecutionService.isPrivateIpv4(ip);
}

if (net.isIPv4(addr)) {
const parts = addr.split('.').map(Number);
const a = parts[0] ?? -1;
const b = parts[1] ?? -1;
if (net.isIPv6(ip)) {
const h = WorkflowExecutionService.expandIpv6(ip);
if (!h) {
return true;
}

return (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) || // 169.254.0.0/16 link-local / cloud metadata
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space
a === 0 || // 0.0.0.0/8
a >= 224 // 224.0.0.0+ multicast and reserved
);
}
const [h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0, h7 = 0] = h;
const v4 = (hi: number, lo: number) => `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
const topZero = h0 === 0 && h1 === 0 && h2 === 0 && h3 === 0 && h4 === 0;

// ::/128 unspecified and ::1/128 loopback
if (topZero && h5 === 0 && h6 === 0 && (h7 === 0 || h7 === 1)) {
return true;
}

// ::ffff:0:0/96 IPv4-mapped and ::/96 IPv4-compatible (deprecated)
if (topZero && (h5 === 0xffff || h5 === 0)) {
return WorkflowExecutionService.isPrivateIpv4(v4(h6, h7));
}

// 64:ff9b::/96 and 64:ff9b:1::/48 NAT64 (RFC 6052, RFC 8215)
if (h0 === 0x0064 && h1 === 0xff9b) {
return h2 === 0x0001 ? true : WorkflowExecutionService.isPrivateIpv4(v4(h6, h7));
}

// 2002::/16 6to4 (RFC 3056) — the embedded IPv4 is the tunnel endpoint
if (h0 === 0x2002) {
return WorkflowExecutionService.isPrivateIpv4(v4(h1, h2));
}

// 2001:0000::/32 Teredo (RFC 4380) — obfuscated endpoints, block outright
if (h0 === 0x2001 && h1 === 0x0000) {
return true;
}

if (net.isIPv6(addr)) {
const normalized = addr.toLowerCase();
return (
normalized === '::1' || // loopback
normalized.startsWith('fe80:') || // link-local
normalized.startsWith('fc') || // unique local
normalized.startsWith('fd') || // unique local
normalized.startsWith('ff') // multicast
(h0 & 0xffc0) === 0xfe80 || // fe80::/10 link-local
(h0 & 0xfe00) === 0xfc00 || // fc00::/7 unique local
(h0 & 0xff00) === 0xff00 || // ff00::/8 multicast
(h0 === 0x0100 && h1 === 0 && h2 === 0 && h3 === 0) || // 100::/64 discard-only
(h0 === 0x2001 && h1 === 0x0db8) // 2001:db8::/32 documentation
);
}

Expand All @@ -879,7 +977,14 @@ export class WorkflowExecutionService {
throw new Error(`Webhook URL scheme not allowed: ${parsed.protocol}`);
}

const {address} = await dns.lookup(parsed.hostname);
// WHATWG URL keeps the brackets on IPv6 literals (http://[::1]/)
const hostname =
parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname;

// An IP literal needs no resolution — validate it directly
const address = net.isIP(hostname) ? hostname : (await dns.lookup(hostname)).address;

if (WorkflowExecutionService.isPrivateIp(address)) {
throw new Error(`Webhook URL resolves to a private/internal IP address: ${address}`);
Expand Down
114 changes: 114 additions & 0 deletions apps/api/src/services/__tests__/WorkflowExecutionService.ssrf.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {describe, expect, it} from 'vitest';

import {WorkflowExecutionService} from '../WorkflowExecutionService.js';

/**
* `isPrivateIp` is private; reach it through an `as any` cast so the SSRF guard
* can be tested directly without exercising a full workflow execution.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isPrivateIp = (ip: string): boolean => (WorkflowExecutionService as any).isPrivateIp(ip);

describe('WorkflowExecutionService SSRF guard', () => {
describe('IPv4', () => {
it.each([
'127.0.0.1',
'10.1.2.3',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'169.254.169.254',
'100.64.0.1',
'0.0.0.0',
'224.0.0.1',
'255.255.255.255',
])('blocks %s', (ip) => {
expect(isPrivateIp(ip)).toBe(true);
});

it.each(['8.8.8.8', '1.1.1.1', '172.15.0.1', '172.32.0.1', '100.63.0.1', '93.184.216.34'])(
'allows %s',
(ip) => {
expect(isPrivateIp(ip)).toBe(false);
},
);
});

describe('IPv6', () => {
it.each([
['loopback', '::1'],
['loopback, uncompressed', '0:0:0:0:0:0:0:1'],
['unspecified', '::'],
['link-local', 'fe80::1'],
['link-local with zone', 'fe80::1%eth0'],
['link-local, upper bound of /10', 'febf::1'],
['unique local', 'fd00::1'],
['unique local, fc00::/7', 'fc00::1'],
['multicast', 'ff02::1'],
['discard-only', '100::1'],
['documentation', '2001:db8::1'],
])('blocks %s (%s)', (_label, ip) => {
expect(isPrivateIp(ip)).toBe(true);
});

it.each([
['public', '2606:4700:4700::1111'],
['public Google DNS', '2001:4860:4860::8888'],
['not link-local — fec0 is outside fe80::/10', 'fec0::1'],
])('allows %s (%s)', (_label, ip) => {
expect(isPrivateIp(ip)).toBe(false);
});
});

describe('IPv4-mapped and IPv4-compatible', () => {
it.each([
'::ffff:169.254.169.254',
'::ffff:127.0.0.1',
'::ffff:10.0.0.1',
// Uncompressed spelling of the same address
'0:0:0:0:0:ffff:169.254.169.254',
// Hex spelling: a9fe:a9fe == 169.254.169.254
'::ffff:a9fe:a9fe',
// Deprecated IPv4-compatible form
'::169.254.169.254',
])('blocks %s', (ip) => {
expect(isPrivateIp(ip)).toBe(true);
});

it('allows a mapped public address', () => {
expect(isPrivateIp('::ffff:8.8.8.8')).toBe(false);
});
});

describe('transition mechanisms (the reported bypass)', () => {
it.each([
['6to4 → 169.254.169.254', '2002:a9fe:a9fe::'],
['6to4 → 127.0.0.1', '2002:7f00:1::1'],
['6to4 → 10.0.0.1', '2002:a00:1::'],
['NAT64 well-known prefix → 169.254.169.254', '64:ff9b::a9fe:a9fe'],
['NAT64 → 127.0.0.1', '64:ff9b::7f00:1'],
['NAT64 local-use prefix (RFC 8215)', '64:ff9b:1::1'],
['Teredo', '2001:0000:4136:e378:8000:63bf:5600:fedd'],
['Teredo, compressed', '2001::1'],
])('blocks %s (%s)', (_label, ip) => {
expect(isPrivateIp(ip)).toBe(true);
});

it('allows 6to4 wrapping a public IPv4 (8.8.8.8)', () => {
expect(isPrivateIp('2002:808:808::')).toBe(false);
});

it('allows NAT64 wrapping a public IPv4 (8.8.8.8)', () => {
expect(isPrivateIp('64:ff9b::808:808')).toBe(false);
});
});

describe('malformed input', () => {
it.each(['', 'not-an-ip', 'example.com', '999.999.999.999', '::gggg', '1:2:3', '0x7f.0.0.1'])(
'rejects %s',
(ip) => {
expect(isPrivateIp(ip)).toBe(true);
},
);
});
});
Loading