SSRF, Path Traversal, and Other Spring Boot Vulnerabilities That Don't Get Enough Attention

by Eric Hanson, Backend Developer at Clean Systems Consulting

Server-Side Request Forgery (SSRF)

SSRF occurs when the server makes HTTP requests to URLs supplied or influenced by the attacker. The attacker uses the server as a proxy to reach internal services, cloud metadata endpoints, or other restricted network resources.

The Spring Boot SSRF surface area:

  • Webhook registration endpoints (user provides a URL to call back)
  • URL preview or link unfurling features
  • File import from URL (importFromUrl)
  • Image upload from URL
  • Any WebClient or RestTemplate call to a user-controlled URL
// VULNERABLE — fetches user-controlled URL
@PostMapping("/import/csv")
public ImportResult importFromUrl(@RequestBody ImportRequest request) {
    // Attacker sends: url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/admin"
    // On AWS, this returns IAM credentials for the EC2 instance role
    byte[] data = webClient.get()
        .uri(request.getSourceUrl())
        .retrieve()
        .bodyToMono(byte[].class)
        .block();
    return csvParser.parse(data);
}

Mitigation — allowlist validation with DNS resolution:

@Component
public class SsrfProtection {

    private static final Set<String> BLOCKED_HOSTS = Set.of(
        "169.254.169.254",          // AWS metadata
        "metadata.google.internal", // GCP metadata
        "100.100.100.200",          // Alibaba Cloud metadata
        "metadata.azure.internal"   // Azure metadata (internal only)
    );

    public void validateUrl(String urlString) {
        URI uri;
        try {
            uri = new URI(urlString);
        } catch (URISyntaxException e) {
            throw new InvalidUrlException("Malformed URL");
        }

        // Only allow https for external callbacks
        if (!"https".equals(uri.getScheme())) {
            throw new InvalidUrlException("Only HTTPS URLs are permitted");
        }

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

        // Block known metadata endpoints by hostname
        if (BLOCKED_HOSTS.contains(host.toLowerCase())) {
            throw new InvalidUrlException("URL host is not permitted");
        }

        // Resolve DNS and check the actual IP
        InetAddress resolved;
        try {
            resolved = InetAddress.getByName(host);
        } catch (UnknownHostException e) {
            throw new InvalidUrlException("Cannot resolve host: " + host);
        }

        // Block private, loopback, link-local addresses
        if (resolved.isLoopbackAddress() ||
            resolved.isSiteLocalAddress() ||    // 10.x, 172.16.x, 192.168.x
            resolved.isLinkLocalAddress() ||    // 169.254.x
            resolved.isAnyLocalAddress()) {
            throw new InvalidUrlException("URL resolves to a restricted IP address");
        }
    }
}

DNS rebinding. An attacker registers a domain that initially resolves to a public IP (passes validation) then immediately changes DNS to resolve to an internal IP. Mitigate by pinning the resolved IP:

// After validation, use the resolved IP directly — don't re-resolve
InetSocketAddress resolvedAddress = new InetSocketAddress(resolved, uri.getPort());
webClient.get()
    .uri(UriComponentsBuilder.fromUri(uri)
        .host(resolved.getHostAddress())  // use IP, not hostname
        .build().toUri())
    .retrieve()
    // ...

Path Traversal

Path traversal occurs when user input is used to construct a file path, allowing access to files outside the intended directory.

// VULNERABLE — attacker sends filename: "../../etc/passwd"
@GetMapping("/files/{filename}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename) {
    Path filePath = Paths.get("/app/uploads", filename);
    Resource resource = new FileSystemResource(filePath);
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"" + filename + "\"")
        .body(resource);
}

Mitigation — canonicalize and validate within the allowed directory:

@GetMapping("/files/{filename}")
public ResponseEntity<Resource> downloadFile(@PathVariable String filename)
        throws IOException {

    Path uploadDir = Paths.get("/app/uploads").toRealPath();
    Path requestedFile = uploadDir.resolve(filename).normalize().toRealPath();

    // Verify the resolved path is within the upload directory
    if (!requestedFile.startsWith(uploadDir)) {
        throw new AccessDeniedException("Access denied: path traversal detected");
    }

    if (!Files.exists(requestedFile) || !Files.isRegularFile(requestedFile)) {
        throw new FileNotFoundException(filename);
    }

    Resource resource = new FileSystemResource(requestedFile);
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION,
            "attachment; filename=\"" + requestedFile.getFileName() + "\"")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

toRealPath() resolves symlinks and .. components. Checking startsWith(uploadDir) after normalization ensures the path is within the allowed directory. Note: toRealPath() throws NoSuchFileException if the file doesn't exist — handle it appropriately.

Null byte injection. Some systems treat \0 as a string terminator. filename.txt\0.jpg might be stored as filename.txt on a system that truncates at null bytes. Reject filenames containing null bytes:

if (filename.contains("\0")) {
    throw new InvalidFilenameException("Invalid filename");
}

XML External Entity (XXE) Injection

XXE occurs when XML input is parsed with external entity processing enabled. An attacker embeds an external entity reference in the XML that causes the parser to read local files or make network requests.

// VULNERABLE — default DocumentBuilder processes external entities
@PostMapping("/import/xml")
public ImportResult importXml(@RequestBody String xmlContent) {
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    DocumentBuilder builder = factory.newDocumentBuilder();
    Document doc = builder.parse(new InputSource(new StringReader(xmlContent)));
    // Attacker submits: <!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
    // The parsed document contains the file contents
}

Mitigation — disable external entity processing:

@PostMapping("/import/xml")
public ImportResult importXml(@RequestBody String xmlContent) throws Exception {
    DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

    // Disable all external entity processing
    factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
    factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
    factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
    factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
    factory.setXIncludeAware(false);
    factory.setExpandEntityReferences(false);

    DocumentBuilder builder = factory.newDocumentBuilder();
    Document doc = builder.parse(new InputSource(new StringReader(xmlContent)));
    return parseDocument(doc);
}

Jackson XML. If using Jackson's XML module (jackson-dataformat-xml), configure it to disable DTD processing:

@Bean
public XmlMapper xmlMapper() {
    XmlMapper mapper = new XmlMapper();
    mapper.configure(FromXmlParser.Feature.EMPTY_ELEMENT_AS_NULL, true);
    // Disable XXE via the underlying XMLInputFactory
    mapper.getFactory().getXMLInputFactory()
        .setProperty(XMLInputFactory.SUPPORT_DTD, false);
    mapper.getFactory().getXMLInputFactory()
        .setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
    return mapper;
}

Regular Expression Denial of Service (ReDoS)

Certain regular expressions exhibit catastrophic backtracking when matched against crafted input. An attacker provides a string that causes the regex engine to consume 100% CPU for an extended period.

The vulnerable regex pattern: alternation with overlapping groups, e.g., (a+)+, ([a-zA-Z]+)*, (a|aa)+. These cause exponential backtracking.

// VULNERABLE — catastrophic backtracking with input like "aaaaaaaaaaaaaaaaaab"
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$");

@PostMapping("/validate-email")
public boolean validateEmail(@RequestParam String email) {
    return EMAIL_PATTERN.matcher(email).matches(); // hangs on malicious input
}

Mitigation:

Use linear-time regex alternatives. Many popular regex patterns for email, URL, and identifier validation have safe alternatives. For email specifically, use a purpose-built validator:

// Use Apache Commons Validator or Jakarta Bean Validation
@Email
private String email;

@Email from Jakarta Bean Validation uses a safe regex internally and is maintained by the library.

Apply input length limits before regex. A 10,000-character "email address" can't be valid. Reject oversized input before regex matching:

if (input.length() > 500) {
    throw new InvalidInputException("Input too long");
}
if (!SAFE_PATTERN.matcher(input).matches()) {
    throw new InvalidInputException("Invalid format");
}

Use possessive quantifiers or atomic groups (where supported) to prevent backtracking. Java's Pattern supports possessive quantifiers:

// Possessive quantifier ++ — no backtracking
Pattern.compile("^[a-zA-Z0-9]++@[a-zA-Z0-9]++\\.[a-zA-Z]{2,}+$");

Test regex against redos-checker tools. Run candidate patterns through redos-checker or safe-regex before adding them to production code.

Java Deserialization Vulnerabilities

Deserializing untrusted Java objects from user input can execute arbitrary code — the Java deserialization vulnerability has been exploited in numerous high-profile incidents (Apache Struts, Jenkins, WebLogic).

// VULNERABLE — deserializes user-provided bytes
@PostMapping("/session/restore")
public Session restoreSession(@RequestBody byte[] sessionData) {
    try (ObjectInputStream ois = new ObjectInputStream(
            new ByteArrayInputStream(sessionData))) {
        return (Session) ois.readObject(); // remote code execution possible
    }
}

Mitigation:

Don't use Java serialization for external data. Use JSON, Protocol Buffers, or MessagePack instead. Java's ObjectInputStream should never deserialize bytes from untrusted sources.

Use serialization filtering (Java 9+ ObjectInputFilter) if Java deserialization cannot be avoided:

ObjectInputStream ois = new ObjectInputStream(input);
ois.setObjectInputFilter(info -> {
    // Only allow specific safe classes
    Class<?> clazz = info.serialClass();
    if (clazz == null) return ObjectInputFilter.Status.UNDECIDED;
    if (clazz == Session.class || clazz == String.class ||
            clazz == Long.class) {
        return ObjectInputFilter.Status.ALLOWED;
    }
    return ObjectInputFilter.Status.REJECTED;
});

Use the serialization filter agent. The noSerialFilter or javaSerialKiller Java agent blocks deserialization of known gadget chains at the JVM level — a defense-in-depth measure for legacy codebases that can't eliminate Java deserialization immediately.

Open Redirect

A redirect to a user-controlled URL, used for phishing — users see a trusted domain URL that redirects to a malicious site.

// VULNERABLE — attacker sends: redirect=https://evil.com
@GetMapping("/login")
public void login(HttpServletResponse response,
        @RequestParam String redirect) throws IOException {
    // After login, redirect the user
    response.sendRedirect(redirect);
}

Mitigation — validate redirect URLs against an allowlist:

private static final List<String> ALLOWED_REDIRECT_HOSTS = List.of(
    "example.com", "app.example.com", "admin.example.com"
);

@GetMapping("/login")
public void login(HttpServletResponse response,
        @RequestParam(required = false) String redirect) throws IOException {

    String safeRedirect = "/dashboard"; // default

    if (redirect != null) {
        try {
            URI uri = new URI(redirect);
            String host = uri.getHost();
            if (host == null || ALLOWED_REDIRECT_HOSTS.contains(host)) {
                // Relative URLs (no host) or allowed hosts are safe
                safeRedirect = redirect;
            }
        } catch (URISyntaxException ignored) {
            // Malformed URL — use default
        }
    }

    response.sendRedirect(safeRedirect);
}

Relative URLs (no scheme, no host) are safe for redirects — they can only redirect within the same origin.

HTTP Response Splitting

If user input is reflected in HTTP response headers without sanitization, an attacker can inject additional headers or a fake response body by including \r\n sequences.

// VULNERABLE — user input in header value
@GetMapping("/redirect")
public ResponseEntity<Void> redirect(@RequestParam String location) {
    return ResponseEntity.status(HttpStatus.FOUND)
        .header("Location", location) // attacker injects \r\n Content-Type: text/html\r\n\r\n<script>...
        .build();
}

Mitigation: Spring's HttpHeaders class sanitizes header values in modern versions. But for older Spring versions or custom header construction, strip CR and LF from header values:

private String sanitizeHeaderValue(String value) {
    return value.replaceAll("[\r\n]", "");
}

Modern servlet containers also reject headers containing CR/LF. Verify your Tomcat version performs this validation.

The vulnerability audit per feature

When building a feature that accepts external input or fetches external resources, run through this checklist:

  • Does the feature fetch a URL? → SSRF validation required
  • Does the feature handle file paths? → Path traversal check required
  • Does the feature parse XML? → XXE protection required
  • Does the feature use regex on user input? → ReDoS analysis required
  • Does the feature deserialize binary data? → Eliminate or filter Java deserialization
  • Does the feature redirect based on user input? → Open redirect validation required
  • Does the feature include user input in response headers? → Strip CR/LF

These vulnerabilities don't get as much attention as SQL injection, but they appear in production APIs regularly and are reliably found by penetration testers. Each has a clear mitigation pattern — the difficulty is remembering to apply it.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Berlin Has a Backend Developer Shortage. Remote Contractors Fill the Gap

You've been hiring for three months. The role is still open. The gap in your backend isn't waiting for you to find the perfect candidate.

Read more

Austin's Backend Developer Boom Is Cooling — What Startups Are Doing to Keep Shipping

The hiring market that made Austin feel like anything was possible has shifted. Here's how founders are staying lean without stalling out.

Read more

Broken Object-Level Authorization in Spring Boot — How to Detect and Prevent IDOR

IDOR (Insecure Direct Object Reference) is consistently the most common API vulnerability. It occurs when an API endpoint accepts a resource identifier and returns or modifies the resource without verifying the caller has permission to access that specific resource.

Read more

Configuring Spring Boot for Docker and Kubernetes — Health Probes, Graceful Shutdown, and Resource Limits

Spring Boot applications deployed to Kubernetes need specific configuration to behave correctly under orchestration — proper health probes, graceful shutdown, container-aware resource limits, and externalized configuration. Here is the complete setup.

Read more