Incomplete URL validation

Incomplete URL validation

Description

If a user-supplied redirect destination is not adequately validated, an attacker can send users to an unintended site. Domain prefix comparisons or special-character encoding alone do not establish that a destination is permitted.

Potential impact

  • Information exposure: Users sent to a phishing site may disclose credentials or sensitive information.
  • Additional attacks: A URL also inserted into HTML needs separate context-appropriate encoding and scheme validation. A redirect alone does not imply XSS.
  • Malicious redirects: Users may be redirected to an attacker-controlled website.

Remediation

  • Parse the URL or use the framework’s validation function to check the permitted scheme, exact hostname and path policy.
  • Apply encoding appropriate to the output context separately. Encoding does not decide whether an external destination is allowed.
  • Restrict destinations to an allow-list and reject redirects that fail validation.

Examples

Django

Before

python
# Unsafe Django code
from django.shortcuts import redirect

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

After

python
# Safe Django code
from django.http import HttpResponseBadRequest
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 redirect(url)
    return HttpResponseBadRequest("Invalid URL")

Explanation:

  • Before: Unvalidated input is used as the redirect destination, allowing an attacker to direct users to a phishing site.
  • After: Django’s validator allows appropriate relative internal paths or exact allowed hosts using HTTPS. Unlike a prefix comparison, it rejects lookalike domains such as example.com.evil.

Flask

Before

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

app = Flask(__name__)

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

After

python
# Safe Flask code
from flask import Flask, request, redirect, abort
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('/safe')
def safe():
    url = request.args.get('next')
    if is_allowed_redirect(url):
        return redirect(url)
    return abort(400, description="Invalid URL")

Explanation:

  • Before: The redirect destination is taken directly from user input.
  • After: The parsed URL must use HTTPS, an exact allowed hostname, the default HTTPS port and no user information. Invalid destinations are not used.

FastAPI

Before

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

app = FastAPI()

@app.get("/unsafe")
async def unsafe(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("/safe")
async def safe(request: Request):
    url = request.query_params.get('next')
    if is_allowed_redirect(url):
        return RedirectResponse(url)
    raise HTTPException(status_code=400, detail="Invalid URL")

Explanation:

  • Before: Unvalidated input can redirect users to a phishing site.
  • After: The handler checks URL components for HTTPS and the exact allowed hostname, rejecting credentials and nonstandard ports.

References