Open redirect

Validate redirect hosts and schemes to prevent unintended redirects to external sites.

Description

An open redirect occurs when an application uses unvalidated user input to redirect to an external URL. Attackers may exploit it for phishing or to direct users to malicious sites.

Potential impact

  • Phishing: An attacker may trick users into visiting a malicious site and disclosing sensitive information.
  • Loss of trust: Redirects to malicious sites may undermine trust in the application.
  • Policy bypass: An attacker may use a redirect to bypass security policies.

Remediation

  • Restrict redirect destinations to an allow-list.
  • Validate input and use only trusted values for redirects.
  • If only internal navigation is required, use server-defined or validated internal paths. Do not treat a path beginning with // and an external host as an internal path.

Examples

Set allowed hosts for the actual service. Django's home and Flask's index refer to internal routes defined by the application.

Django

Before

python
# Unsafe Django code
from django.http import HttpResponseRedirect

def unsafe_redirect(request):
    url = request.GET.get('next')
    return HttpResponseRedirect(url)

After

python
# Safe Django code
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme

def safe_redirect(request):
    url = request.GET.get('next', '')
    allowed_hosts = {'example.com', 'mysite.com'}
    if url_has_allowed_host_and_scheme(
        url,
        allowed_hosts=allowed_hosts,
        require_https=True,
    ):
        return HttpResponseRedirect(url)
    return redirect('home')

Explanation:

  • Before: Redirects using unvalidated input, allowing open redirects.
  • After: Django's URL validator permits safe relative internal paths or HTTPS URLs with exact allow-listed hostnames. A domain such as https://example.com.evil does not pass merely because it begins with an allowed hostname.

Flask

Before

python
# Unsafe Flask code
from flask import Flask, request, redirect

app = Flask(__name__)

@app.route('/redirect')
def unsafe_redirect():
    url = request.args.get('next')
    return redirect(url)

After

python
# Safe Flask code
from flask import Flask, request, redirect, url_for
from urllib.parse import urlsplit

app = Flask(__name__)
ALLOWED_REDIRECT_HOSTS = frozenset({'example.com', 'mysite.com'})

def is_allowed_redirect(url):
    if not url:
        return False
    try:
        parsed = urlsplit(url)
        port = parsed.port
    except ValueError:
        return False

    return (
        parsed.scheme == 'https'
        and parsed.hostname in ALLOWED_REDIRECT_HOSTS
        and parsed.username is None
        and parsed.password is None
        and port in (None, 443)
    )

@app.route('/redirect')
def safe_redirect():
    url = request.args.get('next')
    if is_allowed_redirect(url):
        return redirect(url)
    return redirect(url_for('index'))

Explanation:

  • Before: Redirects using unvalidated input, allowing open redirects.
  • After: Parses the URL and checks HTTPS, the exact hostname, the default HTTPS port and the absence of user information. Failure redirects to an internal URL generated by the server.

FastAPI

Before

python
# Unsafe FastAPI code
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse

app = FastAPI()

@app.get("/redirect")
async def unsafe_redirect(request: Request):
    url = request.query_params.get('next')
    return RedirectResponse(url)

After

python
# Safe FastAPI code
from fastapi import FastAPI, Request, HTTPException
from starlette.responses import RedirectResponse
from urllib.parse import urlsplit

app = FastAPI()
ALLOWED_REDIRECT_HOSTS = frozenset({'example.com', 'mysite.com'})

def is_allowed_redirect(url):
    if not url:
        return False
    try:
        parsed = urlsplit(url)
        port = parsed.port
    except ValueError:
        return False

    return (
        parsed.scheme == 'https'
        and parsed.hostname in ALLOWED_REDIRECT_HOSTS
        and parsed.username is None
        and parsed.password is None
        and port in (None, 443)
    )

@app.get("/redirect")
async def safe_redirect(request: Request):
    url = request.query_params.get('next')
    if is_allowed_redirect(url):
        return RedirectResponse(url)
    raise HTTPException(status_code=400, detail="Invalid redirect URL")

Explanation:

  • Before: Redirects using unvalidated input, allowing open redirects.
  • After: Validates URL components to allow only HTTPS and exact permitted hosts, rejecting URLs with credentials or nonstandard ports.

References