Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix SSRF in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Attach the policy to the connection, not the hostname string. Allow only http/https and ports 80/443, reject credentials; resolve the host (or read an IP literal in any notation) and reject loopback, private, link-local and IPv4-mapped addresses; connect a socket to the vetted IP; re-validate every redirect; cap time and bytes.

Stack
  • Language Java 21
  • Runtime OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
  • Framework Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 30 KB
On this page

Steps

  1. Find every server-side fetch built from request data (@RequestParam, @PathVariable, body): java.net.http.HttpClient, URL.openConnection, RestClient/WebClient and raw Socket.
  2. Parse the URL once with java.net.URI. Enforce a scheme allowlist (http/https) and a port allowlist (80/443) and reject userinfo. URI returns a null host for some encodings (a hex IPv4 literal), so fall back to the authority and still vet it.
  3. Resolve the host with InetAddress.getAllByName, or recognise an IP literal, and reject every address that is not globally routable (loopback, 10/8, 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10, IPv4-mapped and NAT64). Reject the whole set if any answer is internal.
  4. Connect to the vetted InetAddress and send the original host only in the Host header, so the name is never re-resolved (no DNS rebinding).
  5. Handle redirects yourself: follow HttpClient.Redirect.NEVER (or a raw socket, which does not follow) and re-run the policy on each Location with a hop limit.
  6. Set connect and read timeouts and read the body with a byte cap; log the parsed destination and reason server-side via ProblemDetail, never the database or SSRF detail to the caller.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/ssrf/impl/VulnerableUrlFetcher.javaJava
// No validation at all: parse the URL and hand the host to the resolver.
private static String[] target(String rawUrl) {
    Urls.Parsed u = Urls.parse(rawUrl);
    int port = u.port() == -1 ? ("https".equals(u.scheme()) ? 443 : 80) : u.port();
    String hostHeader = u.port() == -1 ? u.host() : u.host() + ":" + u.port();
    return new String[] {u.host(), String.valueOf(port), hostHeader, u.path(), u.origin()};
}

With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).

The fix: validate the destination

Fixed…/ssrf/impl/FixedUrlFetcher.javaJava
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
private static final Set<Integer> ALLOWED_PORTS = Set.of(80, 443);

// Private, loopback, link-local and other non-public ranges in one table, so the
// policy is reviewable in one place.
private record Cidr(byte[] prefix, int len) {}

private static final List<Cidr> DENY_V4 = parseCidrs(
    "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
    "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16",
    "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4");

private record Target(String ip, int port, String hostHeader, String path, String origin) {}

// inet_aton-style parse so decimal (2130706433), octal (0177.0.0.1) and hex
// (0x7f.0.0.1) literals are recognised as addresses rather than trusted as names.
private static byte[] parseIpLiteral(String host) {
    String inner = host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host;
    if (inner.indexOf(':') >= 0) {
        try {
            return InetAddress.getByName(inner).getAddress(); // IPv6 literal: no DNS
        } catch (Exception e) {
            return null;
        }
    }
    if (!inner.equals(host)) {
        return null; // bracketed but not IPv6
    }
    String[] parts = host.split("\\.", -1);
    if (parts.length < 1 || parts.length > 4) {
        return null;
    }
    long[] nums = new long[parts.length];
    for (int i = 0; i < parts.length; i++) {
        String p = parts[i];
        try {
            if (p.matches("0[xX][0-9a-fA-F]+")) {
                nums[i] = Long.parseLong(p.substring(2), 16);
            } else if (p.matches("0[0-7]*")) {
                nums[i] = p.equals("0") ? 0 : Long.parseLong(p, 8);
            } else if (p.matches("[1-9][0-9]*")) {
                nums[i] = Long.parseLong(p);
            } else {
                return null;
            }
        } catch (NumberFormatException e) {
            return null;
        }
    }
    long[] caps = {0xffffffffL, 0xffffffL, 0xffffL, 0xffL};
    int last = nums.length - 1;
    if (nums[last] > caps[last]) {
        return null;
    }
    for (int i = 0; i < last; i++) {
        if (nums[i] > 0xff) {
            return null;
        }
    }
    long value = switch (nums.length) {
        case 1 -> nums[0];
        case 2 -> (nums[0] << 24) | nums[1];
        case 3 -> (nums[0] << 24) | (nums[1] << 16) | nums[2];
        default -> (nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3];
    };
    return new byte[] {(byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value};
}

private static boolean inRange(byte[] addr, byte[] prefix, int prefixLen) {
    for (int bit = 0; bit < prefixLen; bit++) {
        int idx = bit >> 3;
        int mask = 1 << (7 - (bit & 7));
        if ((addr[idx] & mask) != (prefix[idx] & mask)) {
            return false;
        }
    }
    return true;
}

// Reject anything that is not a public address. IPv4-mapped IPv6 literals already
// arrive as 4-byte addresses; NAT64 and 6to4 carry an embedded IPv4 that is unwrapped.
private static void assertPublic(byte[] original) {
    byte[] addr = original;
    if (addr.length == 16) {
        if (inRange(addr, literal("64:ff9b::"), 96)) {
            addr = new byte[] {addr[12], addr[13], addr[14], addr[15]};
        } else if (inRange(addr, literal("2002::"), 16)) {
            addr = new byte[] {addr[2], addr[3], addr[4], addr[5]};
        } else {
            boolean globalUnicast = inRange(addr, literal("2000::"), 3)
                    && !inRange(addr, literal("2001:db8::"), 32);
            if (!globalUnicast) {
                throw new BlockedRequestException("non-public IPv6 address");
            }
            return;
        }
    }
    for (Cidr cidr : DENY_V4) {
        if (inRange(addr, cidr.prefix(), cidr.len())) {
            throw new BlockedRequestException("non-public IPv4 address");
        }
    }
}

// Parse the URL, enforce the scheme and port allowlists, turn the host into a vetted
// address, and reject (not resolve) IP literals that are not public.
private Target vet(String rawUrl) throws IOException {
    Urls.Parsed u = Urls.parse(rawUrl);
    if (u.scheme() == null || !ALLOWED_SCHEMES.contains(u.scheme())) {
        throw new BlockedRequestException("scheme not allowed");
    }
    if (u.userInfo() != null) {
        throw new BlockedRequestException("credentials in URL not allowed");
    }
    if (u.host() == null) {
        throw new BlockedRequestException("missing host");
    }
    int port = u.port() == -1 ? ("https".equals(u.scheme()) ? 443 : 80) : u.port();
    if (!ALLOWED_PORTS.contains(port)) {
        throw new BlockedRequestException("port not allowed");
    }

    byte[] literal = parseIpLiteral(u.host());
    List<String> addresses = new ArrayList<>();
    if (literal != null) {
        assertPublic(literal);
        addresses.add(ipString(literal, u.host()));
    } else {
        addresses.addAll(resolver.resolve(u.host()));
        if (addresses.isEmpty()) {
            throw new BlockedRequestException("host did not resolve");
        }
        for (String ip : addresses) {
            assertPublic(InetAddress.getByName(bracketless(ip)).getAddress()); // whole set must be public
        }
    }
    String hostHeader = u.port() == -1 ? u.host() : u.host() + ":" + u.port();
    return new Target(addresses.get(0), port, hostHeader, u.path(), u.origin());
}

private static byte[] literal(String ip) {
    try {
        return InetAddress.getByName(ip).getAddress();
    } catch (Exception e) {
        throw new IllegalStateException(e);
    }
}

private static List<Cidr> parseCidrs(String... cidrs) {
    List<Cidr> out = new ArrayList<>();
    for (String cidr : cidrs) {
        String[] parts = cidr.split("/");
        out.add(new Cidr(literal(parts[0]), Integer.parseInt(parts[1])));
    }
    return out;
}

private static String bracketless(String host) {
    return host.startsWith("[") && host.endsWith("]") ? host.substring(1, host.length() - 1) : host;
}

private static String ipString(byte[] addr, String original) {
    try {
        return InetAddress.getByAddress(bracketless(original), addr).getHostAddress();
    } catch (Exception e) {
        return bracketless(original);
    }
}

With IMPL=fixed, 15 of 15 tests pass.

The fix: re-validate every redirect

Fixed…/ssrf/impl/FixedUrlFetcher.javaJava
// A 3xx response is a fresh destination decision, not permission to follow. Re-run
// the full policy on every hop and pin each connection to its own vetted address.
@Override
public FetchResult fetch(String url) throws IOException {
    String target = url;
    for (int hop = 0; hop <= HttpOverSocket.MAX_REDIRECTS; hop++) {
        Target t = vet(target);
        HttpOverSocket.RawResponse response = HttpOverSocket.sendOnce(
                dialer, t.ip(), t.port(), t.hostHeader(), t.path(),
                HttpOverSocket.MAX_RESPONSE_BYTES, HttpOverSocket.TIMEOUT_MS);
        if (response.status() >= 300 && response.status() < 400 && response.location() != null) {
            target = URI.create(t.origin() + t.path()).resolve(response.location()).toString(); // relative to this hop's URL; validated again
            continue;
        }
        return HttpOverSocket.summarise(response);
    }
    throw new BlockedRequestException("too many redirects");
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fix…/ssrf/impl/WrongUrlFetcher.javaJava
// A substring blocklist of "obviously internal" hosts. Looks reassuring; the address
// an attacker actually reaches is decided by DNS, which this never consults.
private static final String[] BLOCKED_SUBSTRINGS = {
    "localhost", "127.0.0.1", "0.0.0.0", "169.254", "10.", "192.168", "172.16", "::1", "metadata", "internal",
};

private static void screen(String host) {
    String lower = host == null ? "" : host.toLowerCase();
    for (String bad : BLOCKED_SUBSTRINGS) {
        if (lower.contains(bad)) {
            throw new BlockedRequestException("host is blocked");
        }
    }
}

private static String[] target(String rawUrl) {
    Urls.Parsed u = Urls.parse(rawUrl);
    int port = u.port() == -1 ? ("https".equals(u.scheme()) ? 443 : 80) : u.port();
    String hostHeader = u.port() == -1 ? u.host() : u.host() + ":" + u.port();
    return new String[] {u.host(), String.valueOf(port), hostHeader, u.path(), u.origin()};
}

With IMPL=wrong, 7 of 15 tests fail (5 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Test…/example/ssrf/FetchApiTest.javaJava
// Exploit: each payload names an internal destination a different way. The secure
// outcome is the same every time: 400 and no connection to the internal service.

@Test
void exploitLoopbackIpv4Literal() throws Exception {
    assertBlocked("http://127.0.0.1/latest/meta-data/");
}

@Test
void exploitLoopbackDecimalInteger() throws Exception {
    assertBlocked("http://2130706433/");
}

@Test
void exploitLoopbackOctal() throws Exception {
    assertBlocked("http://0177.0.0.1/");
}

@Test
void exploitLoopbackHex() throws Exception {
    assertBlocked("http://0x7f.0.0.1/");
}

@Test
void exploitIpv6LoopbackLiteral() throws Exception {
    assertBlocked("http://[::1]/");
}

@Test
void exploitIpv4MappedIpv6Loopback() throws Exception {
    assertBlocked("http://[::ffff:127.0.0.1]/");
}

@Test
void exploitCloudMetadataAddressByHostname() throws Exception {
    assertBlocked("http://metadata.internal.example/latest/meta-data/");
}

@Test
void exploitPrivateAddressByHostname() throws Exception {
    assertBlocked("http://db.internal.example/");
}

@Test
void exploitNameResolvingToLoopback() throws Exception {
    assertBlocked("http://rebind.example/");
}

@Test
void exploitRedirectFromAllowedHostToInternal() throws Exception {
    assertBlocked("http://files.partner.example.test/redirect-internal");
}

Pitfalls

  • java.net.URI returns null from getHost() for a hex IPv4 literal such as 0x7f.0.0.1 and for some odd authorities; do not treat null as safe, parse the authority and vet it.
  • InetAddress converts an IPv4-mapped literal (::ffff:127.0.0.1) to an Inet4Address, but NAT64 (64:ff9b::/96) and 6to4 (2002::/16) stay IPv6 with an embedded IPv4; unwrap before classifying.
  • isSiteLocalAddress covers 10/8, 172.16/12 and 192.168/16 but not 169.254/16 (link-local), carrier-grade NAT or 0.0.0.0/8; build an explicit range table rather than trusting one helper.
  • java.net.http.HttpClient follows redirects when set to NORMAL/ALWAYS and re-resolves the host; use NEVER and re-validate, and connect to the vetted address.
  • InetAddress.getAllByName returns several addresses; vet all of them, not just the one a connection would pick first.
  • A permitted host can still stream forever; cap the bytes you read and set timeouts.
  • Spring Boot 4 / Spring Framework 7 test support needs JUnit Jupiter 6 (the SpringExtension requires 6.0 or later), so this card uses the Boot-managed JUnit 6.0.3.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs java-spring does this for you).
  2. Each exploit asserts the request is refused with 400 and the internal test server records zero connection attempts, for 127.0.0.1, decimal/octal/hex, [::1], [::ffff:127.0.0.1], a name resolving internal, and a redirect to an internal host.
  3. Against your own endpoint, request http://169.254.169.254/latest/meta-data/ and expect a refusal, and confirm a 302 to an internal host is not followed.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix SSRF in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.spring.security.injection.tainted-url-host.tainted-url-hostNo findings. The rule is taint from a Spring @RequestParam/@PathVariable into a URL/HttpClient open; this card's @RequestParam is in FetchController and the connection is a raw java.net.Socket in impl/, through an injected dialer, so Semgrep CE does not connect source to sink.Checked: Semgrep 1.180.0 (CE); semgrep --config r/java.spring.security.injection.tainted-url-host.tainted-url-host src/main/java (Ran 1 rule)
CodeQLjava/ssrfThe Java SSRF query tracks remote input into URL/URLConnection/HttpClient/Socket sinks. This card connects a raw Socket to a vetted IP chosen by an injected dialer, so the sink host is a validated address, not the raw request value.Checked: github/codeql main; not executed
Find Security Bugs (SpotBugs plugin)URLCONNECTION_SSRF_FDURLConnection Server-Side Request Forgery and File DisclosureNot run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed
Semgrepjava.lang.security.audit.crypto.unencrypted-socket.unencrypted-socketUnencrypted socket (observed on the production dialer)Fired once on SystemNetwork.java (the production dialer uses a plain java.net.Socket). It is a TLS/plaintext note, not the SSRF control; included for honesty. No SSRF rule flagged the fetcher.Checked: Semgrep 1.180.0 (CE), p/security-audit

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
java.net.Socket, java.net.URI, java.net.InetAddress (JDK)OpenJDK 21
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).

Shell
mvn -B -q test
Shell
# Expected: every test passes
IMPL=fixed mvn -B -o -q test

# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test

# Expected: a test fails
IMPL=wrong mvn -B -o -q test

Each run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs ssrf/java-spring runs all three modes and checks the expected results.

Download the project ZIP, 30 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • jdk OpenJDK 21.0.12.1
  • maven 3.9.16
  • springBoot 4.1.1
  • junitJupiter 6.0.3
Matrix
fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 7 of 15 tests fail (5 exploit tests)
Test results
fixed: 15/15 pass; vulnerable: 12 fail (10 exploit); wrong: 7 fail (5 exploit)
Notes
java.net.URI keeps IPv4 shorthand verbatim in the host, so the wrong implementation's string blocklist is bypassed by the decimal, octal and hex payloads here. Maven on this machine defaults to a newer JDK, hence the pinned javaHome; several agents run Maven concurrently, so .mvn/maven.config pins the file-lock sync context.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Server-Side Request Forgery Prevention Cheat Sheet
  2. Java SE 21: InetAddress
  3. Java SE 21: HttpClient.Redirect
  4. Java SE 21: java.net.URI
  5. Spring Framework: SpringExtension for JUnit Jupiter
  6. CWE-918: Server-Side Request Forgery (SSRF)
  7. RFC 6598: IANA-Reserved IPv4 Prefix for Shared Address Space (100.64.0.0/10)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.