FlawOpen

flawopen.com/ssrf/Java

● CWE-918 · High
FlawOpen Security Research

Server-Side Request Forgery (SSRF) in Java (OkHttp, Spring)

new URL(userUrl).openStream() lets a Spring service read file:, jar: and internal http: URLs, while isSiteLocalAddress() misses loopback and link-local addresses. DNS rebinding and redirects can bypass an earlier check. See how OkHttp Dns filters listed address classes, why IP literals need checking, and why network egress policy remains necessary.

💡 Plain English Explainer (ELI5)

Imagine giving a visitor a universal building pass that opens the web lobby, the records room, and the loading dock. java.net.URL is similarly broad: Java 21 guarantees handlers for file:, jar:, http:, and https:, while applications may provide additional protocols. A user-controlled value can select both a destination and a protocol. Calling openStream() uses the selected protocol handler. The safer Java example switches to OkHttp HttpUrl for HTTP(S), filters resolved hosts through OkHttp Dns, checks IP literals too, and disables redirects and proxies. Think of Dns as the guard checking each address the courier will actually use, while followRedirects(false) prevents a public desk from handing over a new route into a restricted room.

Core Concepts & Subsystem Terms

URL.openStream() and non-HTTP schemes
java.net.URL uses protocol handlers. Java 21 guarantees handlers for file:, jar:, http:, and https:; additional protocols may be available through providers. Thus openStream() on a file: URL can read a local file. For HTTP, it also follows redirects by default, so a public URL can bounce a request to an internal one.
InetAddress.isSiteLocalAddress() is not enough
It only covers 10/8, 172.16/12, 192.168/16 and the deprecated fec0::/10. 127.0.0.1, 169.254.169.254, 0.0.0.0, 100.64.0.0/10 and IPv6 unique-local fc00::/7 all pass, and getByName returns just one of the host's addresses.
Redirects and check-then-fetch
OkHttp follows redirects by default, and it resolves the hostname again when it connects. A DNS server with a short TTL can return a public address to the check and 127.0.0.1 to OkHttp, and a 302 to http://169.254.169.254/ works even when the original URL was public.
OkHttp Dns
OkHttp connects only to the addresses returned by the client's Dns.lookup, for the first request and for every redirect or retry, so filtering there checks the addresses actually used. OkHttp does not call Dns for IP-literal hosts such as http://127.0.0.1/, so check those before the call, and set Proxy.NO_PROXY so a proxy does not bypass the filter. This sample blocks only the classes shown; it is not a complete IANA global-reachability test. Keep the range policy current and enforce egress independently.
IPv4-mapped addresses in InetAddress
InetAddress.getByName("::ffff:127.0.0.1") returns an Inet4Address, so IPv4-mapped IPv6 is classified as IPv4. The isLoopbackAddress, isLinkLocalAddress, isSiteLocalAddress and isAnyLocalAddress methods cover most ranges; CGNAT and fc00::/7 need explicit byte checks.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A Spring controller receives url in a JSON body and passes it to URL.openStream, RestTemplate, WebClient or OkHttp.

Step 2

The service connects to an internal address

The URL names 127.0.0.1, a private host, 169.254.169.254, a file: path, a hostname that rebinds, or a public page that redirects inward. The benign test value http://127.0.0.1:8080/actuator/health illustrates the case.

Step 3

The internal service answers the trusted caller

Spring Boot Actuator, admin consoles and the metadata service accept the request because it comes from inside the network.

Step 4

The attacker reads secrets or pivots

The response or its status leaks credentials, environment values or open ports, and cloud keys can be reused from outside.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// FetchController.java: both endpoints let the caller choose the destination
package com.example.fetch;

import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class FetchController {
    record FetchRequest(String url) {}

    static final OkHttpClient CLIENT = new OkHttpClient();

    // java.net.URL also opens file: and jar: URLs and follows redirects
    @PostMapping("/preview")
    public String preview(@RequestBody FetchRequest in) throws IOException {
        try (InputStream stream = new URL(in.url()).openStream()) {
            return new String(stream.readNBytes(5000), StandardCharsets.UTF_8);
        }
    }

    // isSiteLocalAddress misses 127.0.0.1 and 169.254.169.254; OkHttp resolves again
    @PostMapping("/webhooks/test")
    public Map<String, Integer> testWebhook(@RequestBody FetchRequest in) throws IOException {
        InetAddress address = InetAddress.getByName(new URL(in.url()).getHost());
        if (address.isSiteLocalAddress()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        RequestBody body = RequestBody.create("{\"ping\":true}", MediaType.get("application/json"));
        Request request = new Request.Builder().url(in.url()).post(body).build();
        try (Response response = CLIENT.newCall(request).execute()) {
            return Map.of("status", response.code());
        }
    }
}
HARDENED SECURE PATCH
// OkHttp applies a baseline address filter; independently enforce current network egress policy
package com.example.fetch;

import java.io.IOException;
import java.net.InetAddress;
import java.net.Proxy;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class FetchController {
    record FetchRequest(String url) {}

    // baseline filter only; this is not a complete IANA global-reachability classifier
    static boolean passesBaselineFilter(InetAddress a) {
        byte[] b = a.getAddress();
        boolean cgnat = b.length == 4 && (b[0] & 0xff) == 100 && (b[1] & 0xc0) == 64;
        boolean uniqueLocal = b.length == 16 && (b[0] & 0xfe) == 0xfc;
        return !(a.isAnyLocalAddress() || a.isLoopbackAddress() || a.isLinkLocalAddress()
                || a.isSiteLocalAddress() || a.isMulticastAddress() || cgnat || uniqueLocal);
    }

    // OkHttp connects only to what this returns, for every request and redirect
    static final Dns FILTERED_DNS = hostname -> {
        List<InetAddress> addresses = Dns.SYSTEM.lookup(hostname);
        if (addresses.isEmpty() || !addresses.stream().allMatch(FetchController::passesBaselineFilter)) {
            throw new UnknownHostException("blocked destination " + hostname);
        }
        return addresses;
    };

    // no proxy, no redirects, timeouts; IP literals skip Dns, so check them first
    static final OkHttpClient CLIENT = new OkHttpClient.Builder()
            .dns(FILTERED_DNS)
            .proxy(Proxy.NO_PROXY)
            .followRedirects(false)
            .followSslRedirects(false)
            .connectTimeout(3, TimeUnit.SECONDS)
            .readTimeout(5, TimeUnit.SECONDS)
            .callTimeout(10, TimeUnit.SECONDS)
            .build();

    static HttpUrl checkedUrl(String raw) throws UnknownHostException {
        HttpUrl url = HttpUrl.parse(raw);
        if (url == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "only http and https URLs are allowed");
        }
        FILTERED_DNS.lookup(url.host());
        return url;
    }

    // at most 5000 bytes are read from any response
    @PostMapping("/preview")
    public String preview(@RequestBody FetchRequest in) throws IOException {
        Request request = new Request.Builder().url(checkedUrl(in.url())).build();
        try (Response response = CLIENT.newCall(request).execute()) {
            return response.peekBody(5000).string();
        }
    }

    @PostMapping("/webhooks/test")
    public Map<String, Integer> testWebhook(@RequestBody FetchRequest in) throws IOException {
        RequestBody body = RequestBody.create("{\"ping\":true}", MediaType.get("application/json"));
        Request request = new Request.Builder().url(checkedUrl(in.url())).post(body).build();
        try (Response response = CLIENT.newCall(request).execute()) {
            return Map.of("status", response.code());
        }
    }

    @ExceptionHandler(UnknownHostException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    void blocked() {
    }
}

Engineering & System Hardening Checklist

Sources