URL Redirection to Untrusted Site ('Open Redirect')

Description

URL Redirection to Untrusted Site occurs when a web application accepts a user-controlled input that specifies a link to an external site and uses that link to redirect the user. This allows attackers to craft URLs that appear to point to the legitimate site but redirect victims to malicious sites. Open redirects are commonly exploited in phishing attacks, as victims see the trusted domain in the URL before clicking. The vulnerability occurs in login pages, logout handlers, and any functionality that redirects based on user input.

Risk

Open redirects are a significant phishing enabler. Attackers craft URLs like https://trusted-bank.com/redirect?url=https://evil-site.com that victims trust because they see the legitimate domain. After clicking, victims land on attacker-controlled sites mimicking login pages. Open redirects can also be chained with other vulnerabilities—they can bypass SSRF protections, steal OAuth tokens, or facilitate XSS through javascript: URLs. While often rated as "low severity," their role in phishing campaigns makes them impactful.

Solution

Avoid user-controlled redirects when possible. If redirects are necessary, use a whitelist of allowed destinations. Validate redirect URLs against a list of trusted domains. Use indirect references (mapping IDs to URLs server-side) instead of direct URLs. Block javascript:, data:, and other dangerous URL schemes. For relative redirects, ensure the path doesn't contain protocol handlers. Display interstitial warnings before redirecting to external sites.

Common Consequences

ImpactDetails
ReputationScope: Phishing Enablement

Trusted domains used in phishing attacks damage organizational reputation.
ConfidentialityScope: Credential Theft

Users redirected to fake login pages may disclose credentials.
Access ControlScope: Token Theft

OAuth and SSO flows can leak tokens through open redirects.

Example Code + Solution Code

Vulnerable Code

# VULNERABLE: Direct redirect from parameter
from flask import Flask, request, redirect

@app.route('/redirect')
def redirect_handler():
    url = request.args.get('url')
    # Attacker: /redirect?url=https://evil.com
    return redirect(url)

# VULNERABLE: Login redirect
@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    if authenticate(username, password):
        # Attacker: /login?next=https://phishing-site.com
        next_url = request.args.get('next', '/')
        return redirect(next_url)

    return 'Invalid credentials', 401

# VULNERABLE: Logout with redirect
@app.route('/logout')
def logout():
    session.clear()
    # Redirect to attacker site after logout
    return redirect(request.args.get('redirect', '/'))
// VULNERABLE: Servlet redirect
@WebServlet("/redirect")
public class RedirectServlet extends HttpServlet {

    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String url = request.getParameter("url");
        // No validation - redirects anywhere!
        response.sendRedirect(url);
    }
}

// VULNERABLE: Spring Controller
@Controller
public class AuthController {

    @GetMapping("/login")
    public String login(@RequestParam(required = false) String returnUrl) {
        // Store return URL for post-login redirect
        // Attacker: /login?returnUrl=https://evil.com
        return "login";
    }

    @PostMapping("/login")
    public String processLogin(@RequestParam String returnUrl) {
        // After successful login
        return "redirect:" + returnUrl;  // Open redirect!
    }
}
// VULNERABLE: Express redirect
app.get('/goto', (req, res) => {
    const url = req.query.url;
    // Attacker: /goto?url=https://malicious.com
    res.redirect(url);
});

// VULNERABLE: OAuth callback
app.get('/oauth/callback', (req, res) => {
    const code = req.query.code;
    const state = req.query.state;  // Contains redirect URL

    // Exchange code for token...

    // Redirect to state URL - attacker controlled!
    res.redirect(state);
});

// VULNERABLE: Partial validation bypass
app.get('/redirect', (req, res) => {
    let url = req.query.url;

    // Weak check - can be bypassed
    if (url.includes('trusted.com')) {
        res.redirect(url);  // trusted.com.evil.com bypasses!
    }
});

Fixed Code

# SAFE: Whitelist allowed redirect destinations
from flask import Flask, request, redirect, abort
from urllib.parse import urlparse

ALLOWED_HOSTS = {'example.com', 'www.example.com', 'app.example.com'}

def is_safe_url(url):
    """Check if URL is safe for redirect."""
    if not url:
        return False

    # Allow relative URLs
    if url.startswith('/') and not url.startswith('//'):
        return True

    # Parse and validate absolute URLs
    try:
        parsed = urlparse(url)
        # Must be http or https
        if parsed.scheme not in ('http', 'https'):
            return False
        # Must be in allowed hosts
        return parsed.netloc in ALLOWED_HOSTS
    except:
        return False

@app.route('/redirect')
def redirect_handler():
    url = request.args.get('url', '/')

    if not is_safe_url(url):
        abort(400, 'Invalid redirect URL')

    return redirect(url)

# SAFE: Use indirect references
REDIRECT_MAPPING = {
    'home': '/',
    'dashboard': '/dashboard',
    'profile': '/user/profile',
    'settings': '/user/settings',
}

@app.route('/goto/<destination>')
def safe_redirect(destination):
    url = REDIRECT_MAPPING.get(destination)
    if not url:
        abort(404, 'Unknown destination')
    return redirect(url)

# SAFE: Login with validated redirect
@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']

    if authenticate(username, password):
        next_url = request.args.get('next', '/')

        # Validate redirect URL
        if not is_safe_url(next_url):
            next_url = '/'  # Default to home

        return redirect(next_url)

    return 'Invalid credentials', 401
// SAFE: Whitelist validation
@WebServlet("/redirect")
public class SafeRedirectServlet extends HttpServlet {

    private static final Set<String> ALLOWED_DOMAINS = Set.of(
        "example.com",
        "www.example.com",
        "app.example.com"
    );

    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        String url = request.getParameter("url");

        if (!isValidRedirectUrl(url)) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid redirect URL");
            return;
        }

        response.sendRedirect(url);
    }

    private boolean isValidRedirectUrl(String url) {
        if (url == null || url.isEmpty()) {
            return false;
        }

        // Allow relative URLs starting with /
        if (url.startsWith("/") && !url.startsWith("//")) {
            return true;
        }

        try {
            URL parsedUrl = new URL(url);
            String protocol = parsedUrl.getProtocol();
            String host = parsedUrl.getHost();

            // Only allow http/https
            if (!protocol.equals("http") && !protocol.equals("https")) {
                return false;
            }

            // Check against whitelist
            return ALLOWED_DOMAINS.contains(host.toLowerCase());
        } catch (MalformedURLException e) {
            return false;
        }
    }
}

// SAFE: Spring Security approach
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .formLogin()
                .successHandler(new SafeAuthenticationSuccessHandler())
            .and()
            .logout()
                .logoutSuccessHandler(new SafeLogoutSuccessHandler());
    }
}

public class SafeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication authentication) throws IOException {

        String targetUrl = request.getParameter("redirect");

        // Validate or use default
        if (!isValidUrl(targetUrl)) {
            targetUrl = "/dashboard";
        }

        redirectStrategy.sendRedirect(request, response, targetUrl);
    }
}
// SAFE: Whitelist validation
const ALLOWED_HOSTS = new Set([
    'example.com',
    'www.example.com',
    'app.example.com'
]);

function isValidRedirectUrl(url) {
    if (!url) return false;

    // Allow relative URLs
    if (url.startsWith('/') && !url.startsWith('//')) {
        return true;
    }

    try {
        const parsed = new URL(url);

        // Only allow http/https
        if (!['http:', 'https:'].includes(parsed.protocol)) {
            return false;
        }

        // Check whitelist
        return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
    } catch {
        return false;
    }
}

app.get('/goto', (req, res) => {
    const url = req.query.url || '/';

    if (!isValidRedirectUrl(url)) {
        return res.status(400).send('Invalid redirect URL');
    }

    res.redirect(url);
});

// SAFE: OAuth with state validation
const crypto = require('crypto');

app.get('/oauth/start', (req, res) => {
    // Generate secure state with embedded return URL
    const returnUrl = req.query.return || '/';
    const state = crypto.randomBytes(16).toString('hex');

    // Store mapping server-side
    req.session.oauthState = {
        token: state,
        returnUrl: isValidRedirectUrl(returnUrl) ? returnUrl : '/'
    };

    // Redirect to OAuth provider with state token only
    res.redirect(`https://oauth.provider.com/auth?state=${state}&...`);
});

app.get('/oauth/callback', (req, res) => {
    const { code, state } = req.query;

    // Validate state matches session
    if (!req.session.oauthState || req.session.oauthState.token !== state) {
        return res.status(400).send('Invalid state');
    }

    // Get validated return URL from session, not request
    const returnUrl = req.session.oauthState.returnUrl;
    delete req.session.oauthState;

    // Exchange code for token...

    res.redirect(returnUrl);
});

Exploited in the Wild

Google Open Redirect (Multiple)

Google has had multiple open redirect vulnerabilities over the years in various services. Attackers used these in phishing campaigns, leveraging Google's trusted domain.

Facebook OAuth Open Redirect (2019)

An open redirect in Facebook's OAuth flow allowed attackers to steal access tokens by redirecting the OAuth callback to attacker-controlled sites.

PayPal Phishing via Open Redirect

PayPal has faced numerous phishing campaigns exploiting open redirects, where emails with legitimate PayPal URLs ultimately directed users to credential-harvesting sites.


Tools to test/exploit


CVE Examples


References

  1. MITRE. "CWE-601: URL Redirection to Untrusted Site." https://cwe.mitre.org/data/definitions/601.html

  2. OWASP. "Unvalidated Redirects and Forwards Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html