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
WebClientorRestTemplatecall 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.