Thursday, 26 March 2026

SSRF Protection Done Right: DNS, IP Normalization, and Cloud Metadata Defense

 Modern backend systems increasingly act as network intermediaries. Whether it’s fetching webhooks, generating link previews, integrating with third-party APIs, or enabling AI-driven tools, applications today routinely accept user-provided URLs and make outbound requests on their behalf. This pattern is powerful—but it quietly introduces a critical security risk that is often overlooked during design and implementation.

 

That risk is Server-Side Request Forgery (SSRF). Unlike typical attacks where an attacker directly targets a system, SSRF works by abusing your server as a proxy. By supplying a carefully crafted URL, an attacker can trick your application into making requests to unintended destinations—internal services, private networks, or sensitive infrastructure endpoints that are otherwise inaccessible from the outside.

 

What makes SSRF particularly dangerous is how subtle it can be. A seemingly harmless line of code that fetches a URL can expose access to internal APIs, databases, or even cloud infrastructure metadata. In cloud environments, this risk is amplified significantly. Many cloud providers expose instance metadata endpoints that return credentials and configuration details. If an attacker can reach these endpoints through your application, it can lead to credential leakage and full system compromise.

 

Despite the severity of the threat, many implementations rely on simplistic defenses—string matching, basic hostname checks, or blocklists. These approaches fail in real-world scenarios due to techniques like DNS rebinding, redirect chaining, and IP format manipulation (such as IPv6-mapped IPv4 addresses). The core issue is that SSRF is not a string validation problem; it is fundamentally a network trust boundary problem.

 

The rise of AI frameworks like LangChain and LangChain4j has further expanded the attack surface. Applications now allow large language models to generate URLs and trigger network calls dynamically. This introduces a new dimension where untrusted inputs are not just user-provided but also AI-generated, making robust SSRF protection even more essential.

 

To address this, SSRF protection must go beyond basic validation and adopt a defense-in-depth approach. It requires understanding how URLs resolve at the network level, normalizing IP representations to prevent bypasses, and explicitly blocking high-risk targets such as private networks and cloud metadata endpoints. Most importantly, it requires a shift in mindset—from validating inputs to controlling where your server is allowed to connect.

 

In this post, we’ll explore what it means to implement SSRF protection “the right way.” We’ll break down the common pitfalls, examine real attack vectors, and walk through a practical, production-grade approach that combines DNS-aware validation, IP normalization, and cloud-aware defenses.

 

1. Understanding SSRF: The Fundamentals

At its core, Server-Side Request Forgery (SSRF) is a vulnerability that allows an attacker to make your server perform unintended network requests.

 

Instead of directly attacking a protected system, the attacker leverages your application as a proxy to reach resources that are otherwise inaccessible.

 

How SSRF Works?

A typical SSRF scenario looks like this: Attacker Your Application Internal / External Resource

 

·      The attacker provides a crafted URL as input

·      Your application accepts it and makes a request

·      The request is executed from your server’s network context

·      The attacker receives the response (directly or indirectly)

 

Why This is Dangerous?

Your server usually has:

 

·      Access to internal services (databases, admin APIs)

·      Trust within your private network

·      Permissions to call cloud infrastructure endpoints

 

When your server makes a request, it does so with far more privileges than an external attacker.

Consider a backend endpoint that fetches data from a given URL:

 

public String fetch(String url) throws Exception {
    return HttpClient.newHttpClient()
        .send(HttpRequest.newBuilder(URI.create(url)).GET().build(),
              HttpResponse.BodyHandlers.ofString())
        .body();
}

This looks perfectly valid. But the problem is, it blindly trusts user input. An attacker can now supply URLs like "http://localhost:8080/admin". Then your server will

·      Call its own internal admin API

·      Return sensitive data

 

Or worse if attacker pass this endpoint "http://169.254.169.254/latest/meta-data/", in cloud environments, this can expose:

 

·      Access tokens

·      IAM credentials

·      Instance configuration

 

The vulnerability is not in the HTTP client. It’s in the assumption that the URL is safe to call.

 

Find the following Application to understand SSRF better.

 

InternalService.java

 

import java.io.BufferedWriter;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class InternalService {

    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(9090);
        System.out.println("Internal Service running on port 9090...");

        while (true) {
            Socket socket = serverSocket.accept();

            BufferedWriter out = new BufferedWriter(
                new OutputStreamWriter(socket.getOutputStream())
            );

            String responseBody = "SECRET: Database password = super-secret";

            String httpResponse =
                "HTTP/1.1 200 OK\r\n" +
                "Content-Length: " + responseBody.length() + "\r\n" +
                "\r\n" +
                responseBody;

            out.write(httpResponse);
            out.flush();
            socket.close();
        }
    }
}

   

Internal Service Runs on port 9090 and simulates a sensitive internal API. It returns confidential data (e.g., database password).

 

VulnerableServer.java

 

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.net.URLDecoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class VulnerableServer {

    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("Vulnerable Server running on port 8080...");

        while (true) {
            Socket socket = serverSocket.accept();

            BufferedReader in = new BufferedReader(
                new InputStreamReader(socket.getInputStream())
            );
            BufferedWriter out = new BufferedWriter(
                new OutputStreamWriter(socket.getOutputStream())
            );

            // Read HTTP request line
            String requestLine = in.readLine();
            System.out.println("Incoming request: " + requestLine);

            String url = extractUrl(requestLine);

            String responseBody;

            try {
                responseBody = fetch(url);
            } catch (Exception e) {
                responseBody = "Error: " + e.getMessage();
            }

            String httpResponse =
                "HTTP/1.1 200 OK\r\n" +
                "Content-Length: " + responseBody.length() + "\r\n" +
                "\r\n" +
                responseBody;

            out.write(httpResponse);
            out.flush();
            socket.close();
        }
    }

    // Vulnerable fetch method
    public static String fetch(String url) throws Exception {
        return HttpClient.newHttpClient()
            .send(HttpRequest.newBuilder(URI.create(url)).GET().build(),
                  HttpResponse.BodyHandlers.ofString())
            .body();
    }

    // Extract URL from query: GET /fetch?url=...
    private static String extractUrl(String requestLine) {
        try {
            String path = requestLine.split(" ")[1];
            String query = path.split("\\?")[1];

            for (String param : query.split("&")) {
                if (param.startsWith("url=")) {
                    return URLDecoder.decode(param.substring(4), "UTF-8");
                }
            }
        } catch (Exception ignored) {}

        return "http://example.com";
    }
}

Vulnerable Server runs on port 8080, accepts a URL from the user and fetches the content using Java HttpClient.

 

Compile the code

javac InternalService.java VulnerableServer.java

 

Start Internal Service

java InternalService

 

Start Vulnerable Server

java VulnerableServer

 

SSRF attack

curl "http://localhost:8080/fetch?url=http://localhost:9090"

 

Even though the attacker cannot directly access "http://localhost:9090", they can exploit the vulnerable server. In short, SSRF is about losing control over where your server can connect.

 

SSRF vs Client-Side Attacks

It’s important to distinguish SSRF from typical browser-based attacks.

 

Aspect

Client Side (eg,. XSS)

SSRF

Executes on   

User’s browser         

Your server             

Network access

Public internet        

Internal/private network

Impact scope  

Single user            

Entire infrastructure   

 

You are at risk if your application:

·      Accepts a URL as input

·      Makes an outbound HTTP request

·      Does not validate the destination properly

 

2. Real-World Impact of SSRF

SSRF is often underestimated because the initial exploit looks simple—just a URL fetch. But the real danger lies in where your server is allowed to connect. Once an attacker gains control over outbound requests, they can pivot into areas of your infrastructure that were never meant to be exposed externally.

 

2.1 Internal Service Access

Most production systems run multiple internal services that are:

 

·      not exposed to the internet

·      protected behind firewalls or private networks

·      trusted by default within the system

 

These include:

 

·      admin APIs

·      internal dashboards

·      health check endpoints

·      service-to-service APIs

 

An attacker can exploit SSRF to access these directly. Even though these endpoints are not publicly accessible, your server can access them because it runs inside the trusted network.

 

2.2 Cloud Metadata Exploitation (Critical Risk)

In cloud environments, SSRF becomes significantly more dangerous. Most cloud providers expose a metadata service at "http://169.254.169.254/latest/meta-data/".

 

This endpoint is accessible only from inside the instance and can return:

 

·      IAM role credentials

·      access tokens

·      instance identity

·      network configuration

 

If your application can be tricked into calling this endpoint, the attacker can extract credentials like "http://169.254.169.254/latest/meta-data/iam/security-credentials/"

 

Impact

·      Full access to cloud resources

·      Privilege escalation

·      Data breaches across storage, databases, and services

 

Many real-world breaches have started with SSRF to metadata endpoints.

 

2.3 Lateral Movement in Microservices

Modern architectures rely heavily on microservices, where services communicate over internal networks.

 

These services often:

·      trust each other implicitly

·      expose APIs without strong authentication

·      assume traffic originates from within the network

 

With SSRF, an attacker can move laterally:

http://user-service.internal/api/users

http://payment-service.internal/api/transactions

 

This allows the attacker to:

·      enumerate services

·      discover internal APIs

·      chain multiple internal calls

 

Impact

·      Service discovery

·      Unauthorized access to business logic

·      Chained attacks across services

 

2.4 Data Exfiltration

SSRF can be used not just to access systems, but to extract sensitive data. Depending on how your application returns responses, attackers can retrieve:

 

·      database contents

·      internal API responses

·      configuration files

·      secrets and tokens

 

Example: http://internal-db:5432

 

Even if the response is not directly returned, attackers can:

·      infer data via error messages

·      use blind SSRF techniques (timing, DNS callbacks)

 

In advanced scenarios, attackers combine SSRF with:

·      logging systems

·      external callbacks

·      DNS-based exfiltration

 

SSRF is dangerous not because of what it does initially, but because of what it enables. It turns your application into a gateway for:

 

·      internal network access

·      cloud infrastructure compromise

·      large-scale data exfiltration

 

3. Why Naive SSRF Protections Fail

Once teams become aware of SSRF, the first instinct is usually to "add a quick check" before making the request. These checks often look reasonable, but in practice, they are easy to bypass. The core mistake is treating SSRF as a string validation problem, when in reality it is a network resolution problem.

 

3.1 String Matching Pitfalls

A very common approach is to block dangerous keywords:

if (!url.contains("localhost")) {
    fetch(url);
}

At first glance, this seems to prevent access to localhost. But attackers can easily bypass this with slight variations.

 

Use IP instead of hostname: http://127.0.0.1

·      Alternative IP formats: http://2130706433, it is decimal representation of 127.0.0.1

·      IPv6 loopback: http://[::1]

·      IPv6-mapped IPv4: http://[::ffff:127.0.0.1]

·      DNS tricks: If this DNS "http://my-service.internal" resolves to 127.0.0.1

 

Your check is "Does the STRING contain localhost?", But what actually matters is "Where does this resolve at NETWORK level?". In summary, a hostname is just a label. The real destination is determined after DNS resolution.

 

Find the below working Application.

 

StringMatch.java

package com.sample.app.demo2;

import java.net.InetAddress;
import java.net.URI;

public class StringMatch {

	public static void main(String[] args) {

		String[] testUrls = { "http://localhost:9090", "http://localhost.attacker.com", "http://127.0.0.1",
				"http://2130706433", // decimal form of 127.0.0.1
				"http://[::1]", // IPv6 loopback
				"http://[::ffff:127.0.0.1]", // IPv6-mapped IPv4
				"http://example.com" };

		for (String url : testUrls) {
			testUrl(url);
			System.out.println("--------------------------------------------------");
		}
	}

	private static void testUrl(String url) {
		System.out.println("Testing URL: " + url);

		// ❌ Naive check
		if (!url.contains("localhost")) {
			System.out.println("Naive Check: ✅ ALLOWED");
		} else {
			System.out.println("Naive Check: ❌ BLOCKED");
		}

		try {
			URI uri = URI.create(url);
			String host = uri.getHost();

			System.out.println("Extracted Host: " + host);

			InetAddress[] addresses = InetAddress.getAllByName(host);

			for (InetAddress addr : addresses) {
				System.out.println("Resolved IP: " + addr.getHostAddress());

				if (addr.isLoopbackAddress()) {
					System.out.println("⚠️  This is LOOPBACK (localhost equivalent)");
				}
			}

		} catch (Exception e) {
			System.out.println("Error resolving host: " + e.getMessage());
		}
	}
}

   

Output

 

Testing URL: http://localhost:9090
Naive Check: ❌ BLOCKED
Extracted Host: localhost
Resolved IP: 127.0.0.1
⚠️  This is LOOPBACK (localhost equivalent)
Resolved IP: 0:0:0:0:0:0:0:1
⚠️  This is LOOPBACK (localhost equivalent)
--------------------------------------------------
Testing URL: http://localhost.attacker.com
Naive Check: ❌ BLOCKED
Extracted Host: localhost.attacker.com
Resolved IP: 146.112.61.107
--------------------------------------------------
Testing URL: http://127.0.0.1
Naive Check: ✅ ALLOWED
Extracted Host: 127.0.0.1
Resolved IP: 127.0.0.1
⚠️  This is LOOPBACK (localhost equivalent)
--------------------------------------------------
Testing URL: http://2130706433
Naive Check: ✅ ALLOWED
Extracted Host: 2130706433
Resolved IP: 127.0.0.1
⚠️  This is LOOPBACK (localhost equivalent)
--------------------------------------------------
Testing URL: http://[::1]
Naive Check: ✅ ALLOWED
Extracted Host: [::1]
Resolved IP: 0:0:0:0:0:0:0:1
⚠️  This is LOOPBACK (localhost equivalent)
--------------------------------------------------
Testing URL: http://[::ffff:127.0.0.1]
Naive Check: ✅ ALLOWED
Extracted Host: [::ffff:127.0.0.1]
Resolved IP: 127.0.0.1
⚠️  This is LOOPBACK (localhost equivalent)
--------------------------------------------------
Testing URL: http://example.com
Naive Check: ✅ ALLOWED
Extracted Host: example.com
Resolved IP: 104.18.26.120
Resolved IP: 104.18.27.120
Resolved IP: 2606:4700:0:0:0:0:6812:1b78
Resolved IP: 2606:4700:0:0:0:0:6812:1a78
--------------------------------------------------

   

3.2 Blocklist Limitations

Another common approach is maintaining a blocklist:

 

List<String> blocked = List.of("localhost", "127.0.0.1");

if (!blocked.contains(host)) {
    fetch(url);
}

   

This fails because:

·      There are many ways to represent the same IP

·      Attackers can use alternative formats

 

Bypass examples

·      http://127.1

·      http://2130706433        (decimal representation of 127.0.0.1)

·      http://0x7f000001        (hex representation)

 

All of these resolve to 127.0.0.1, but string checks won’t catch them.

 

3.3 Redirect-Based Bypass

Even if you validate the initial URL, attackers can exploit HTTP redirects.

 

Example:

http://safe-site.com redirects http://localhost:8080

 

Naive code:

HttpClient.newHttpClient().send(request, BodyHandlers.ofString());

 

By default, many HTTP clients follow redirects automatically.

 

Attack Flow

·      User provides: http://trusted.com

·      trusted.com responds with: 302 http://localhost:8080

·      Your server follows redirect

·      Internal service is accessed

 

Validation only happened once, but the request changed later.

if (!url.contains("127.0.0.1")) {
    fetch(url);
}

 

 

 

 

3.4 IPv6 Tricks and IP Encoding

Modern systems support IPv6, and attackers can use it to bypass IPv4 checks.

 

For example, http://[::1] is equivalent to 127.0.0.1

 

Let's take a look at following snippet.

This completely misses IPv6 representations.

 

A URL goes through multiple transformations: URL Hostname DNS Resolution IP Address Network Call

Naive protections only validate: URL (string)

But attackers control: Hostname DNS IP

 

In summary, if your SSRF protection relies on string checks, it is already broken.

 

4. SSRF is a Network Problem, Not a String Problem

One of the biggest misconceptions about SSRF is treating it as an input validation issue—something you can fix with string checks, regex, or simple filters. But it’s not. SSRF exists because your application is making network calls on behalf of untrusted input. That means the real question isn’t "Does this URL look safe?", but rather "Where will this request actually go at the network level?"

 

4.1 The Journey of a URL

When your application receives a URL, it doesn’t directly connect to that string. It goes through multiple transformation steps:

 

URL Hostname DNS Resolution IP Address Network Connection

 

For example, let's take the url http://example.com/api, following steps are performed while hitting this API.

 

·      Hostname extraction: example.com

·      DNS resolution for example.com: 93.184.216.34

·      IP address selection: could be IPv4 or IPv6, multiple results possible

·      Actual network call: Your server connects to that IP

 

Consider this URL "http://example.com", Looks harmless, right? But if DNS resolves it to 127.0.0.1, your server is now calling itself (localhost)

 

4.2 What You Should Be Validating Instead

A secure system focuses on network-level validation:

 

·      Resolve hostname get all IPs

·      Normalize IP formats (IPv4, IPv6, mapped)

·      Check if IP belongs to:

·      loopback (localhost)

·      private networks

·      link-local ranges

·      cloud metadata endpoints

 

Only after this should the request be allowed.

 

Instead of thinking "Is this URL safe?" think "Am I okay with my server connecting to this destination?"

 

5. IP Normalization: Closing Hidden Bypass Vectors

Even if you move beyond simple string checks and start validating IP addresses, there’s still a subtle but critical problem, the same destination can be represented in multiple different formats.

 

If your validation logic does not account for these variations, attackers can bypass your defenses using equivalent—but differently encoded—IP representations. This is where IP normalization becomes essential.

 

IP normalization is the process of converting an IP address—no matter how it is written into a standard, canonical form so that it can be reliably compared and validated.

 

5.1 Why Do We Need It?

The same IP address can be represented in many different ways. For example, all of the following represent localhost.

127.0.0.1
::1
::ffff:127.0.0.1
2130706433
127.1

   

To a human, these may look different, but to the network, they all point to the same destination

 

The Problem Without Normalization

If you compare IPs as plain strings like below:

 

if (ip.equals("127.0.0.1")) {
    block();
}

You will miss:

 

·      ::1

·      2130706433

·      ::ffff:127.0.0.1

 

5.2 What Normalization Does

IP normalization converts all these variants into a single consistent format.

 

For example:

InetAddress address = InetAddress.getByName("::ffff:127.0.0.1");
System.out.println(address.getHostAddress());

Output

127.0.0.1

Without normalization: "::ffff:127.0.0.1" ≠ "127.0.0.1"

With Normalization: ::ffff:127.0.0.1 127.0.0.1

 

Find the below working Application.

 

IpNormalizationDemo.java  

package com.sample.app.demo2;

import java.net.InetAddress;
import java.net.URI;

public class IpNormalizationDemo {

	public static void main(String[] args) {

		String[] testUrls = { "http://127.0.0.1", "http://[::1]", "http://[::ffff:127.0.0.1]", "http://2130706433", // decimal
				"http://0x7f000001", // hex (may fail in Java DNS)
				"http://127.1", // short form
				"http://example.com" };

		for (String url : testUrls) {
			System.out.println("==================================================");
			testUrl(url);
		}
	}

	private static void testUrl(String url) {
		System.out.println("Testing URL: " + url);

		// ❌ Naive string-based check
		if (url.contains("127.0.0.1") || url.contains("localhost")) {
			System.out.println("Naive Check: ❌ BLOCKED");
		} else {
			System.out.println("Naive Check: ✅ ALLOWED");
		}

		try {
			URI uri = URI.create(url);
			String host = uri.getHost();

			System.out.println("Extracted Host: " + host);

			// Resolve and normalize
			InetAddress[] addresses = InetAddress.getAllByName(host);

			for (InetAddress addr : addresses) {
				String normalizedIp = addr.getHostAddress();

				System.out.println("Normalized IP: " + normalizedIp);

				// ✅ Proper checks
				if (addr.isLoopbackAddress()) {
					System.out.println("❗ SECURITY RISK: Loopback (localhost)");
				} else if (addr.isSiteLocalAddress()) {
					System.out.println("❗ SECURITY RISK: Private network");
				} else if (addr.isAnyLocalAddress()) {
					System.out.println("❗ SECURITY RISK: Any local address");
				} else {
					System.out.println("✅ Public IP (appears safe)");
				}
				System.out.println();
			}

		} catch (Exception e) {
			System.out.println("Error: " + e.getMessage());
		}
	}
}

   

Output

 

==================================================
Testing URL: http://127.0.0.1
Naive Check: ❌ BLOCKED
Extracted Host: 127.0.0.1
Normalized IP: 127.0.0.1
❗ SECURITY RISK: Loopback (localhost)

==================================================
Testing URL: http://[::1]
Naive Check: ✅ ALLOWED
Extracted Host: [::1]
Normalized IP: 0:0:0:0:0:0:0:1
❗ SECURITY RISK: Loopback (localhost)

==================================================
Testing URL: http://[::ffff:127.0.0.1]
Naive Check: ❌ BLOCKED
Extracted Host: [::ffff:127.0.0.1]
Normalized IP: 127.0.0.1
❗ SECURITY RISK: Loopback (localhost)

==================================================
Testing URL: http://2130706433
Naive Check: ✅ ALLOWED
Extracted Host: 2130706433
Normalized IP: 127.0.0.1
❗ SECURITY RISK: Loopback (localhost)

==================================================
Testing URL: http://0x7f000001
Naive Check: ✅ ALLOWED
Extracted Host: 0x7f000001
Error: 0x7f000001
==================================================
Testing URL: http://127.1
Naive Check: ✅ ALLOWED
Extracted Host: null
Normalized IP: 127.0.0.1
❗ SECURITY RISK: Loopback (localhost)

==================================================
Testing URL: http://example.com
Naive Check: ✅ ALLOWED
Extracted Host: example.com
Normalized IP: 104.18.27.120
✅ Public IP (appears safe)

Normalized IP: 104.18.26.120
✅ Public IP (appears safe)

Normalized IP: 2606:4700:0:0:0:0:6812:1a78
✅ Public IP (appears safe)

Normalized IP: 2606:4700:0:0:0:0:6812:1b78
✅ Public IP (appears safe)

   

6. DNS Resolution and Rebinding Attacks

By now, you’ve seen why validating raw URLs or strings isn’t enough. But even if you move to hostname validation, there’s still a critical layer many implementations miss. DNS resolution can completely change where your request actually goes

 

What is DNS Resolution?

When your application sees a URL like "http://example.com", it does not connect to example.com directly. Instead, it performs a DNS lookup and fetch the IP Addresses mapped to it.

 

Example

example.com 93.184.216.34

 

The IP address is the real destination. You might think "If the hostname looks safe, the request is safe", that assumption is wrong. Because a hostname can resolve to any IP, including internal or sensitive ones.

 

DnsResolutionDemo.java

package com.sample.app.demo2;

import java.net.InetAddress;

public class DnsResolutionDemo {

	public static void main(String[] args) {
		String host = "google.com";

		try {
			System.out.println("Resolving host: " + host);
			System.out.println("--------------------------------------------------");

			InetAddress[] addresses = InetAddress.getAllByName(host);

			for (InetAddress addr : addresses) {
				String ip = addr.getHostAddress();

				System.out.println("Resolved IP: " + ip);

				// Classification (important for SSRF understanding)
				if (addr.isLoopbackAddress()) {
					System.out.println("❗ Loopback (localhost)");
				} else if (addr.isSiteLocalAddress()) {
					System.out.println("❗ Private IP");
				} else if (addr.isAnyLocalAddress()) {
					System.out.println("❗ Any local address");
				} else if (addr.isLinkLocalAddress()) {
					System.out.println("❗ Link-local address");
				} else {
					System.out.println("✅ Public IP");
				}

				System.out.println();
			}

		} catch (Exception e) {
			System.out.println("Error resolving host: " + e.getMessage());
		}
	}
}

   

Output

Resolving host: google.com
--------------------------------------------------
Resolved IP: 142.250.182.78
✅ Public IP

Resolved IP: 2404:6800:4007:810:0:0:0:200e
✅ Public IP

   

7. Cloud Metadata Endpoints: The Biggest SSRF Risk

If there is one SSRF scenario you absolutely must understand, it is this access to cloud metadata endpoints can lead to full infrastructure compromise. This is not theoretical—this is one of the most exploited SSRF attack paths in real systems.

 

7.1 What Are Metadata Endpoints?

When you run your application in a cloud environment like:

 

·      Amazon EC2

·      Google Cloud VM

·      Azure VM

 

the cloud provider gives each instance a special internal HTTP service called a metadata endpoint. Think of it as a local API available only inside the machine.

 

This metadata service provides:

 

·      Instance details (ID, region, etc.)

·      Network configuration

·      IAM roles / service accounts

·      Temporary access credentials (very sensitive)

 

Almost all cloud providers expose it via a special IP address 169.254.169.254. This is a link-local IP (not accessible from the internet), only accessible from inside the machine.

 

Your application can access this endpoint like any normal HTTP API: http://169.254.169.254/latest/meta-data/, if an attacker can trick your app into calling this URL (via SSRF), then they can retrieve internal secrets.

 

For example, take a look at following snippet.

public String fetch(String url) throws Exception {
    return HttpClient.newHttpClient()
        .send(HttpRequest.newBuilder(URI.create(url)).GET().build(),
              HttpResponse.BodyHandlers.ofString())
        .body();
}

   

If attacker pass input 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' to fetch api, then your backedn make request to the metadata server and expose sensitive details.

 

Common Metadata endpoints

 

169.254.169.254
169.254.170.2        (AWS ECS)
169.254.170.23       (AWS EKS)
100.100.100.200      (Alibaba Cloud)

   

IPv6 variants for the same

 

fd00:ec2::254
fe80::a9fe:a9fe

   

These IPs are:

·      Not public

·      Not visible externally

·      But fully accessible from your app

 

That’s why SSRF is so dangerous.

 

Find the sample Application to demo the same.

 

SsrfProtection.java

 

package com.sample.app.demo3;

import java.net.*;
import java.util.Set;

/**
 * SSRF Protection utility for validating URLs against Server-Side Request Forgery attacks.
 *
 * <p>This class provides utilities to validate user-provided URLs and prevent SSRF attacks
 * by blocking requests to:
 *
 * <ul>
 *     <li>Private IP ranges (RFC 1918, loopback, link-local)</li>
 *     <li>Cloud metadata endpoints (AWS, GCP, Azure, etc.)</li>
 *     <li>Localhost addresses</li>
 *     <li>Invalid or unsafe URL schemes</li>
 * </ul>
 *
 * <h2>Usage</h2>
 *
 * <pre>{@code
 * // Validate a URL (throws IllegalArgumentException if unsafe)
 * String safeUrl = SsrfProtection.validateSafeUrl(
 *     "https://example.com/webhook",
 *     false,
 *     true
 * );
 *
 * // Check if URL is safe (returns boolean)
 * boolean isSafe = SsrfProtection.isSafeUrl(
 *     "http://192.168.1.1",
 *     false,
 *     true
 * );
 *
 * // Allow private IPs for development/testing (still blocks metadata)
 * String devUrl = SsrfProtection.validateSafeUrl(
 *     "http://localhost:8080",
 *     true,
 *     true
 * );
 * }</pre>
 *
 * <h2>Security Guarantees</h2>
 *
 * <ul>
 *     <li>Validates URLs after DNS resolution (prevents DNS rebinding attacks)</li>
 *     <li>Normalizes IP addresses (handles IPv6 and IPv4-mapped IPv6)</li>
 *     <li>Validates all resolved IPs (defends against multi-IP resolution tricks)</li>
 *     <li>Always blocks cloud metadata endpoints (even if private access is allowed)</li>
 *     <li>Fails closed on DNS or network errors</li>
 * </ul>
 */
public final class SsrfProtection {

    private SsrfProtection() {}

    // -----------------------------
    // Constants
    // -----------------------------

    /**
     * Known cloud metadata endpoint IPs across major cloud providers.
     */
    private static final Set<String> CLOUD_METADATA_IPS = Set.of(
        "169.254.169.254",   // AWS, GCP, Azure
        "169.254.170.2",     // AWS ECS
        "169.254.170.23",    // AWS EKS
        "100.100.100.200",   // Alibaba Cloud
        "fd00:ec2::254",     // AWS IPv6
        "fd00:ec2::23",
        "fe80::a9fe:a9fe"    // OpenStack
    );

    /**
     * Known metadata hostnames.
     */
    private static final Set<String> CLOUD_METADATA_HOSTNAMES = Set.of(
        "metadata.google.internal",
        "metadata",
        "instance-data"
    );

    /**
     * Localhost name variations.
     */
    private static final Set<String> LOCALHOST_NAMES = Set.of(
        "localhost",
        "localhost.localdomain"
    );

    // -----------------------------
    // Public API
    // -----------------------------

    /**
     * Validates a URL for SSRF protection.
     *
     * <p>This method ensures that the provided URL does not point to internal
     * or sensitive network locations such as private IP ranges or cloud metadata endpoints.
     *
     * @param url the URL to validate
     * @param allowPrivate if true, allows private IPs and localhost (for development use)
     *                     but still blocks cloud metadata endpoints
     * @param allowHttp if true, allows HTTP and HTTPS; otherwise only HTTPS is allowed
     * @return the validated URL string
     *
     * @throws IllegalArgumentException if the URL is invalid or unsafe
     *
     * <h3>Examples</h3>
     *
     * <pre>{@code
     * validateSafeUrl("https://example.com", false, true);
     * // returns "https://example.com"
     *
     * validateSafeUrl("http://127.0.0.1", false, true);
     * // throws IllegalArgumentException
     *
     * validateSafeUrl("http://169.254.169.254/latest/meta-data/", false, true);
     * // throws IllegalArgumentException
     *
     * validateSafeUrl("http://localhost:8080", true, true);
     * // allowed in dev mode
     * }</pre>
     */
    public static String validateSafeUrl(String url,
                                         boolean allowPrivate,
                                         boolean allowHttp) {

        URI uri = parseUrl(url);

        validateScheme(uri, allowHttp);

        String host = uri.getHost();
        if (host == null || host.isBlank()) {
            throw new IllegalArgumentException("URL must have a valid hostname");
        }

        // Block metadata hostnames
        if (isCloudMetadataHost(host)) {
            throw new IllegalArgumentException("Cloud metadata endpoints are not allowed: " + host);
        }

        // Block localhost
        if (!allowPrivate && isLocalhost(host)) {
            throw new IllegalArgumentException("Localhost URLs are not allowed: " + host);
        }

        // Resolve DNS and validate all IPs
        InetAddress[] addresses = resolveAll(host);

        for (InetAddress addr : addresses) {

            String normalizedIp = normalizeIp(addr);

            // Always block metadata IPs
            if (isCloudMetadataIp(normalizedIp, addr)) {
                throw new IllegalArgumentException(
                    "URL resolves to cloud metadata IP: " + normalizedIp
                );
            }

            // Block localhost IPs
            if (!allowPrivate && isLocalhost(addr)) {
                throw new IllegalArgumentException(
                    "URL resolves to localhost IP: " + normalizedIp
                );
            }

            // Block private IPs
            if (!allowPrivate && isPrivate(addr)) {
                throw new IllegalArgumentException(
                    "URL resolves to private IP address: " + normalizedIp
                );
            }
        }

        return url;
    }

    /**
     * Checks whether a URL is safe (non-throwing version).
     *
     * @param url the URL to check
     * @param allowPrivate whether private IPs are allowed
     * @param allowHttp whether HTTP is allowed
     * @return true if safe, false otherwise
     */
    public static boolean isSafeUrl(String url,
                                   boolean allowPrivate,
                                   boolean allowHttp) {
        try {
            validateSafeUrl(url, allowPrivate, allowHttp);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // -----------------------------
    // Internal Helpers
    // -----------------------------

    private static URI parseUrl(String url) {
        try {
            return new URI(url);
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid URL: " + url, e);
        }
    }

    private static void validateScheme(URI uri, boolean allowHttp) {
        String scheme = uri.getScheme();

        if (scheme == null ||
            (!scheme.equalsIgnoreCase("http") &&
             !scheme.equalsIgnoreCase("https"))) {
            throw new IllegalArgumentException("Only HTTP/HTTPS URLs are allowed");
        }

        if (!allowHttp && !scheme.equalsIgnoreCase("https")) {
            throw new IllegalArgumentException("Only HTTPS URLs are allowed");
        }
    }

    private static InetAddress[] resolveAll(String host) {
        try {
            return InetAddress.getAllByName(host);
        } catch (Exception e) {
            throw new IllegalArgumentException(
                "Failed to resolve hostname: " + host, e
            );
        }
    }

    /**
     * Normalizes IP address into canonical string form.
     *
     * <p>Handles IPv6, IPv4, and IPv4-mapped IPv6 representations.
     */
    private static String normalizeIp(InetAddress addr) {
        return addr.getHostAddress();
    }

    // -----------------------------
    // Classification Helpers
    // -----------------------------

    private static boolean isPrivate(InetAddress addr) {
        return addr.isSiteLocalAddress()
            || addr.isLinkLocalAddress()
            || addr.isAnyLocalAddress();
    }

    private static boolean isLocalhost(InetAddress addr) {
        return addr.isLoopbackAddress();
    }

    private static boolean isLocalhost(String host) {
        return LOCALHOST_NAMES.contains(host.toLowerCase());
    }

    private static boolean isCloudMetadataHost(String host) {
        return CLOUD_METADATA_HOSTNAMES.contains(host.toLowerCase());
    }

    private static boolean isCloudMetadataIp(String ip, InetAddress addr) {
        if (CLOUD_METADATA_IPS.contains(ip)) {
            return true;
        }

        // Defense-in-depth: block full link-local range
        return addr.isLinkLocalAddress();
    }
}

   

SsrfDemoApp.java

 

package com.sample.app.demo3;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SsrfDemoApp {

	public static void main(String[] args) {

		// Test URLs
		String[] testUrls = { "https://example.com", // ✅ Safe
				"http://localhost:8080/admin", // ❌ Localhost
				"http://192.168.1.1", // ❌ Private IP
				"http://169.254.169.254/latest/meta-data/", // ❌ Cloud metadata
				"http://google.com" // ✅ Safe
		};

		System.out.println("=== SSRF Protection Demo ===\n");

		for (String url : testUrls) {
			testUrl(url, false);
		}

		System.out.println("\n=== Dev Mode (allowPrivate=true) ===\n");

		for (String url : testUrls) {
			testUrl(url, true);
		}
	}

	private static void testUrl(String url, boolean allowPrivate) {
	    System.out.println("Testing: " + url);

	    try {
	        String safeUrl = SsrfProtection.validateSafeUrl(
	            url,
	            allowPrivate,
	            true
	        );

	        System.out.println("✔ SAFE: " + safeUrl);

	        try {
	            String response = fetch(safeUrl);
	            System.out.println("Response length: " + response.length());
	        } catch (Exception e) {
	            System.out.println("⚠ FETCH FAILED: " + 
	                (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
	        }

	    } catch (Exception e) {
	        System.out.println("✘ BLOCKED: " + 
	            (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
	    }

	    System.out.println("----------------------------------------");
	}

	/**
	 * Simple HTTP fetch method (simulates SSRF vulnerable behavior if not
	 * protected)
	 */
	public static String fetch(String url) throws Exception {
		return HttpClient.newHttpClient()
				.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString())
				.body();
	}
}

Output

=== SSRF Protection Demo ===

Testing: https://example.com
✔ SAFE: https://example.com
Response length: 528
----------------------------------------
Testing: http://localhost:8080/admin
✘ BLOCKED: Localhost URLs are not allowed: localhost
----------------------------------------
Testing: http://192.168.1.1
✘ BLOCKED: URL resolves to private IP address: 192.168.1.1
----------------------------------------
Testing: http://169.254.169.254/latest/meta-data/
✘ BLOCKED: URL resolves to cloud metadata IP: 169.254.169.254
----------------------------------------
Testing: http://google.com
✔ SAFE: http://google.com
Response length: 219
----------------------------------------

=== Dev Mode (allowPrivate=true) ===

Testing: https://example.com
✔ SAFE: https://example.com
Response length: 528
----------------------------------------
Testing: http://localhost:8080/admin
✔ SAFE: http://localhost:8080/admin
⚠ FETCH FAILED: ConnectException
----------------------------------------
Testing: http://192.168.1.1
✔ SAFE: http://192.168.1.1

   

You can download these applications from this link

 

You may like

Miscellaneous

Quick guide to Java DecimalFormat class

Compress and decompress a string in Java

Discover and load the implementations of a service using ServiceLoader in Java

Code Formatting Control in Java: Using @formatter:off and @formatter:on

No HTTP. No Sockets. Then How Do Programs Communicate? Enter stdio Transport

No comments:

Post a Comment