CWE-601: Open Redirect - Java
Overview
Open redirect vulnerabilities in Java web applications occur when user-controlled input is used in sendRedirect(), forward(), or <meta> refresh tags without proper validation, enabling phishing attacks and credential theft. Spring MVC, Jakarta EE (formerly Java EE), and servlets each require careful handling of redirect destinations.
Primary Defence: For local redirects, validate that user-supplied URLs are relative paths using URI.create() and checking that getHost() returns null. For external redirects, use an explicit allowlist of permitted domains with exact host matching after parsing with new URI(). Reject protocol-relative URLs (//evil.com), JavaScript URLs (javascript:), and ensure validation happens before calling sendRedirect() or returning Spring's RedirectView. Always fail-closed with a safe default redirect when validation fails.
Common Vulnerable Patterns
Unvalidated Servlet Redirect
import javax.servlet.http.*;
// VULNERABLE - No validation
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// Authenticate user...
String redirectUrl = request.getParameter("returnUrl");
response.sendRedirect(redirectUrl); // Dangerous!
}
}
// Attack: /login?returnUrl=https://evil.com/phishing
Why this is vulnerable:
request.getParameter("returnUrl")retrieves user-controlled input without validationsendRedirect()accepts any URL including absolute URLs to attacker domains- No check for null values causes
NullPointerExceptionif parameter is missing - No validation of protocol-relative URLs, JavaScript URLs, or data URLs
Unvalidated Spring MVC Redirect
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
// VULNERABLE - Direct redirect from parameter
@Controller
public class LoginController {
@PostMapping("/login")
public String login(@RequestParam String returnUrl) {
// Authenticate user...
return "redirect:" + returnUrl; // Vulnerable!
}
}
// Attack: /login?returnUrl=https://evil.com/fake-login
Why this is vulnerable:
@RequestParam String returnUrlbinds user input directly to parameter"redirect:" + returnUrlallows absolute URLs without restriction- Spring's
redirect:prefix accepts external URLs without validation - Missing default value means null parameter causes errors
String-Based Validation
// VULNERABLE - Insufficient string checking
public String unsafeRedirect(String url) {
if (!url.contains("http://") && !url.contains("https://")) {
return "redirect:" + url; // Still vulnerable!
}
return "redirect:/";
}
// Attack: url = "//evil.com/phishing"
// Protocol-relative URL bypasses the check
Why this is vulnerable:
- String
contains()check misses protocol-relative URLs like//evil.com - Case-sensitive check can be bypassed with
HTTP://orHttps:// - Doesn't prevent JavaScript URLs (
javascript:alert(1)) - No validation of URL structure or components using proper URI parsing
Secure Patterns
Servlet: Validate Local URLs
import javax.servlet.http.*;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
@WebServlet("/login")
public class SecureLoginServlet extends HttpServlet {
private boolean isLocalUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
try {
URI uri = new URI(url);
// Must be relative (no scheme or host)
if (uri.isAbsolute() || uri.getHost() != null) {
return false;
}
// Must start with / but not //
if (!url.startsWith("/") || url.startsWith("//")) {
return false;
}
return true;
} catch (URISyntaxException e) {
return false; // Malformed URL
}
}
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// Authenticate user...
String returnUrl = request.getParameter("returnUrl");
if (returnUrl != null && isLocalUrl(returnUrl)) {
response.sendRedirect(returnUrl);
} else {
response.sendRedirect("/"); // Safe default
}
}
}
Why this works:
new URI(url)properly parses URLs and throwsURISyntaxExceptionfor malformed input, preventing parsing attacksuri.isAbsolute()returns true for URLs with schemes (http://,https://,javascript:), allowing rejection of absolute URLsuri.getHost() != nulldetects any URL with a domain/netloc, including protocol-relative URLs like//evil.comstartsWith("/")ensures URL is a valid relative path within the applicationnot startsWith("//")provides additional defense against protocol-relative URLs that might slip through URI parsing- Try-catch block handles malformed URLs safely by returning false instead of throwing exceptions
- Null/empty check prevents NullPointerException and rejects missing parameters
- Fail-closed behavior redirects to
/when validation fails
Spring MVC: Redirect Validation
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.net.URISyntaxException;
@Controller
public class SecureLoginController {
private boolean isLocalUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
try {
URI uri = new URI(url);
// Relative URL only (no host, no scheme)
if (uri.isAbsolute() || uri.getHost() != null) {
return false;
}
// Must start with /
return url.startsWith("/") && !url.startsWith("//");
} catch (URISyntaxException e) {
return false;
}
}
@PostMapping("/login")
public String login(@RequestParam(defaultValue = "/") String returnUrl) {
// Authenticate user...
if (isLocalUrl(returnUrl)) {
return "redirect:" + returnUrl;
}
return "redirect:/"; // Safe default
}
}
Why this works:
- Same URI validation logic as servlet example - parses URL properly with
new URI() @RequestParam(defaultValue = "/")provides safe fallback when parameter is missing- Spring's
redirect:prefix works safely with validated relative URLs - Fail-closed behavior returns
"redirect:/"for invalid URLs - Exception handling prevents application errors from malformed URLs
Allowlist External Domains
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
@Controller
public class ExternalRedirectController {
private static final Set<String> ALLOWED_DOMAINS = Set.of(
"example.com",
"www.example.com",
"partner.example.org"
);
private boolean isAllowedUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
try {
URI uri = new URI(url);
// Allow relative URLs (no host)
if (uri.getHost() == null) {
return url.startsWith("/") && !url.startsWith("//");
}
// For absolute URLs, check scheme and host
String scheme = uri.getScheme();
if (!"http".equals(scheme) && !"https".equals(scheme)) {
return false;
}
// Exact host match (case-insensitive)
return ALLOWED_DOMAINS.contains(uri.getHost().toLowerCase());
} catch (URISyntaxException e) {
return false;
}
}
@GetMapping("/external")
public String externalRedirect(@RequestParam String url) {
if (isAllowedUrl(url)) {
return "redirect:" + url;
}
return "redirect:/";
}
}
Why this works:
Set.of()creates immutable allowlist of permitted domains for thread-safe O(1) lookup- Combines relative URL validation with external domain allowlist for flexibility
uri.getScheme()check blocks JavaScript URLs (javascript:), data URLs (data:), and file URLs (file:)uri.getHost().toLowerCase()performs case-insensitive exact host matching, preventingExAmPlE.cOmbypasses- Separate validation paths for relative vs absolute URLs ensures proper handling
- Null host check allows relative URLs while requiring domain validation for absolute URLs
- Fail-closed default redirects to
/for invalid/unlisted domains
Indirect Redirects (Best Practice)
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Controller
public class IndirectRedirectController {
private static final Map<String, String> REDIRECT_MAP = Map.of(
"dashboard", "/dashboard",
"profile", "/user/profile",
"settings", "/user/settings"
);
@GetMapping("/goto")
public String safeRedirect(@RequestParam String dest) {
String url = REDIRECT_MAP.get(dest);
if (url != null) {
return "redirect:" + url;
}
return "redirect:/"; // Invalid destination
}
}
Why this works:
- Eliminates URL injection completely - users provide string keys, not URLs
Map.of()creates immutable mapping at compile time, preventing runtime modificationsREDIRECT_MAP.get()performs safe lookup with no injection risk- Invalid keys (like
"<script>alert(1)</script>"or"../../etc/passwd") simply return null - Fail-closed behavior redirects to
/for missing/invalid destination IDs - Immune to encoding bypasses, protocol tricks, and domain manipulation
- Easiest pattern to audit - just review the REDIRECT_MAP contents
Jakarta EE Filter Pattern
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
public class RedirectValidationFilter implements Filter {
private boolean isLocalUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
try {
URI uri = new URI(url);
return !uri.isAbsolute() &&
uri.getHost() == null &&
url.startsWith("/") &&
!url.startsWith("//");
} catch (URISyntaxException e) {
return false;
}
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String returnUrl = httpRequest.getParameter("returnUrl");
if (returnUrl != null && !isLocalUrl(returnUrl)) {
// Invalid redirect attempt - block it
httpRequest.setAttribute("returnUrl", "/");
}
chain.doFilter(request, response);
}
}
Why this works:
- Filter provides centralized redirect validation across multiple servlets
- Sanitizes dangerous
returnUrlparameters before they reach application code - Same URI validation logic ensures consistency across the application
- Replaces invalid URLs with safe default (
"/") instead of rejecting requests - Transparent to application code - validation happens in filter layer
Warning Page for External URLs
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;
@Controller
public class ExternalWarningController {
private static final Set<String> ALLOWED_DOMAINS = Set.of(
"example.com", "partner.example.org"
);
@GetMapping("/external")
public String externalLink(@RequestParam String url, Model model) {
if (url == null || url.isEmpty()) {
return "redirect:/";
}
try {
URI uri = new URI(url);
// Check if local
if (uri.getHost() == null) {
if (url.startsWith("/") && !url.startsWith("//")) {
return "redirect:" + url;
}
return "redirect:/";
}
// Check if allowed external domain
String scheme = uri.getScheme();
if (("http".equals(scheme) || "https".equals(scheme)) &&
ALLOWED_DOMAINS.contains(uri.getHost().toLowerCase())) {
// Show warning page
model.addAttribute("destination", url);
return "external-warning";
}
} catch (URISyntaxException e) {
// Malformed URL
}
return "redirect:/";
}
}
// external-warning.html (Thymeleaf template):
/*
<h2>You are leaving our site</h2>
<p>You are about to visit: <span th:text="${destination}"></span></p>
<a th:href="${destination}">Continue to external site</a>
<a href="/">Stay here</a>
*/
Why this works:
- Interstitial warning breaks automatic phishing redirect chain
- Displays full destination URL using Thymeleaf's auto-escaping (
th:text) - Requires explicit user click to proceed to external site
- Provides clear escape option ("Stay here") for suspicious redirects
- Only shown for external allowlisted URLs - local redirects are seamless
- Combines validation with user awareness for defense-in-depth