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

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

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 throws URISyntaxException for malformed input, preventing parsing attacks
  • uri.isAbsolute() returns true for URLs with schemes (http://, https://, javascript:), allowing rejection of absolute URLs
  • 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

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, preventing ExAmPlE.cOm bypasses
  • 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 modifications
  • REDIRECT_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 returnUrl parameters 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

Additional Resources