Safeguard
Application Security

Secure URL Encoding and Decoding in Java

Java's URLEncoder turns spaces into + instead of %20 — a form-encoding quirk that, mixed with double-decoding, still causes path-traversal bugs like CVE-2025-41242 in 2025.

Safeguard Research Team
Research
6 min read

On August 14, 2025, Spring's security team disclosed CVE-2025-41242, a path-traversal flaw in Spring Framework versions 6.2.0–6.2.9, 6.1.0–6.1.21, 6.0.0–6.0.29, and 5.3.0–5.3.43, scored 5.9 on CVSS 3.1. The root cause was not a missing access check — it was a mismatch between how ResourceHttpRequestHandler decoded and normalized a requested path and how the underlying servlet container had already interpreted the same encoded sequence, letting a crafted URL escape the intended resource root. It is the kind of bug that java.net.URLEncoder and URLDecoder have been quietly setting teams up for since JDK 1.0: both classes implement the application/x-www-form-urlencoded MIME format defined for HTML form submission, not RFC 3986 percent-encoding, and Oracle's own Javadoc has said so for over two decades. The gap between what developers assume ("this encodes a URL") and what the API actually does ("this encodes a form field") is where double-encoding bugs, mojibake, and traversal exploits like CVE-2025-41242 come from. This post walks through the specific pitfalls in Java's URL encoding APIs, why double-encoding is a recurring filter-bypass technique, and how to build and decode URLs without reintroducing the class of bug Spring just patched.

What does URLEncoder actually encode, and why is that a problem?

URLEncoder.encode() implements the application/x-www-form-urlencoded scheme from the HTML specification, which was designed for encoding form field name/value pairs inside a request body — not for producing valid path segments, query strings, or full URIs. The clearest symptom is spaces: URLEncoder converts a space to +, while RFC 3986 percent-encoding (what browsers and most servers expect in a URL) converts it to %20. If you use URLEncoder.encode() to build a path segment and the input happens to contain a literal + character, that + is indistinguishable on decode from an encoded space, silently corrupting the value. Oracle's Javadoc for java.net.URLEncoder states plainly that the class is "utility class for HTML form encoding" and cross-references RFC 1866 (the old HTML 2.0 form spec), not RFC 3986. Using it anywhere outside actual form-field encoding — building a redirect URL, a query parameter for an API client, a cache key from a path — is a category error that produces subtly wrong output rather than an exception, which is exactly why it survives code review.

Why does the missing-charset overload matter?

Older overloads of URLEncoder.encode(String) and URLDecoder.decode(String) — deprecated since Java 1.4 but still callable — silently fall back to the JVM's platform default charset instead of a fixed encoding. On a Linux CI box configured for UTF-8 that produces one output; on a Windows server defaulting to Windows-1252, the same input byte sequence encodes differently. That divergence is a classic source of mojibake, but it is also a security-relevant inconsistency: if a validator runs on one host and the consuming service runs on another with a different default charset, the two can disagree about what a decoded string actually contains. Oracle's current documentation is explicit that the charset-taking overloads — URLEncoder.encode(s, "UTF-8") and URLDecoder.decode(s, "UTF-8") — should always be used, and Java 10 added URLEncoder.encode(String, Charset) so callers can pass StandardCharsets.UTF_8 directly instead of a string that can typo into an UnsupportedEncodingException at runtime.

How do double-encoding bugs actually enable injection?

Double encoding happens when a string is encoded (or decoded) more than the number of times the receiving layer expects, so a security check inspects one representation of the data while the component that acts on it processes a different one. A common failure mode: an edge proxy or WAF decodes a URL once to inspect it for traversal sequences like ../, finds none, and forwards the still-partially-encoded string; the application then performs its own decode and reveals a ../ sequence that was hiding as %252e%252e%252f (a percent-encoded percent-encoding) at the layer that did the inspection. This is not Java-specific — OWASP's Testing Guide documents double encoding as a standalone filter-evasion technique — but it is especially common in Java web stacks that mix a servlet container's own URI-decoding pass, a framework's resource-resolution layer, and manual URLDecoder.decode() calls in application code, each with its own opinion about how many times a value has already been unescaped.

What exactly went wrong in CVE-2025-41242?

CVE-2025-41242 sat in Spring MVC's ResourceHttpRequestHandler, the component responsible for serving static files. According to Spring's own advisory and the corresponding GitHub Security Advisory (GHSA-r936-gwx5-v52f), the flaw let a specially encoded request path bypass Spring's path-traversal protections and reach files outside the configured static-resource root, when the application ran on a servlet container that does not itself reject suspicious encoded sequences before handing the path to Spring. Spring's advisory is explicit that Tomcat and Jetty, run with default settings, are not vulnerable on their own — the exposure depends on container configuration, not just the Spring Framework version. It was fixed in Spring Framework 6.2.10 for the open-source line, and in 6.1.22 and 5.3.44 for commercially supported branches. The lesson for Java teams isn't "don't use Spring" — it's that decode-then-validate logic layered across a proxy, a container, and a framework is fragile by construction, and each layer needs to agree on exactly how many decode passes have already happened before a path-safety check runs.

What should a safe encode/decode pipeline in Java actually look like?

Treat URLEncoder/URLDecoder as form-field tools only, and reach for java.net.URI or java.net.URLEncoder only where explicitly building application/x-www-form-urlencoded bodies; for path segments and general URI construction, build values with new URI(scheme, authority, path, query, fragment) or a library that implements RFC 3986 percent-encoding directly, so spaces become %20 and reserved characters are escaped in the right context (path vs. query vs. fragment have different reserved-character sets). Always pass an explicit charset — StandardCharsets.UTF_8 — and never rely on a deprecated overload's platform default. Decode exactly once, as close as possible to the point where the value is consumed, and validate after that single decode rather than before it, so a filter can't be shown one representation while the application acts on another. And for anything path-related, resolve the final path with Path.normalize() and confirm with startsWith() that it remains inside the intended root directory after normalization, rather than pattern-matching for ../ in the raw or partially decoded string.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.