Skip to content

CWE-918: Server-Side Request Forgery (SSRF) - Java

Overview

Server-Side Request Forgery (SSRF) occurs when an attacker can make a server perform HTTP requests to arbitrary destinations. This can lead to accessing internal services, cloud metadata endpoints, or bypassing firewalls. Always validate URLs with a single parser, enforce allowlists, and implement network-level protections.

Primary Defence: Validate URLs against an allowlist of permitted domains/IPs, block private IP ranges (RFC 1918, loopback, link-local), and restrict protocols to https:// only.

Common Vulnerable Patterns

Direct URL Usage from User Input

// VULNERABLE - Direct user input to HTTP request
import java.net.URL;
import java.io.InputStream;

public class ImageFetcher {
    public byte[] fetchImage(String imageUrl) throws Exception {
        // No validation - attacker can access internal resources!
        URL url = new URL(imageUrl);
        try (InputStream in = url.openStream()) {
            return in.readAllBytes();
        }
    }
}

// Attack examples:
// http://localhost:8080/admin
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// file:///etc/passwd

Why this is vulnerable: Accepting user-provided URLs without validation allows attackers to make the server request internal resources (localhost, cloud metadata 169.254.169.254, internal IPs), bypass firewalls, or read local files via file:// protocol.

Unvalidated HttpClient Requests

// VULNERABLE - No URL validation
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;

public class WebhookHandler {
    public void sendWebhook(String webhookUrl, String data) throws Exception {
        // No validation - SSRF vulnerability!
        try (CloseableHttpClient client = HttpClients.createDefault()) {
            HttpGet request = new HttpGet(webhookUrl);
            client.execute(request, response -> response.getCode());
        }
    }
}

// Attack: webhookUrl = "http://internal-api.local/sensitive-endpoint"

Why this is vulnerable: HttpClient makes requests to any URL without validation, enabling attackers to scan internal networks, access cloud metadata endpoints (AWS, Azure, GCP), or pivot attacks through the server. HttpClients.createDefault() also follows redirects, so even a checked URL can hand the client on to somewhere that was never checked.

Unvalidated Spring RestTemplate

// VULNERABLE - RestTemplate without validation
import org.springframework.web.client.RestTemplate;
import org.springframework.web.bind.annotation.*;

@RestController
public class ProxyController {
    private final RestTemplate restTemplate = new RestTemplate();

    @GetMapping("/proxy")
    public String proxy(@RequestParam String url) {
        // No validation - SSRF vulnerability!
        return restTemplate.getForObject(url, String.class);
    }
}

// Attack: /proxy?url=http://169.254.169.254/latest/meta-data/

Why this is vulnerable: Spring RestTemplate fetches any URL without validation, so a request parameter chooses the destination - internal services, cloud metadata APIs, or localhost-bound administrative interfaces.

Following Redirects Without Re-validation

// VULNERABLE - the client follows a redirect nobody validated
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Set;

public class LinkPreviewFetcher {
    private static final Set<String> ALLOWED_HOSTS = Set.of("news.example.com");

    // ALWAYS follows every 3xx, including an https -> http downgrade. NORMAL
    // refuses only the downgrade - a redirect to https://internal-host, or
    // from an http:// original, passes it just the same
    private final HttpClient client = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.ALWAYS)
        .build();

    public String preview(String pageUrl) throws Exception {
        URI uri = new URI(pageUrl).parseServerAuthority();
        if (!"https".equals(uri.getScheme()) || !ALLOWED_HOSTS.contains(uri.getHost().toLowerCase())) {
            throw new SecurityException("URL not allowed");
        }

        // The check above ran once, against this URI. The client follows the
        // 302 below to a URI nothing checked
        HttpRequest request = HttpRequest.newBuilder(uri).build();
        return client.send(request, HttpResponse.BodyHandlers.ofString()).body();
    }
}

// Attack: a page on the allowlisted host answers
//   302 Location: http://169.254.169.254/latest/meta-data/
// and the client fetches it with the server's network position

Why this is vulnerable: The validation ran against the URL the caller supplied, and the client then made a second request to a URL it took from the response. Any allowlisted host that can be made to redirect - an open redirect on it, a user-controlled short link, a compromised page - hands the client to the metadata service. An open redirect on this application is the neighbouring weakness, CWE-601; it becomes SSRF only when something server-side follows it, and the fetcher above is that something.

Two Parsers Disagreeing About the Host (UriComponentsBuilder, Unpatched)

// VULNERABLE on Spring Framework < 5.3.34 / 6.0.19 / 6.1.6
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriComponents;

public boolean isAllowedHost(String userSuppliedUrl) {
    UriComponents parsed = UriComponentsBuilder.fromUriString(userSuppliedUrl).build();
    return ALLOWED_HOSTS.contains(parsed.getHost());
}

// Elsewhere, a browser or a different HTTP client fetches the same string:
// webClient.get(userSuppliedUrl)

Why this is vulnerable: This follows the page's own "validate URLs with a single parser" principle in letter but not in spirit - the validation and the actual request use two different parsers on the same raw string, and before Spring Framework 5.3.34 / 6.0.19 / 6.1.6 those parsers disagreed about backslash. UriComponentsBuilder's regex-based parser treated \ as an ordinary character rather than a URL-component terminator, while the WHATWG URL Standard that browsers and many HTTP clients follow treats \ the same as /. A crafted URL such as https://internal-service.local\@allowed.example/path parses under the old regex as userinfo internal-service.local\ and host allowed.example - it passes the allowlist check - while a browser or WHATWG-compliant client reads the same string as host internal-service.local with @allowed.example/path as its path, and connects to the attacker-chosen host instead. Three successive CVEs closed gaps in the same regex (CVE-2024-22243, CVE-2024-22259, CVE-2024-22262), each fixing a different character the parser had failed to treat as a delimiter; the fix confirmed here added \ to the scheme, userinfo, host, and port patterns. Every secure pattern below validates a URI/URL object and then uses that same object to connect, so there is only ever one parse - this vulnerable pattern instead validates a UriComponentsBuilder-derived host string and hands the original, unparsed string to something else (a browser via redirect, a different HTTP client) to resolve independently. Upgrading closes the specific backslash gap, but the durable fix is to stop re-parsing: whatever performs the outbound request or redirect should consume the already-validated URI object, not a string parsed a second time by something that might disagree with UriComponentsBuilder about where the host ends.

Secure Patterns

The Address Predicate, in One Place

Every pattern below has to answer the same question - is this an address the application may connect to - so it is worth having one answer to it. Two copies of a range list are two lists to maintain, and the copy that falls behind is the one nothing tests.

// SECURE - one predicate, called from validation and from connection time
import java.net.InetAddress;
import java.net.UnknownHostException;

public final class SsrfAddressPolicy {

    private SsrfAddressPolicy() {
    }

    /** True when the address is not one this application may connect to. */
    public static boolean isBlocked(InetAddress address) {
        if (isBlockedForm(address)) {
            return true;
        }
        // ::7f00:1 is 127.0.0.1 in the IPv4-compatible form and stays an
        // Inet6Address, whose isLoopbackAddress() is false. Check that form as
        // IPv4 too - but only after the address itself, or ::1 unwraps to
        // 0.0.0.1 and loses the property that made it worth blocking
        InetAddress unwrapped = unwrapIpv4(address);
        return unwrapped != address && isBlockedForm(unwrapped);
    }

    /** True when any current DNS answer for the host is blocked, or none resolves. */
    public static boolean resolvesToBlocked(String host) {
        try {
            for (InetAddress address : InetAddress.getAllByName(host)) {
                if (isBlocked(address)) {
                    return true;
                }
            }
            return false;
        } catch (UnknownHostException e) {
            return true;   // fail closed: a name that will not resolve is not one to fetch
        }
    }

    private static boolean isBlockedForm(InetAddress a) {
        if (a.isSiteLocalAddress() ||       // 10/8, 172.16/12, 192.168/16, fec0::/10
            a.isLoopbackAddress() ||        // 127/8, ::1
            a.isLinkLocalAddress() ||       // 169.254/16 (169.254.169.254), fe80::/10
            a.isAnyLocalAddress() ||        // 0.0.0.0, ::
            a.isMulticastAddress()) {       // 224/4, ff00::/8
            return true;
        }

        byte[] b = a.getAddress();
        if (b.length == 4) {
            int first = b[0] & 0xFF;
            int second = b[1] & 0xFF;
            if (first == 0) return true;                                         // 0.0.0.0/8 "this network"
            if (first == 100 && second >= 64 && second <= 127) return true;      // 100.64.0.0/10 CGN
            if (first == 198 && (second == 18 || second == 19)) return true;      // 198.18.0.0/15 benchmarking
            if (first == 192 && second == 0 && (b[2] & 0xFF) == 0) return true;  // 192.0.0.0/24
            // Documentation ranges (RFC 5737): nothing legitimate lives there,
            // and a filter that admits them has a gap no must-allow test notices
            if (first == 192 && second == 0 && (b[2] & 0xFF) == 2) return true;     // 192.0.2.0/24
            if (first == 198 && second == 51 && (b[2] & 0xFF) == 100) return true;  // 198.51.100.0/24
            if (first == 203 && second == 0 && (b[2] & 0xFF) == 113) return true;   // 203.0.113.0/24
            return first >= 240;                                                 // 240/4 reserved
        }
        // fc00::/7 unique-local, where IPv6 private networks actually are. No
        // JDK predicate covers it: isSiteLocalAddress() knows only fec0::/10
        if ((b[0] & 0xFE) == 0xFC) return true;

        // IPv6 ranges that carry an IPv4 address in their low bits. The JDK
        // folds ::ffff:127.0.0.1 into an Inet4Address, and unwrapIpv4 below
        // handles ::7f00:1, but these it leaves alone - and each can spell
        // 127.0.0.1 or 169.254.169.254. Of the two NAT64 prefixes, only the
        // local-use one can carry a non-global IPv4 address through a compliant
        // translator (RFC 8215); RFC 6052 section 3.1 requires the well-known
        // prefix form to be dropped. 6to4 and Teredo need a relay. All are
        // refused as encodings, cheaply, without a claim about routing.
        //
        // Match the prefixes IANA actually assigns rather than the /32 they sit
        // in: 64:ff9b::/96 and 64:ff9b:1::/48 are two separate entries in the
        // IPv6 special-purpose registry, and a filter that rejects more than
        // its own comment says is the mirror of one that rejects less
        int word0 = ((b[0] & 0xFF) << 8) | (b[1] & 0xFF);
        int word1 = ((b[2] & 0xFF) << 8) | (b[3] & 0xFF);
        int word2 = ((b[4] & 0xFF) << 8) | (b[5] & 0xFF);
        int word4 = ((b[8] & 0xFF) << 8) | (b[9] & 0xFF);
        int word5 = ((b[10] & 0xFF) << 8) | (b[11] & 0xFF);

        // Documentation (2001:db8::/32, RFC 3849) and discard-only (100::/64,
        // RFC 6666): the IPv6 counterparts of the RFC 5737 ranges above
        if (word0 == 0x2001 && word1 == 0x0db8) return true;
        if (word0 == 0x0100 && word1 == 0 && word2 == 0 && (b[6] | b[7]) == 0) return true;

        // ::ffff:0:a.b.c.d - the IPv4-translated form (RFC 6145), one zero
        // group on from the mapped form the JDK folds. Nothing routes it to a
        // host, so the whole prefix is refused rather than unwrapped
        if (word0 == 0 && word1 == 0 && word2 == 0 && (b[6] | b[7]) == 0
                && word4 == 0xffff && word5 == 0) {
            return true;
        }

        if (word0 == 0x0064 && word1 == 0xff9b) {
            // 64:ff9b:1::/48 - the local-use prefix, RFC 8215
            if (word2 == 0x0001) return true;
            // 64:ff9b::/96 - the well-known prefix, RFC 6052: everything from
            // the third word to the twelfth byte is zero
            if (word2 == 0x0000) {
                for (int i = 6; i < 12; i++) {
                    if (b[i] != 0) return false;
                }
                return true;
            }
            return false;
        }
        if (word0 == 0x2002) return true;                      // 6to4, RFC 3056
        return word0 == 0x2001 && word1 == 0x0000;             // Teredo 2001::/32
    }

    private static InetAddress unwrapIpv4(InetAddress addr) {
        byte[] b = addr.getAddress();
        if (b.length != 16) return addr;
        for (int i = 0; i < 10; i++) {
            if (b[i] != 0) return addr;
        }
        boolean mapped = (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 0xFF;  // ::ffff:0:0/96
        boolean compatible = b[10] == 0 && b[11] == 0;                      // ::/96
        if (!mapped && !compatible) return addr;
        try {
            return InetAddress.getByAddress(new byte[] { b[12], b[13], b[14], b[15] });
        } catch (UnknownHostException e) {
            return addr;
        }
    }
}

Why this works:

  • One list, every call site. The validators below, the reusable UrlValidator, and the DnsResolver at the end of the page all call isBlocked, so a range added here is added everywhere. The gaps that turn up in SSRF filters are almost never in the famous ranges.
  • JDK predicates where they exist, byte tests where they do not. isSiteLocalAddress() is the one that misleads: it sounds like private IPv6 and matches only the fec0::/10 range deprecated in 2004, so fc00::/7 needs its own test. There is no JDK method at all for 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, 198.18.0.0/15, the three RFC 5737 documentation ranges, 2001:db8::/32 or 100::/64.
  • The whole of 169.254.0.0/16, not a check for 169.254.169.254. isLinkLocalAddress() covers the AWS metadata address along with the Azure and Alibaba variants and anything else on that interface.
  • Every IPv6 form that carries an IPv4 address, and there are six. The JDK normalizes the mapped form, so InetAddress.getByName("::ffff:127.0.0.1") is already an Inet4Address reporting loopback. The compatible form is not normalized: ::7f00:1 is the same address and every JDK predicate returns false for it, which is what unwrapIpv4 is for. The other four do not unwrap at all, because the IPv4 address sits in the low bits of a prefix that is a perfectly ordinary IPv6 address to the JDK - 64:ff9b::7f00:1 (NAT64), 2002:7f00:1:: (6to4) and a Teredo address all spell 127.0.0.1, and all three were allowed by this filter until 2026-08-26; ::ffff:0:7f00:1, the IPv4-translated form of RFC 6145, one zero group on from the mapped form, was allowed until 2026-09-14. Whether any of them arrives is a separate question from whether the filter should refuse it: RFC 6052 section 3.1 requires a translator to drop the well-known prefix 64:ff9b::/96 around a non-global IPv4 address, RFC 8215 lifts that restriction for the local-use prefix 64:ff9b:1::/48, and 6to4 and Teredo need a relay that mostly no longer exists. They are refused because they are spellings of a blocked address, not because each has been shown to reach it.
  • Fail closed on resolution failure. resolvesToBlocked returns true when DNS raises, so a name that cannot be checked is not fetched.

URL Allowlist Validation

// SECURE - Validate URLs against allowlist
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Set;

public class SafeImageFetcher {
    private static final Set<String> ALLOWED_HOSTS = Set.of(
        "api.example.com",
        "cdn.example.com",
        "images.example.com"
    );

    private static final Set<String> ALLOWED_SCHEMES = Set.of("https");

    // Do not use uri.toURL().openStream() here. HttpURLConnection follows
    // redirects and honours the http.proxyHost/https.proxyHost system
    // properties, and validateUrl sees neither the redirect target nor the
    // proxy - so the check can pass and the bytes come from somewhere else.
    private static final HttpClient CLIENT = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NEVER)
        .proxy(HttpClient.Builder.NO_PROXY)
        .connectTimeout(Duration.ofSeconds(5))
        .build();

    public byte[] fetchImage(String imageUrl) throws Exception {
        URI uri = validateUrl(imageUrl);

        HttpRequest request = HttpRequest.newBuilder(uri)
            .timeout(Duration.ofSeconds(10))
            .build();

        HttpResponse<byte[]> response =
            CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());

        if (response.statusCode() != 200) {
            throw new SecurityException("Unexpected status " + response.statusCode());
        }
        return response.body();
    }

    private URI validateUrl(String urlString) throws Exception {
        URI uri = new URI(urlString).parseServerAuthority();

        // Validate scheme
        if (!ALLOWED_SCHEMES.contains(uri.getScheme().toLowerCase())) {
            throw new SecurityException("Invalid URL scheme: " + uri.getScheme());
        }

        // Validate host
        String host = uri.getHost().toLowerCase();
        if (!ALLOWED_HOSTS.contains(host)) {
            throw new SecurityException("Host not allowed: " + host);
        }

        // Reject anything that resolves somewhere non-public
        if (SsrfAddressPolicy.resolvesToBlocked(host)) {
            throw new SecurityException("Host does not resolve to a public address");
        }

        return uri;
    }
}

Why this works:

  • Host allowlist: requests can only reach the names in ALLOWED_HOSTS, so internal services, databases (Redis, Memcached) and admin panels are not addressable
  • Scheme validation: Blocks file://, jar:// and other protocols accessing local resources or triggering class-loading vulnerabilities
  • Address validation delegated, not repeated: every answer for the host goes through SsrfAddressPolicy, so this validator and the connection-time check later on the page cannot disagree about what counts as internal
  • followRedirects(NEVER): validateUrl never sees a Location header. java.net.http.HttpClient defaults to NEVER already, unlike HttpURLConnection and Apache HttpClient, and setting it explicitly says the choice was made rather than inherited
  • NO_PROXY: without it the client uses the JVM's default proxy selector, which reads http.proxyHost/https.proxyHost. A proxied request resolves the target at the proxy, so the addresses just validated are not the ones reached
  • Fail-closed: DNS exceptions block the request
  • What this client cannot do: pin the connection to the addresses just checked. java.net.http.HttpClient has no DNS or socket hook - see Validating the Address Actually Connected To - so if DNS rebinding is in scope, this pattern needs Apache HttpClient 5 or an egress control outside the process
  • Cloud metadata protection: the metadata address is link-local, which resolvesToBlocked rejects, and no metadata host is in ALLOWED_HOSTS - so AWS IMDSv1/v2, GCP and Azure endpoints fail both checks

Apache HttpClient with Validation

// SECURE - HttpClient with URL validation, pinned resolution, redirects disabled
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import java.net.URI;
import java.util.regex.Pattern;

public class SecureWebhookHandler {
    private static final Pattern ALLOWED_URL_PATTERN =
        Pattern.compile("^https://([a-z0-9-]+\\.)*example\\.com/.*$");

    // One reused client. Two settings on it, and neither is optional:
    //  - setDnsResolver: the client resolves through ValidatingDnsResolver
    //    (defined under "Validating the Address Actually Connected To" below),
    //    so the addresses that were checked are the ones connected to. Without
    //    it, validateWebhookUrl's lookup and the client's lookup are two
    //    different lookups and DNS can answer them differently.
    //  - disableRedirectHandling: validateWebhookUrl checked this URI, and
    //    nothing checks the target of a 3xx the client would follow on its own.
    private static final CloseableHttpClient CLIENT = HttpClients.custom()
        .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
            .setDnsResolver(new ValidatingDnsResolver())
            .build())
        .disableRedirectHandling()
        .build();

    public void sendWebhook(String webhookUrl, String data) throws Exception {
        // Validate URL
        URI uri = validateWebhookUrl(webhookUrl);

        HttpGet request = new HttpGet(uri);
        int status = CLIENT.execute(request, response -> response.getCode());
        if (status >= 300 && status < 400) {
            throw new SecurityException("Webhook responded with a redirect, which is not followed");
        }
    }

    private URI validateWebhookUrl(String url) throws SecurityException {
        if (url == null || url.isEmpty()) {
            throw new SecurityException("URL cannot be empty");
        }

        // Check against allowlist pattern
        if (!ALLOWED_URL_PATTERN.matcher(url).matches()) {
            throw new SecurityException("URL not allowed: " + url);
        }

        try {
            URI uri = new URI(url);

            // Additional validation
            if (!"https".equals(uri.getScheme())) {
                throw new SecurityException("Only HTTPS allowed");
            }

            // Block anything that resolves somewhere non-public
            if (SsrfAddressPolicy.resolvesToBlocked(uri.getHost())) {
                throw new SecurityException("Host does not resolve to a public address");
            }

            return uri;
        } catch (Exception e) {
            throw new SecurityException("Invalid URL: " + e.getMessage());
        }
    }
}

Why this works:

  • Strict domain matching: Regex pattern (^https://([a-z0-9-]+\.)*example\.com/.*$) with subdomain constraints prevents lookalike domains (example-com.attacker.net) or path bypasses
  • HTTPS-only: Prevents downgrade attacks and protects data in transit
  • One shared range list: SsrfAddressPolicy.resolvesToBlocked checks every A/AAAA answer against the ranges defined once at the top of this section
  • The pre-request check and the connection agree: the DnsResolver on the connection manager means the client does not perform a second, unvalidated lookup. validateWebhookUrl still earns its place - it rejects a bad host before a socket is opened and it is where the allowlist lives - but on its own it only establishes that the answers were acceptable at the moment it asked
  • Internal access prevention: an internal API, a database, or anything bound to a private interface fails either the example.com pattern or the address check, and usually both
  • Redirect blocking: Apache HttpClient follows redirects by default and re-checks none of them against ALLOWED_URL_PATTERN, so an allowlisted host answering 302 Location: http://169.254.169.254/ would be followed. disableRedirectHandling() on the builder stops that, and the 3xx surfaces as a response to handle rather than a fetch that already happened. RequestConfig.custom().setRedirectsEnabled(false) is the equivalent per-request form
  • Redirects are still a decision, not a dead end: where following them is a requirement, loop manually - re-run validateWebhookUrl on each Location before issuing the next request, and cap the hop count
  • Exception handling: the catch turns parsing and DNS failures into one SecurityException at the boundary - but it appends e.getMessage(), so the underlying detail travels with it. Log that and return a generic message if the exception can reach a caller

Spring RestTemplate with Validation

// SECURE - Spring RestTemplate with allowlist, pinned resolution, no redirects
// Needs org.apache.httpcomponents.client5:httpclient5 on the classpath
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class SecureProxyController {
    // A plain new RestTemplate() follows redirects, and nothing re-runs
    // validateUrl on where they lead. It also resolves the hostname itself when
    // it connects, so the addresses validateUrl checked are not necessarily the
    // ones reached - ValidatingDnsResolver (below) is what closes that.
    private final RestTemplate restTemplate = new RestTemplate(
        new HttpComponentsClientHttpRequestFactory(
            HttpClients.custom()
                .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                    .setDnsResolver(new ValidatingDnsResolver())
                    .build())
                .disableRedirectHandling()
                .build()));
    private static final Set<String> ALLOWED_DOMAINS = Set.of(
        "api.example.com",
        "public-api.example.org"
    );

    @GetMapping("/proxy")
    public String proxy(@RequestParam String url) {
        URI validatedUri = validateUrl(url);

        try {
            return restTemplate.getForObject(validatedUri, String.class);
        } catch (Exception e) {
            throw new ResponseStatusException(
                HttpStatus.BAD_REQUEST, 
                "Failed to fetch resource"
            );
        }
    }

    private URI validateUrl(String url) {
        try {
            // parseServerAuthority() throws for a host the JDK cannot parse as a
            // server name - 0x7f.0x0.0x0.0x1, 127.1 - where getHost() would be null
            URI uri = new URI(url).parseServerAuthority();

            // Only allow HTTPS
            if (!"https".equals(uri.getScheme())) {
                throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, 
                    "Only HTTPS URLs allowed"
                );
            }

            // Check domain allowlist
            String host = uri.getHost().toLowerCase();
            if (!ALLOWED_DOMAINS.contains(host)) {
                throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, 
                    "Domain not allowed"
                );
            }

            // Prevent DNS rebinding attacks
            if (SsrfAddressPolicy.resolvesToBlocked(host)) {
                throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST,
                    "Host does not resolve to a public address"
                );
            }

            return uri;
        } catch (URISyntaxException e) {
            // resolvesToBlocked handles UnknownHostException itself, so the parse
            // is the only thing here that throws - and javac rejects a catch for
            // a checked exception the try block cannot raise
            throw new ResponseStatusException(
                HttpStatus.BAD_REQUEST, 
                "Invalid URL"
            );
        }
    }

}

Why this works:

  • Clean error responses: every rejection is a 400 rather than a 500 or a hang. The three reasons are distinct strings (Only HTTPS URLs allowed, Domain not allowed, Host does not resolve to a public address), which is what you want in a log and an oracle if returned to the caller - collapse them to one generic message at the boundary
  • Case normalization: ALLOWED_DOMAINS with .toLowerCase() prevents case-variation bypasses (Example.COM vs example.com)
  • One shared range list: the ranges are in SsrfAddressPolicy, not repeated here, so this controller rejects fd00::/8, 100.64.0.0/10 and 192.0.0.0/24 for the same reason the rest of the page does
  • One lookup, not two: validateUrl rejects a host whose current answers are unacceptable, and the DnsResolver on the connection manager makes the client resolve through the same check when it opens the connection. Keeping only the first is the pattern in Common Pitfalls: it holds until DNS answers differently a moment later
  • Redirect blocking: RestTemplate follows redirects by default, and validateUrl never sees the target of one. Constructing it over a HttpComponentsClientHttpRequestFactory whose client has disableRedirectHandling() closes that: a 3xx from an allowlisted host surfaces as a response instead of a second, unchecked fetch
  • Generic error messages: "Failed to fetch resource" prevents probing internal network topology
  • Logging separation: Spring's exception hierarchy (@ExceptionHandler) logs detailed errors server-side while returning sanitized client messages

URL Validation Utility Class

// SECURE - Reusable URL validator
import java.net.*;
import java.util.*;

public class UrlValidator {
    private final Set<String> allowedSchemes;
    private final Set<String> allowedHosts;
    private final boolean blockPrivateIps;

    public UrlValidator(Set<String> allowedSchemes, 
                        Set<String> allowedHosts, 
                        boolean blockPrivateIps) {
        this.allowedSchemes = allowedSchemes;
        this.allowedHosts = allowedHosts;
        this.blockPrivateIps = blockPrivateIps;
    }

    public URI validate(String urlString) throws SecurityException {
        try {
            URI uri = new URI(urlString).parseServerAuthority();

            // Validate scheme
            if (!allowedSchemes.contains(uri.getScheme().toLowerCase())) {
                throw new SecurityException(
                    "Scheme not allowed: " + uri.getScheme()
                );
            }

            String host = uri.getHost().toLowerCase();

            // Validate host against allowlist
            if (!isHostAllowed(host)) {
                throw new SecurityException("Host not allowed: " + host);
            }

            // Block anything that resolves somewhere non-public
            if (blockPrivateIps && SsrfAddressPolicy.resolvesToBlocked(host)) {
                throw new SecurityException(
                    "Host does not resolve to a public address"
                );
            }

            // Block localhost variants
            if (isLocalhost(host)) {
                throw new SecurityException("Localhost not allowed");
            }

            return uri;

        } catch (Exception e) {
            throw new SecurityException("Invalid URL: " + e.getMessage());
        }
    }

    private boolean isHostAllowed(String host) {
        // Exact match
        if (allowedHosts.contains(host)) {
            return true;
        }

        // Check wildcard subdomains (e.g., *.example.com)
        for (String allowedHost : allowedHosts) {
            if (allowedHost.startsWith("*.") && 
                host.endsWith(allowedHost.substring(1))) {
                return true;
            }
        }

        return false;
    }

    private boolean isLocalhost(String host) {
        return host.equals("localhost") ||
               host.equals("127.0.0.1") ||
               host.equals("::1") ||
               host.equals("0.0.0.0");
    }
}

// Usage:
UrlValidator validator = new UrlValidator(
    Set.of("https"),
    Set.of("api.example.com", "*.cdn.example.com"),
    true
);

URI safeUrl = validator.validate(userInput);

Why this works:

  • Encapsulation: scheme allowlist, host allowlist with wildcard subdomain support (*.cdn.example.com) and the localhost check live in one configurable class, so a call site passes policy rather than re-implementing the checks
  • Address checking delegated: the ranges live in SsrfAddressPolicy, so this class holds allowlist and parsing policy only. That is the division worth keeping - allowlists differ per application, the set of non-public addresses does not
  • Immutable configuration: Constructor with Set.of() prevents post-initialization modification
  • One place to review and to change: the validation policy sits in this class rather than scattered across service methods, and the same instance serves whichever client performs the request (HttpClient, OkHttp, RestTemplate)

URL Parser Bypass Prevention

Validation code and the client that makes the request can parse the same URL differently, and the bypasses live in that gap:

Example bypass techniques:

  • http://127.0.0.1@evil.com (userinfo confusion when validation and request layers parse differently)
  • http://[::ffff:127.0.0.1]/ (IPv6 notation for IPv4)
  • http://0x7f.0x0.0x0.0x1/ (Hex encoding)
  • http://2130706433/ (Decimal IP notation for 127.0.0.1)
  • http://localhost%00.evil.com/ (null byte injection)
  • http://evil.com#@127.0.0.1/ (fragment abuse)

Safe URL parsing:

// Java - parse with URI and validate addresses
import java.net.URI;
import java.util.Set;

public class SafeUrlParser {
    private static final Set<String> ALLOWED_DOMAINS = Set.of(
        "api.example.com",
        "cdn.example.com"
    );

    public URI parseAndValidate(String urlString) throws SecurityException {
        try {
            URI uri = new URI(urlString).parseServerAuthority();
            String host = uri.getHost().toLowerCase();

            // Validate against allowlist
            if (!ALLOWED_DOMAINS.contains(host)) {
                throw new SecurityException("Domain not allowed");
            }

            // Reject a host that resolves somewhere non-public. Run this for
            // names as well as literals: a check gated on "is the host an IP
            // address" skips the name that resolves to 127.0.0.1, which is the
            // more common shape
            if (SsrfAddressPolicy.resolvesToBlocked(host)) {
                throw new SecurityException("Host does not resolve to a public address");
            }

            // Validate scheme
            if (!"https".equals(uri.getScheme())) {
                throw new SecurityException("Invalid protocol");
            }

            return uri;
        } catch (Exception e) {
            throw new SecurityException("Invalid URL", e);
        }
    }
}

Why this works:

  • Parser discrepancy exploitation: Attackers abuse userinfo, IPv6-wrapped IPv4 (http://[::ffff:127.0.0.1]/), hex (0x7f.0x0.0x1), decimal IP (2130706433 for 127.0.0.1), and other forms when validation and request code do not use the same normalized destination
  • Resolution decides, not syntax: the alternate notations above are dangerous because the resolver accepts them, not because they look unusual, so the control is SsrfAddressPolicy.resolvesToBlocked - resolve the host, test every answer. An IP-syntax validator is the wrong tool and gets this backwards. Measured on commons-validator 1.11.0 with JDK 26, InetAddressValidator.isValid() returns false for 2130706433 and for 127.1 while InetAddress.getByName() turns both into 127.0.0.1, and false for 0177.0.0.1 where the JDK resolves 177.0.0.1 - a different host again, in the other direction. URI.getHost() hands back [::ffff:127.0.0.1] with the brackets still attached, which the validator also rejects. Treating "not a valid IP literal" as "safe to skip the address check" lets every one of those through, which is why this page routes the decision through resolution instead
  • Pre-resolution validation: Validates parsed host string against domain allowlist before resolving, preventing null byte injection (localhost%00.evil.com), fragment abuse (evil.com#@127.0.0.1)
  • Protocol restriction: Strict validation blocks jar://, file:// and non-HTTP schemes bypassing hostname checks
  • Layered defense: allowlist → resolve the host and test every answer → protocol check. Each layer refuses something different, and none of them depends on recognising an alternate URL representation by its shape

Framework-Specific Guidance

Spring Boot with WebClient

// SECURE - Spring WebClient with validation
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.net.URI;

@Service
public class SafeApiService {
    private static final String BASE_URL = "https://api.example.com";

    private final WebClient webClient;
    private final UrlValidator urlValidator;

    public SafeApiService() {
        // Reactor Netty does not follow redirects unless followRedirect(true)
        // is set, so the base URL constraint is not quietly escapable here
        this.webClient = WebClient.builder()
            .baseUrl(BASE_URL)
            .build();

        this.urlValidator = new UrlValidator(
            Set.of("https"),
            Set.of("api.example.com"),
            true
        );
    }

    public Mono<String> fetchData(String endpoint) {
        try {
            // Resolve the way WebClient will, then validate the result. String
            // concatenation would not: "https://api.example.com" + "//evil.com/x"
            // has host api.example.com and passes, while .uri("//evil.com/x")
            // resolves against the base as protocol-relative and requests evil.com
            URI target = URI.create(BASE_URL).resolve(endpoint);
            urlValidator.validate(target.toString());

            return webClient.get()
                .uri(target)
                .retrieve()
                .bodyToMono(String.class);
        } catch (SecurityException | IllegalArgumentException e) {
            return Mono.error(new SecurityException("Endpoint not allowed"));
        }
    }
}

What this does not close: Reactor Netty resolves the hostname itself when it connects, so urlValidator's lookup and the connection's lookup are two lookups - the gap Validating the Address Actually Connected To describes. With a fixed baseUrl and an exact-host allowlist, the only name that can be rebound is one you operate, which is what makes the shape acceptable for a first-party API. Where that is not enough, build the WebClient on a ClientHttpConnector backed by Apache HttpClient 5 - its async connection manager builder takes the same setDnsResolver(new ValidatingDnsResolver()) - or enforce the destination at an egress proxy.

JAX-RS (Jersey)

// SECURE - JAX-RS with URL validation
import jakarta.ws.rs.*;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.core.Response;
import org.glassfish.jersey.client.ClientProperties;
import java.net.URI;

@Path("/api")
public class SecureProxyResource {
    // Jersey's FOLLOW_REDIRECTS defaults to true, and a followed redirect is
    // never passed back through urlValidator
    private final Client client = ClientBuilder.newClient()
        .property(ClientProperties.FOLLOW_REDIRECTS, false);
    private final UrlValidator urlValidator;

    public SecureProxyResource() {
        this.urlValidator = new UrlValidator(
            Set.of("https"),
            Set.of("external-api.example.com"),
            true
        );
    }

    @GET
    @Path("/fetch")
    public Response fetchResource(@QueryParam("url") String url) {
        try {
            URI validatedUri = urlValidator.validate(url);

            Response response = client.target(validatedUri)
                .request()
                .get();

            if (response.getStatusInfo().getFamily() == Response.Status.Family.REDIRECTION) {
                return Response.status(Response.Status.BAD_GATEWAY)
                    .entity("Upstream redirected; target not validated")
                    .build();
            }

            return Response.ok(response.readEntity(String.class)).build();

        } catch (SecurityException e) {
            return Response.status(Response.Status.BAD_REQUEST)
                .entity("Invalid URL")
                .build();
        }
    }
}

What this does not close: Jersey's default connector is HttpURLConnection, which resolves the name again when it connects and uses the JVM's default proxy selector - the same two behaviours as the openStream() pitfall below. Where rebinding is in scope, register Apache5ConnectorProvider (module jersey-apache5-connector) with a connection manager that carries ValidatingDnsResolver, so the client's own lookup is the validated one, or put the destination control on an egress proxy.

Protecting Cloud Metadata Endpoints

// SECURE - Block AWS/Azure/GCP metadata endpoints
public class MetadataProtection {
    // One entry for 169.254.169.254, which AWS and Azure share: Set.of() throws
    // IllegalArgumentException on a duplicate element, and in a static
    // initializer that surfaces as ExceptionInInitializerError the first time
    // the class is touched
    private static final Set<String> BLOCKED_HOSTS = Set.of(
        "169.254.169.254",          // AWS and Azure metadata
        "metadata.google.internal", // GCP metadata
        "metadata"                  // GCP short name
    );

    private static final Set<String> BLOCKED_PATHS = Set.of(
        "/latest/meta-data",
        "/latest/user-data",
        "/latest/dynamic",
        "/computeMetadata/v1"
    );

    public void validateNotMetadata(String url) throws SecurityException {
        try {
            URI parsedUrl = new URI(url).parseServerAuthority();
            String host = parsedUrl.getHost().toLowerCase();
            String path = parsedUrl.getPath();

            // Block metadata service IPs/hostnames
            if (BLOCKED_HOSTS.contains(host)) {
                throw new SecurityException("Access to metadata service blocked");
            }

            // Block metadata paths
            for (String blockedPath : BLOCKED_PATHS) {
                if (path.startsWith(blockedPath)) {
                    throw new SecurityException("Access to metadata endpoint blocked");
                }
            }

            // 169.254.169.254 is inside link-local, which SsrfAddressPolicy
            // already rejects along with everything else non-public. The host
            // and path lists above are what this class adds: they name the
            // metadata service explicitly, so the refusal does not depend on
            // what the resolver returns for metadata.google.internal
            if (SsrfAddressPolicy.resolvesToBlocked(host)) {
                throw new SecurityException("Host does not resolve to a public address");
            }

        } catch (Exception e) {
            throw new SecurityException("URL validation failed: " + e.getMessage());
        }
    }
}

Validating the Address Actually Connected To

Everything above validates before the request, and the client then resolves the hostname again when it opens the connection. Closing that race means giving the client the addresses rather than letting it look them up - and in Java the first question is which client you are using, because they do not all allow it.

java.net.http.HttpClient cannot do this. Its builder exposes no DNS, socket or connection hook - on JDK 26 the full set is authenticator, build, connectTimeout, cookieHandler, executor, followRedirects, localAddress, priority, proxy, sslContext, sslParameters and version. There is no equivalent to Go's DialContext, .NET's ConnectCallback or Node's lookup. If rebinding is in your threat model, the options are to move to a client that has the hook, or to place the control outside the process entirely with an egress proxy or firewall rule. Advice to "pin the connection" cannot be followed with this client, so do not plan around it.

Apache HttpClient 5 does have the hook, as a DnsResolver on the connection manager:

// SECURE - the client resolves through code that validates
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;

import java.net.InetAddress;
import java.net.UnknownHostException;

public final class ValidatingDnsResolver implements DnsResolver {

    @Override
    public InetAddress[] resolve(String host) throws UnknownHostException {
        InetAddress[] addresses = InetAddress.getAllByName(host);

        // Every answer must pass, not just the one that happens to be used.
        // SsrfAddressPolicy.isBlocked is the predicate from the top of Secure
        // Patterns - the ranges the JDK has no method for (fc00::/7,
        // 100.64.0.0/10) are exactly the ones a check written here would miss
        for (InetAddress address : addresses) {
            if (SsrfAddressPolicy.isBlocked(address)) {
                throw new UnknownHostException(
                    "Blocked non-global address " + address.getHostAddress() + " for " + host);
            }
        }
        return addresses;
    }

    @Override
    public String resolveCanonicalHostname(String host) throws UnknownHostException {
        return InetAddress.getByName(host).getCanonicalHostName();
    }
}

// Wiring
HttpClientConnectionManager connectionManager =
    PoolingHttpClientConnectionManagerBuilder.create()
        .setDnsResolver(new ValidatingDnsResolver())
        .build();

CloseableHttpClient client = HttpClients.custom()
    .setConnectionManager(connectionManager)
    .disableRedirectHandling()
    .build();

Why this works:

  • The client's own lookup is the validated one. There is no second resolution to disagree with the first, which is what separates this from calling InetAddress.getByName() beforehand.
  • It applies to every connection the client opens, so it holds for connection reuse and for any redirect you choose to allow.
  • Rejecting by throwing UnknownHostException keeps the failure on the path the client already handles, rather than surfacing as an unexpected type. It is also the only safe way to reject: never return null. HttpClient 5.5 added a default resolve(String host, int port) whose implementation maps resolve(host) onto InetSocketAddress, and when resolve(host) returns null it hands back InetSocketAddress.createUnresolved(host, port) - an unresolved address the socket layer then resolves itself, at connect time, with no validation. A resolver that returns null for a bad host silently reinstates the bug it was written to fix.
  • Overriding resolve(String) is enough on 5.5+. That default resolve(host, port) delegates to it, so the two-argument form does not need a separate implementation.

The predicate is deliberately not rewritten here. A resolver like this is where an abbreviated range check does the most damage, because it is the last thing to look at the address before the socket opens - and isSiteLocalAddress() alone misses carrier-grade NAT (100.64.0.0/10), unique-local IPv6 (fc00::/7), the IPv4-compatible form (::7f00:1), and the NAT64, 6to4, Teredo and IPv4-translated prefixes that carry an IPv4 address without ever looking like one. Calling SsrfAddressPolicy.isBlocked keeps one list to maintain rather than two that drift.

DnsResolver has been on this interface since 5.0, so the wiring above applies to any Apache HttpClient 5.x.

Common Pitfalls

  • Checking InetAddress.isSiteLocalAddress() alone to reject private IPs - this only covers RFC 1918 and the deprecated fec0::/10, and misses isLinkLocalAddress() (169.254.0.0/16, which includes the cloud metadata address 169.254.169.254), isLoopbackAddress(), unique-local IPv6 (fc00::/7, where IPv6 private networks actually are), carrier-grade NAT (100.64.0.0/10), the benchmarking range (198.18.0.0/15), the IPv4-compatible form ::7f00:1, and the prefixes that carry an IPv4 address inside an ordinary-looking IPv6 one - 64:ff9b::/96 and 64:ff9b:1::/48 (NAT64), 2002::/16 (6to4), 2001::/32 (Teredo), ::ffff:0:0:0/96 (IPv4-translated), where 64:ff9b:1::a9fe:a9fe is the metadata address as a compliant NAT64 can deliver it (RFC 8215 local-use prefix; RFC 6052 section 3.1 has the well-known-prefix form dropped). Each needs its own check, which is why SsrfAddressPolicy above is one class rather than a condition copied per call site.
  • Validating and resolving the host with InetAddress.getByName() before the request, then making the actual call through RestTemplate/HttpClient/OkHttp, which resolves the hostname again independently when it opens the connection - the two lookups can disagree, and the JVM's DNS cache TTL (networkaddress.cache.ttl) interacts with this differently than the client's own resolution does. See Validating the Address Actually Connected To, and note that java.net.http.HttpClient has no hook for this at all.
  • Relying on HttpURLConnection's or RestTemplate's default redirect-following (setInstanceFollowRedirects defaults to true) after validating only the original request URL, so a validated URL that responds with a redirect to an internal address is followed without the new destination being checked.
  • Fetching a validated URI with uri.toURL().openStream(). It is the shortest way to read a URL in Java and it opts into two behaviours the validation cannot see: it follows redirects, and it uses the JVM's default proxy selector, which reads http.proxyHost/https.proxyHost. Both move the fetch to a destination the allowlist never examined - a 302 to 169.254.169.254 is followed, and with a proxy configured the hostname is resolved at the proxy instead. There is also no way to set a read timeout on the stream. Use java.net.http.HttpClient with followRedirects(NEVER) and proxy(NO_PROXY), or Apache HttpClient 5 where connection pinning is also needed.

Additional Resources