SSRF

SSRF in Node.js (Express) remediation [Updated June 2026] [CVE-2026-33975]

[Updated June 2026] Updated CVE-2026-33975

Overview

CVE-2026-33975 describes an SSRF vulnerability in Twenty's SecureHttpClientService (Node.js-based). In versions 1.18.0 and earlier, an attacker could reach internal IPs, including cloud metadata endpoints, by abusing IPv4-mapped IPv6 addresses in URL literals. Node.js's URL parser normalizes IPv4-mapped IPv6 addresses to a compressed hex form (e.g., ::ffff:169.254.169.254 becomes ::ffff:a9fe:a9fe), but the isPrivateIp utility used in this path only recognizes dotted-decimal notation. As a result, the check fails to classify these addresses as private, allowing requests to internal resources. In addition, the socket lookup validation event does not fire for IP literal addresses, bypassing the second validation layer. An authenticated user could exfiltrate credentials such as IAM keys by contacting internal IPs. This class of vulnerability aligns with CWE-918 (Access via SSRF). In a typical Node.js/Express service, similar patterns can surface when code fetches user-supplied URLs and relies on narrow IP checks rather than robust, cross-family validation. If not addressed, attackers can pivot to internal resources, impacting cloud metadata endpoints, internal services, and secrets management tooling.

Affected Versions

Twenty: 1.18.0 and earlier

Code Fix Example

Node.js (Express) API Security Remediation
const express = require('express');
const dns = require('dns').promises;
const http = require('http');
const https = require('https');
const net = require('net');

// Vulnerable: naive private IP check (IPv4 only)
function isPrivateIpV4Only(ip) {
  return /^(10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.)/.test(ip);
}

async function fetchVulnerable(target) {
  const url = new URL(target);
  const hosts = await dns.lookup(url.hostname, { all: true });
  const address = hosts[0].address; // IPv4 or IPv6 (including IPv4-mapped IPv6)
  if (isPrivateIpV4Only(address)) {
    throw new Error('Private IP address disallowed');
  }
  const mod = url.protocol === 'https:' ? https : http;
  return new Promise((resolve, reject) => {
    mod.get(url.toString(), (res) => resolve(res)).on('error', reject);
  });
}

// Fixed: robust cross-family IP validation
function isPublicIp(ip) {
  const kind = net.isIP(ip);
  if (kind === 4) {
    const parts = ip.split('.').map(n => Number(n));
    if (parts[0] === 10) return false;
    if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
    if (parts[0] === 192 && parts[1] === 168) return false;
    return true;
  }
  if (kind === 6) {
    const p = ip.toLowerCase();
    // IPv4-mapped IPv6
    if (p.includes('::ffff:')) {
      const v4 = p.split('::ffff:')[1];
      return isPublicIp(v4);
    }
    // Link-local or private ranges
    if (p.startsWith('fe80') || p.startsWith('fe80:')) return false;
    if (p.startsWith('fc') || p.startsWith('fd')) return false;
    return true;
  }
  return false;
}

async function fetchSafe(target) {
  const url = new URL(target);
  const hosts = await dns.lookup(url.hostname, { all: true });
  const address = hosts[0].address;
  if (!isPublicIp(address)) {
    throw new Error('Outbound to non-public IP disallowed');
  }
  const mod = url.protocol === 'https:' ? https : http;
  return new Promise((resolve, reject) => {
    mod.get(url.toString(), (res) => resolve(res)).on('error', reject);
  });
}

module.exports = { fetchVulnerable, fetchSafe };

CVE References

Choose which optional cookies to allow. You can change this any time.