fix: Implement SSRF protection in webhook handling with safeFetch method

This commit is contained in:
Dries Augustyns
2026-04-02 07:48:43 +02:00
parent a8014cf7cf
commit 2c5a71518d
@@ -10,6 +10,8 @@ import type {
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
import dns from 'node:dns/promises';
import net from 'node:net';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -822,6 +824,89 @@ export class WorkflowExecutionService {
};
}
/**
* Validates that an IP address is not in a private/reserved range to prevent SSRF.
* Blocks loopback, private, link-local, and cloud metadata ranges.
*/
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(addr)) {
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
);
}
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
);
}
// Unknown format — reject to be safe
return true;
}
/**
* SSRF-safe fetch. Resolves the hostname, validates the IP is not internal,
* and manually follows redirects re-validating each hop.
*/
private static async safeFetch(url: string, options: RequestInit): Promise<Response> {
const MAX_REDIRECTS = 5;
let currentUrl = url;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
const parsed = new URL(currentUrl);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`Webhook URL scheme not allowed: ${parsed.protocol}`);
}
const {address} = await dns.lookup(parsed.hostname);
if (WorkflowExecutionService.isPrivateIp(address)) {
throw new Error(`Webhook URL resolves to a private/internal IP address: ${address}`);
}
const response = await fetch(currentUrl, {
...options,
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
throw new Error('Redirect with no Location header');
}
// Resolve relative redirects against the current URL
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return response;
}
throw new Error('Too many redirects');
}
/**
* WEBHOOK step - Call an external webhook
*/
@@ -859,7 +944,7 @@ export class WorkflowExecutionService {
};
// Make HTTP request
const response = await fetch(url, {
const response = await WorkflowExecutionService.safeFetch(url, {
method,
headers: {
'Content-Type': 'application/json',