Skip to content

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 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: Where the destination does not have to come from the request at all, use the indirect pattern below - a server-side map from an opaque key to a URL removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.

Where it does, validate with new URI() and check that isAbsolute() is false and getHost() returns null. Use new URI() rather than URI.create(): the constructor throws the checked URISyntaxException that the examples below catch, while URI.create() throws an unchecked IllegalArgumentException that the same catch will not see. For external redirects, use an explicit allowlist of permitted domains with exact host matching after parsing. Reject protocol-relative URLs (//evil.com) and JavaScript URLs (javascript:), and validate 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 jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.IOException;

// 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 validation
  • sendRedirect() accepts any URL including absolute URLs to attacker domains
  • No check for null values causes NullPointerException if 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 returnUrl binds user input directly to parameter
  • "redirect:" + returnUrl allows absolute URLs without restriction
  • Spring's redirect: prefix accepts external URLs without validation
  • Missing default value means null parameter causes errors

The framework itself can trigger this, with no application code in the flow at all. The pattern above assumes application code builds "redirect:" + returnUrl. Two Spring Framework CVEs show the same redirect: sink reached without any string concatenation on the application's part:

  • CVE-2026-41844 - a controller mapped to "/**" without an explicit view name lets Spring resolve the request path itself as the view name. A request to a path such as /redirect:https://evil.example gets resolved by the view resolver straight into a 302 redirect to the external host - nothing in the application ever builds or returns that string. Affects Spring MVC and WebFlux: versions 7.0.0-7.0.7, 6.2.0-6.2.18, 6.1.0-6.1.27, and 5.3.48 and earlier. The publicly available (OSS) fix is Spring Framework 7.0.8 (7.0.x line) and 6.2.19 (6.2.x line). Fixes for 6.1.28 and 5.3.49 also exist but are Enterprise Support Only, not public releases - upgrading a plain OSS dependency on the 6.1.x or 5.3.x line will not reach them without a commercial support contract.
  • CVE-2026-47887 - UrlFilenameViewController passes the request's path segment through as a view name without rejecting redirect: or forward: prefixes, producing the same open redirect (or an internal forward) through a controller that never touches a returnUrl parameter. Affects Spring Framework 5.2.25 through 7.0.8. The only publicly available (OSS) fix is 7.0.9; the other listed patch numbers (7.0.8.1, 6.2.20, 6.1.29, 6.0.31, 5.3.50, 5.2.26) are commercial Enterprise Support backports (for example via HeroDevs), not public releases - do not mistake one of those version numbers for something reachable by bumping a public Maven coordinate.

Auditing "does my code concatenate untrusted input into redirect:" is not enough to close either of these off. Check the Spring Framework version in use, and confirm that any patched version relied on is the OSS release rather than an enterprise-only backport carrying the same-looking number.

A third Spring class of bug reaches the same outcome from the validation side rather than the resolution side: UriComponentsBuilder's regex-based URL parser disagreed with how browsers interpret a backslash, so a host that passed an allowlist check under UriComponentsBuilder could still resolve to an attacker's host when the same URL was actually followed (CVE-2024-22243/22259/22262, fixed in 5.3.34 / 6.0.19 / 6.1.6). See CWE-918 - Java for the mechanism in full - the same parser confusion produces an open redirect here or an SSRF there, depending on whether the validated URL is handed to a Location header or to an outbound HTTP client.

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:// or Https://
  • 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

// SECURE - servlet: java.net.URI parse, then same-site path checks
import jakarta.servlet.annotation.WebServlet;
import jakarta.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) parses the URL and throws URISyntaxException for malformed input instead of accepting it
  • uri.isAbsolute() returns true for URLs with schemes (http://, https://, javascript:), so absolute URLs are rejected
  • uri.getHost() != null detects any URL with a domain/netloc, including protocol-relative URLs like //evil.com
  • startsWith("/") ensures URL is a valid relative path within the application
  • not 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

// SECURE - Spring MVC: validate before building the redirect: view name
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 as the servlet example - parses the URL 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

// SECURE - allowlist of external hosts, exact match after parsing
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); reject userinfo and custom ports
            if (uri.getUserInfo() != null || uri.getPort() != -1) {
                return false;
            }
            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 after parsing, preventing ExAmPlE.cOm bypasses
  • Rejecting userinfo and custom ports avoids ambiguous external redirect destinations
  • 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)

// SECURE - indirect redirect: the request carries a key, never a URL
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 modifications
  • REDIRECT_MAP.get() performs safe lookup with no injection risk
  • Invalid keys (like "<script>alert(1)</script>" or "../../etc/passwd") return null
  • Fail-closed behavior redirects to / for missing/invalid destination IDs
  • Encoding bypasses, protocol tricks, and domain manipulation have no URL in the request to act on
  • Easy to audit - review the REDIRECT_MAP contents and keep mapped destinations local or explicitly trusted

Jakarta EE Filter Pattern

// SECURE - filter replaces a rejected returnUrl through a request wrapper
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;

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)) {
            // Hand the rest of the chain a request whose returnUrl parameter
            // is the safe default. setAttribute("returnUrl", "/") would not
            // do this: attributes and parameters are separate maps, and
            // getParameter() never consults the attribute map, so every
            // servlet downstream would still read the attacker's value.
            chain.doFilter(new SafeReturnUrlRequest(httpRequest), response);
            return;
        }

        chain.doFilter(request, response);
    }

    private static final class SafeReturnUrlRequest extends HttpServletRequestWrapper {

        private static final String[] SAFE = { "/" };

        SafeReturnUrlRequest(HttpServletRequest request) {
            super(request);
        }

        @Override
        public String getParameter(String name) {
            return "returnUrl".equals(name) ? SAFE[0] : super.getParameter(name);
        }

        @Override
        public String[] getParameterValues(String name) {
            return "returnUrl".equals(name) ? SAFE.clone() : super.getParameterValues(name);
        }

        @Override
        public Map<String, String[]> getParameterMap() {
            Map<String, String[]> map = new HashMap<>(super.getParameterMap());
            map.put("returnUrl", SAFE.clone());
            return Map.copyOf(map);
        }
    }
}

Why this works:

  • Filter provides centralized redirect validation across multiple servlets
  • HttpServletRequestWrapper is what actually replaces the value. A filter that calls setAttribute("returnUrl", "/") and then chain.doFilter(request, response) changes nothing at all: the servlet reads getParameter("returnUrl") and gets the original query-string value, because parameters come from the query string and the form body and attributes are a separate map that getParameter() never looks at. The filter compiles, runs, and blocks nothing
  • All three parameter accessors are overridden together, so a servlet reading getParameterValues() or getParameterMap() sees the same replacement as one reading getParameter()
  • Same URI validation logic ensures consistency across the application
  • Replaces invalid URLs with safe default ("/") instead of rejecting requests

This covers code that reads the value through the request object. A servlet that parses getQueryString() itself, or a JSP that reaches for the raw query string, bypasses the wrapper - so treat the filter as a backstop and keep the validation in the redirect path as well.

Warning Page for External URLs

// SECURE - interstitial for allowlisted external destinations
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

Common Pitfalls

  • Copying only the uri.getHost() != null half of the vetted isLocalUrl() check while dropping uri.isAbsolute(). An opaque URI such as javascript:alert(1) has no host component, so getHost() returns null and the trimmed-down check wrongly classifies it as a safe relative path.
  • Fixing every response.sendRedirect() call site a scanner flagged, while a Spring MVC controller elsewhere returns "redirect:" + returnUrl as a view name. The redirect: prefix is a separate sink that Spring's view resolver turns into a redirect, and a sweep focused on sendRedirect() calls typically misses it entirely.
  • Validating returnUrl server-side in the controller, while a JSP or Thymeleaf template independently reads the same request parameter to build a <meta http-equiv="refresh"> tag or a client-side window.location assignment for a "you'll be redirected in 5 seconds" page - the server-side check doesn't cover a template that re-reads the untrusted value on its own.

Testing

A scanner can tell that a validation function is now being called. It cannot tell whether that function is correct, and the interesting inputs here are the ones nobody writes by hand. Assert the classification directly, with a table of inputs and expected verdicts rather than a single happy-path redirect.

For isLocalUrl(), these are the cases worth pinning, all confirmed against the implementation above:

Input Expected Why it is in the list
/dashboard accept The feature has to keep working
/search?q=x&page=2 accept Query strings must survive
//evil.com reject Protocol-relative, the most common bypass
///evil.com reject Some parsers collapse the third slash
http://evil.com reject Plain absolute URL
javascript:alert(1) reject Opaque URI - has no host, so a getHost()-only check admits it
/\evil.com reject Browsers treat the backslash as a separator; new URI() throws here
\\evil.com reject Same, in UNC form
https:/evil.com reject Single slash after the scheme
//evil.com with a leading space reject Leading whitespace, which some frameworks trim later
/p\r\nSet-Cookie: a=b reject CRLF, which would otherwise reach a response header
/<tab>/evil.com reject Sent as /%09/evil.com; getParameter() decodes it. Browsers delete tab, CR and LF before resolving a URL, so this is //evil.com to them. new URI() throws on the control character, which is what closes this case in Java without a separate test; parse_url() and Uri.TryCreate do not, so the PHP and hand-written C# checks reject control characters explicitly instead

For the external allowlist, https://example.com@evil.com/ is the case to assert first: it must be rejected, and a check that compares url.startsWith("https://example.com") accepts it. Also assert https://example.com.evil.com/ and https://evil.com/example.com are rejected while https://EXAMPLE.COM/x is accepted, which together pin suffix matching, path confusion, and case handling.

Assert the accepts as well as the rejects. A validator that returns false unconditionally passes every security case in this table and breaks every login that uses a return URL, and nothing in a re-scan distinguishes the two.

Additional Resources