Refuse backslashes and control characters, then parse next with java.net.URI: return "redirect:" + next only for a path that starts with exactly one slash, or an https URL whose host is in an exact allowlist with no port or user info; otherwise redirect to a fixed page. Never trust startsWith("/") or a host substring test.
- Find every redirect built from request data: return "redirect:" + param, new RedirectView(param), new ModelAndView("redirect:" + param), response.sendRedirect(param), ResponseEntity.status(...).location(...), and Spring Security success handlers configured with setTargetUrlParameter or setUseReferer.
- Refuse a value that contains a backslash or an ASCII control character: browsers read \ as / and drop tabs and newlines, and java.net.URI does neither (it throws on most of them).
- Parse with new URI(next) and treat URISyntaxException as a refusal. With no scheme and no authority the value is a path: accept it only if it starts with exactly one slash, because URI reports no authority for ///evil.example/.
- Otherwise require scheme https, a host in an exact allowlist (compared in lower case), port -1 and no user info.
- Return "redirect:" + the value you checked, and "redirect:/account" on any refusal; @RequestParam(defaultValue = "/account") covers a missing or empty next.
- With Spring Security form login, rely on its RequestCache instead of a parameter: the default success handler returns users to the request it saved in the session.
- Add regression tests that sign in through MockMvc with this card's payloads and resolve the Location header the way a browser does (the BrowserUrl helper in the tests).
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
@Override
public String redirectAfterLogin(String next) {
return "redirect:" + next;
}With IMPL=vulnerable, 9 of 16 tests fail (9 exploit tests).
The fix
private static final Set<String> ALLOWED_HOSTS = Set.of("app.example.test", "docs.example.test");
private static final String FALLBACK = "/account";
@Override
public String redirectAfterLogin(String next) {
return "redirect:" + safeRedirectTarget(next);
}
static String safeRedirectTarget(String next) {
// Browsers read "\" as "/" and drop tabs and newlines. Refuse them here rather than rely on
// java.net.URI happening to throw on them.
if (next.indexOf('\\') >= 0 || next.chars().anyMatch(c -> c < 0x20 || c == 0x7f)) {
return FALLBACK;
}
URI uri;
try {
uri = new URI(next); // also rejects spaces, braces and other characters a URI cannot contain
} catch (URISyntaxException e) {
return FALLBACK;
}
if (uri.getScheme() == null && uri.getRawAuthority() == null) {
// A path on this site. URI finds no authority in ///evil.example/, so check the slashes too.
return next.startsWith("/") && !next.startsWith("//") ? next : FALLBACK;
}
boolean allowed = "https".equalsIgnoreCase(uri.getScheme())
&& uri.getHost() != null
&& ALLOWED_HOSTS.contains(uri.getHost().toLowerCase(Locale.ROOT))
&& uri.getPort() == -1
&& uri.getRawUserInfo() == null;
return allowed ? next : FALLBACK;
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
@Override
public String redirectAfterLogin(String next) {
return "redirect:" + checkedTarget(next);
}
private static String checkedTarget(String next) {
// A path on this site...
if (next.startsWith("/")) {
return next;
}
// ...or a full link back to one of our sites, app.example.test or docs.example.test.
try {
String host = new URI(next).getHost();
if (host != null && host.contains("example.test")) {
return next;
}
} catch (URISyntaxException e) {
// not a valid URI
}
return "/account";
}With IMPL=wrong, 4 of 16 tests fail (4 exploit tests).
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.
/**
* Each value arrives in a link to the real sign-in page, such as /login?next=%2F%2Fevil.example%2F.
* After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
*/
class ExploitTest extends LoginFlow {
@Test
void anAbsoluteUrlToAnotherSite() throws Exception {
assertLandsOnAnAllowedSite("https://evil.example/");
}
@Test
void aSchemeRelativeUrl() throws Exception {
assertLandsOnAnAllowedSite("//evil.example/");
}
@Test
void threeSlashes() throws Exception {
assertLandsOnAnAllowedSite("///evil.example/");
}
@Test
void aBackslashAfterTheSlash() throws Exception {
assertLandsOnAnAllowedSite("/\\evil.example/");
}
@Test
void twoBackslashes() throws Exception {
assertLandsOnAnAllowedSite("\\\\evil.example/");
}
@Test
void ourHostNameAsAUserName() throws Exception {
assertLandsOnAnAllowedSite("https://app.example.test@evil.example/");
}
@Test
void aLookAlikeHostThatContainsAnAllowedName() throws Exception {
assertLandsOnAnAllowedSite("https://docs.example.test.evil.example/");
}
@Test
void aBackslashBeforeTheAt() throws Exception {
assertLandsOnAnAllowedSite("https://evil.example\\@docs.example.test/");
}
@Test
void encodedSlashes() throws Exception {
assertLandsOnAnAllowedSite("/%2F%2Fevil.example/");
}
@Test
void aJavascriptUrl() throws Exception {
assertLandsOnAnAllowedSite("javascript:alert(document.domain)");
}
}- java.net.URI reports no host for ///evil.example/ and throws on /\evil.example/, and UriComponentsBuilder's default parser reports no host for either; a check that treats 'no host' as 'local' approves both, and browsers go to evil.example.
- UriComponentsBuilder.fromUriString(next, ParserType.WHAT_WG) is documented as aligning with how browsers parse URLs, but it parses relative input against an empty base: on Spring Framework 7.0.9 it returned no host for /\evil.example/, \\evil.example/ and ///evil.example/, which a browser on an https page sends to evil.example.
- Spring Framework fixed three host-validation bypasses in UriComponentsBuilder in 2024 (CVE-2024-22243, CVE-2024-22259, CVE-2024-22262): applications that parsed an external URL and checked its host could be steered into an open redirect or SSRF. Refusing ambiguous characters before parsing narrows that kind of parser gap.
- Spring Security's AbstractAuthenticationTargetUrlRequestHandler returns the targetUrlParameter value, and the Referer header when useReferer is set, without checking it (Spring Security 7.1.1), so setTargetUrlParameter("next") turns form login into an open redirect.
- startsWith("/") accepts //evil.example/ and /\evil.example/; a host test with contains or endsWith accepts docs.example.test.evil.example and look-alike registrations. Compare whole hosts against a set.
- Browsers refuse a Location header whose scheme is not http or https, but a javascript: value rendered into an href runs as script; keep the scheme check wherever next is used.
- Semgrep's Spring rule matches only a method whose whole body is return "redirect:" + param, and CodeQL's default Java query does not model "redirect:" view names at all; keep the regression tests.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/java-spring does this for you).
- Against your own app, sign in through /login?next=%2F%5Cevil.example%2F and /login?next=%2F%2F%2Fevil.example%2F and check that the Location header resolves to your own origin in a browser, not just in java.net.URI.
- Semgrep's Spring rule flags only the exact return "redirect:" + param shape; review every "redirect:", RedirectView, sendRedirect and Location header by hand or with the experimental CodeQL query.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.spring.security.audit.spring-unvalidated-redirect.spring-unvalidated-redirect | One finding, on the vulnerable implementation (VulnerableLoginRedirect.redirectAfterLogin, return "redirect:" + next). Nothing on the fixed or wrong implementations. The rule matches a method with a String parameter whose whole body is return "redirect:" + param (or a few ModelAndView forms): on a probe it missed the same return behind an if (next.startsWith("/")) check, which is just as exploitable.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> src/main/java |
| Semgrep | java.lang.security.audit.unvalidated-redirect.unvalidated-redirect | No findings on this card, which redirects through view names rather than HttpServletResponse. On a probe it flagged response.sendRedirect(next) with and without a startsWith("/") check.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | One finding, the same spring-unvalidated-redirect hit on the vulnerable implementation; nothing on the fixed or wrong implementations.Checked: Semgrep 1.180.0 (CE); on src/main/java |
| CodeQL | java/unvalidated-url-redirection | From the query source: sinks are HttpServletResponse.sendRedirect, Location headers set with setHeader or addHeader, Apache HttpComponents Location headers and modelled sinks; none of the Spring model files marks "redirect:" view names, so it is not expected to report this card. The experimental query java/spring-unvalidated-url-redirection (java/ql/src/experimental, not in the default suites) does model "redirect:" + url, RedirectView, ModelAndView and ResponseEntity locations.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | SPRING_UNVALIDATED_REDIRECTSpring Unvalidated Redirect (and UNVALIDATED_REDIRECT for sendRedirect) | Not run on this card.Checked: Both bug patterns are listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| 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) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| java.net.URI (JDK) | OpenJDK 21 |
| org.springframework.boot:spring-boot-starter-webmvc-test (test only) | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot, test only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# 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 testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs open-redirect/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- jdk OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- tomcat 11.0.26
- jackson 3.1.7
- junitJupiter 6.0.3
- semgrep 1.180.0
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 9 of 16 tests fail (9 exploit tests); wrong: 4 of 16 tests fail (4 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 9/16 fail (9 exploit tests: every payload except the encoded slashes); wrong 4/16 fail (4 exploit tests: //, ///, /\ and the look-alike host), offline after install
- Dependency freshness
- Spring Boot 4.1.1 was published on 2026-08-20. Boot 4.1.1 manages Tomcat 11.0.24 and Jackson 3.1.5, which have advisories fixed in Tomcat 11.0.26 and Jackson 3.1.7, so the pom overrides tomcat.version to 11.0.26 (2026-09-09) and jackson-bom.version to 3.1.7 (2026-09-21), as the other Java cards do. OSV listed no advisories for the 72 resolved artifacts on 2026-10-10. Maven has no lockfile; the Boot parent and the two overrides fix every version. .mvn/maven.config pins the file-lock sync context because several agents run Maven at once.
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
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Java SE 21: java.net.URI
- Spring Framework: URI parsing (RFC and WhatWG parser types)
- Spring MVC: handler method return values (redirect: view names)
- CVE-2024-22262: Spring Framework URL Parsing with Host Validation (3rd report)
- Spring Security: form login (saved request and success handler)
- CodeQL query help: java/unvalidated-url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')