Skip to content

Commit 51fd8c4

Browse files
author
Bill
committed
fix(security): SSRF vulnerability in webhook URL validation
The previous URL validation regex `/^https?:\/\/.+/` was too permissive and allowed requests to internal/private network addresses, including: - localhost - 127.0.0.1 and other loopback addresses - 169.254.169.254 (AWS metadata endpoint) - Private IP ranges (10.x, 172.16-31.x, 192.168.x) Changes: - Add src/api/utils/ssrfProtection.js with comprehensive URL validation - Block private/internal IP ranges, link-local addresses, and localhost variants - Add DNS resolution check to prevent DNS rebinding attacks (async version) - Update notifications.js route to use SSRF-safe validation - Add extensive tests for blocked and allowed URLs Fixes security vulnerability in webhook notification feature (PR #219).
1 parent 1fe44d7 commit 51fd8c4

4 files changed

Lines changed: 619 additions & 2 deletions

File tree

src/api/routes/notifications.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Router } from 'express';
1111
import { info, error as logError } from '../../logger.js';
1212
import { getConfig, setConfigValue } from '../../modules/config.js';
1313
import { getDeliveryLog, testEndpoint, WEBHOOK_EVENTS } from '../../modules/webhookNotifier.js';
14+
import { validateUrlForSsrfSync } from '../utils/ssrfProtection.js';
1415

1516
const router = Router();
1617

@@ -120,8 +121,10 @@ router.post('/:guildId/notifications/webhooks', async (req, res) => {
120121
return res.status(400).json({ error: 'Missing or invalid "url"' });
121122
}
122123

123-
if (!/^https:\/\/.+/.test(url)) {
124-
return res.status(400).json({ error: '"url" must be a valid HTTPS URL' });
124+
// SSRF-safe URL validation - require HTTPS to prevent DNS rebinding attacks
125+
const urlValidation = validateUrlForSsrfSync(url);
126+
if (!urlValidation.valid) {
127+
return res.status(400).json({ error: urlValidation.error });
125128
}
126129

127130
if (!Array.isArray(events) || events.length === 0) {

src/api/utils/ssrfProtection.js

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* SSRF Protection Utilities
3+
*
4+
* Validates URLs to prevent Server-Side Request Forgery attacks by blocking
5+
* requests to internal/private network addresses.
6+
*/
7+
8+
/**
9+
* Check if a hostname resolves to a blocked IP address.
10+
* This handles DNS rebinding attacks by checking the resolved IP.
11+
*
12+
* @param {string} hostname - The hostname to check
13+
* @returns {Promise<string|null>} The blocked IP if found, null if safe
14+
*/
15+
async function resolveAndCheckIp(hostname) {
16+
// Only perform DNS resolution in Node.js runtime
17+
if (typeof process === 'undefined') return null;
18+
19+
const dns = await import('node:dns').catch(() => null);
20+
if (!dns) return null;
21+
22+
return new Promise((resolve) => {
23+
dns.lookup(hostname, { all: true }, (err, addresses) => {
24+
if (err || !addresses) {
25+
resolve(null);
26+
return;
27+
}
28+
29+
for (const addr of addresses) {
30+
if (isBlockedIp(addr.address)) {
31+
resolve(addr.address);
32+
return;
33+
}
34+
}
35+
resolve(null);
36+
});
37+
});
38+
}
39+
40+
/**
41+
* Check if an IP address is in a blocked range.
42+
* Blocks:
43+
* - Loopback (127.0.0.0/8)
44+
* - Link-local (169.254.0.0/16) - includes AWS metadata at 169.254.169.254
45+
* - Private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
46+
* - Localhost IPv6 (::1)
47+
* - IPv6 link-local (fe80::/10)
48+
*
49+
* @param {string} ip - The IP address to check
50+
* @returns {boolean} True if the IP is blocked
51+
*/
52+
export function isBlockedIp(ip) {
53+
// Normalize IPv6 addresses
54+
const normalizedIp = ip.toLowerCase().trim();
55+
56+
// IPv6 loopback
57+
if (normalizedIp === '::1' || normalizedIp === '0:0:0:0:0:0:0:1') {
58+
return true;
59+
}
60+
61+
// IPv6 link-local (fe80::/10)
62+
if (normalizedIp.startsWith('fe80:')) {
63+
return true;
64+
}
65+
66+
// IPv4-mapped IPv6 addresses (::ffff:192.168.1.1)
67+
const ipv4MappedMatch = normalizedIp.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
68+
if (ipv4MappedMatch) {
69+
return isBlockedIp(ipv4MappedMatch[1]);
70+
}
71+
72+
// IPv4 checks
73+
const parts = normalizedIp.split('.');
74+
if (parts.length !== 4) {
75+
// Not a valid IPv4, let it pass (will fail elsewhere)
76+
return false;
77+
}
78+
79+
const octets = parts.map((p) => {
80+
const num = parseInt(p, 10);
81+
return Number.isNaN(num) ? -1 : num;
82+
});
83+
84+
// Invalid octets
85+
if (octets.some((o) => o < 0 || o > 255)) {
86+
return false;
87+
}
88+
89+
const [first, second] = octets;
90+
91+
// Loopback: 127.0.0.0/8
92+
if (first === 127) {
93+
return true;
94+
}
95+
96+
// Link-local: 169.254.0.0/16 (includes AWS metadata endpoint)
97+
if (first === 169 && second === 254) {
98+
return true;
99+
}
100+
101+
// Private: 10.0.0.0/8
102+
if (first === 10) {
103+
return true;
104+
}
105+
106+
// Private: 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
107+
if (first === 172 && second >= 16 && second <= 31) {
108+
return true;
109+
}
110+
111+
// Private: 192.168.0.0/16
112+
if (first === 192 && second === 168) {
113+
return true;
114+
}
115+
116+
// 0.0.0.0/8 - "this network"
117+
if (first === 0) {
118+
return true;
119+
}
120+
121+
return false;
122+
}
123+
124+
/**
125+
* Check if a hostname is a blocked literal (like "localhost")
126+
*
127+
* @param {string} hostname - The hostname to check
128+
* @returns {boolean} True if the hostname is blocked
129+
*/
130+
function isBlockedHostname(hostname) {
131+
const normalized = hostname.toLowerCase().trim();
132+
133+
// Block localhost variants
134+
const blockedHostnames = [
135+
'localhost',
136+
'localhost.localdomain',
137+
'ip6-localhost',
138+
'ip6-loopback',
139+
'ip6-localnet',
140+
'ip6-mcastprefix',
141+
];
142+
143+
if (blockedHostnames.includes(normalized)) {
144+
return true;
145+
}
146+
147+
// Block hostnames that end with .local, .localhost, .internal, .localdomain
148+
const blockedSuffixes = ['.local', '.localhost', '.internal', '.localdomain', '.home', '.home.arpa'];
149+
if (blockedSuffixes.some((suffix) => normalized.endsWith(suffix))) {
150+
return true;
151+
}
152+
153+
// Block if the hostname is a raw IP address that's blocked
154+
// IPv4 check
155+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized)) {
156+
return isBlockedIp(normalized);
157+
}
158+
159+
// IPv6 check (basic patterns)
160+
if (normalized.includes(':') && (normalized.startsWith('::1') || normalized.startsWith('fe80:'))) {
161+
return true;
162+
}
163+
164+
return false;
165+
}
166+
167+
/**
168+
* Validation result for SSRF-safe URL check
169+
*
170+
* @typedef {Object} UrlValidationResult
171+
* @property {boolean} valid - Whether the URL is safe to use
172+
* @property {string} [error] - Error message if invalid
173+
* @property {string} [blockedIp] - The blocked IP address if found during DNS resolution
174+
*/
175+
176+
/**
177+
* Validate a URL for SSRF safety.
178+
* Checks both the hostname literal and performs DNS resolution to prevent
179+
* DNS rebinding attacks.
180+
*
181+
* @param {string} urlString - The URL to validate
182+
* @param {Object} [options] - Validation options
183+
* @param {boolean} [options.allowHttp=false] - Allow HTTP (not just HTTPS)
184+
* @param {boolean} [options.checkDns=true] - Perform DNS resolution check
185+
* @returns {Promise<UrlValidationResult>} Validation result
186+
*/
187+
export async function validateUrlForSsrf(urlString, options = {}) {
188+
const { allowHttp = false, checkDns = true } = options;
189+
190+
// Basic URL parsing
191+
let url;
192+
try {
193+
url = new URL(urlString);
194+
} catch {
195+
return { valid: false, error: 'Invalid URL format' };
196+
}
197+
198+
// Protocol check
199+
const allowedProtocols = allowHttp ? ['https:', 'http:'] : ['https:'];
200+
if (!allowedProtocols.includes(url.protocol)) {
201+
return {
202+
valid: false,
203+
error: allowHttp
204+
? 'URL must use HTTP or HTTPS protocol'
205+
: 'URL must use HTTPS protocol',
206+
};
207+
}
208+
209+
const hostname = url.hostname;
210+
211+
// Check for blocked hostnames (localhost, etc.)
212+
if (isBlockedHostname(hostname)) {
213+
return {
214+
valid: false,
215+
error: 'URL hostname is not allowed (private/internal addresses are blocked)',
216+
};
217+
}
218+
219+
// Check if hostname is already an IP and if it's blocked
220+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
221+
if (isBlockedIp(hostname)) {
222+
return {
223+
valid: false,
224+
error: 'URL resolves to a blocked IP address (private/internal ranges are not allowed)',
225+
};
226+
}
227+
} else if (checkDns) {
228+
// Perform DNS resolution to prevent DNS rebinding
229+
const blockedIp = await resolveAndCheckIp(hostname);
230+
if (blockedIp) {
231+
return {
232+
valid: false,
233+
error: `URL hostname resolves to blocked IP address ${blockedIp} (private/internal ranges are not allowed)`,
234+
blockedIp,
235+
};
236+
}
237+
}
238+
239+
return { valid: true };
240+
}
241+
242+
/**
243+
* Synchronous version of SSRF validation for cases where DNS resolution
244+
* is not possible or desired. Use the async version when possible.
245+
*
246+
* @param {string} urlString - The URL to validate
247+
* @param {Object} [options] - Validation options
248+
* @param {boolean} [options.allowHttp=false] - Allow HTTP (not just HTTPS)
249+
* @returns {UrlValidationResult} Validation result
250+
*/
251+
export function validateUrlForSsrfSync(urlString, options = {}) {
252+
const { allowHttp = false } = options;
253+
254+
// Basic URL parsing
255+
let url;
256+
try {
257+
url = new URL(urlString);
258+
} catch {
259+
return { valid: false, error: 'Invalid URL format' };
260+
}
261+
262+
// Protocol check
263+
const allowedProtocols = allowHttp ? ['https:', 'http:'] : ['https:'];
264+
if (!allowedProtocols.includes(url.protocol)) {
265+
return {
266+
valid: false,
267+
error: allowHttp
268+
? 'URL must use HTTP or HTTPS protocol'
269+
: 'URL must use HTTPS protocol',
270+
};
271+
}
272+
273+
const hostname = url.hostname;
274+
275+
// Check for blocked hostnames
276+
if (isBlockedHostname(hostname)) {
277+
return {
278+
valid: false,
279+
error: 'URL hostname is not allowed (private/internal addresses are blocked)',
280+
};
281+
}
282+
283+
// Check if hostname is a raw IP and if it's blocked
284+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
285+
if (isBlockedIp(hostname)) {
286+
return {
287+
valid: false,
288+
error: 'URL points to a blocked IP address (private/internal ranges are not allowed)',
289+
};
290+
}
291+
}
292+
293+
return { valid: true };
294+
}
295+
296+
export default {
297+
validateUrlForSsrf,
298+
validateUrlForSsrfSync,
299+
isBlockedIp,
300+
};

tests/api/routes/notifications.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,52 @@ describe('notifications routes', () => {
252252

253253
expect(res.status).toBe(401);
254254
});
255+
256+
// ── SSRF Protection ─────────────────────────────────────────────────────
257+
258+
const blockedUrls = [
259+
{ url: 'https://localhost/webhook', desc: 'localhost' },
260+
{ url: 'https://localhost:8080/webhook', desc: 'localhost with port' },
261+
{ url: 'https://127.0.0.1/webhook', desc: '127.0.0.1 loopback' },
262+
{ url: 'https://127.0.0.1:3000/webhook', desc: '127.0.0.1 with port' },
263+
{ url: 'https://169.254.169.254/latest/meta-data/', desc: 'AWS metadata endpoint' },
264+
{ url: 'https://10.0.0.1/webhook', desc: '10.x private range' },
265+
{ url: 'https://10.255.255.255/webhook', desc: '10.x upper bound' },
266+
{ url: 'https://172.16.0.1/webhook', desc: '172.16.x private range' },
267+
{ url: 'https://172.31.255.255/webhook', desc: '172.31.x private range upper' },
268+
{ url: 'https://192.168.0.1/webhook', desc: '192.168.x private range' },
269+
{ url: 'https://192.168.255.255/webhook', desc: '192.168.x upper bound' },
270+
{ url: 'https://0.0.0.0/webhook', desc: '0.0.0.0 this-network' },
271+
{ url: 'https://myserver.local/webhook', desc: 'local domain' },
272+
{ url: 'https://api.internal/webhook', desc: 'internal domain' },
273+
{ url: 'https://app.localhost/webhook', desc: 'localhost domain' },
274+
];
275+
276+
it.each(blockedUrls)('should reject $desc', async ({ url }) => {
277+
const res = await request(app)
278+
.post(`/api/v1/guilds/${GUILD_ID}/notifications/webhooks`)
279+
.set(authHeaders())
280+
.send({ url, events: ['bot.error'] });
281+
282+
expect(res.status).toBe(400);
283+
expect(res.body.error).toMatch(/not allowed|blocked|private|internal/i);
284+
});
285+
286+
const allowedUrls = [
287+
{ url: 'https://example.com/webhook', desc: 'public domain' },
288+
{ url: 'https://api.example.com/v1/webhook', desc: 'public domain with path' },
289+
{ url: 'https://example.com:8443/webhook', desc: 'public domain with port' },
290+
{ url: 'https://example.com/webhook?token=abc', desc: 'public domain with query' },
291+
];
292+
293+
it.each(allowedUrls)('should accept $desc', async ({ url }) => {
294+
const res = await request(app)
295+
.post(`/api/v1/guilds/${GUILD_ID}/notifications/webhooks`)
296+
.set(authHeaders())
297+
.send({ url, events: ['bot.error'] });
298+
299+
expect(res.status).toBe(201);
300+
});
255301
});
256302

257303
// ── DELETE /guilds/:guildId/notifications/webhooks/:endpointId ────────────

0 commit comments

Comments
 (0)