TRENDING
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
A raft of long grey wooden logs lying in a green meadow, with a forked post standing in the middle
October 28, 2026
How to Build the Raft Consensus Algorithm in Python and Test It With Crashes, Partitions, and Planted Bugs
Open handwritten account book from the Brussels Confraternity of the Seven Sorrows (1499 to 1516), with entries running down each page and blank space below them
October 28, 2026
Anthropic’s S-1 Puts $52.9 Billion More on Three Named Compute Commitments Than Its October 9 Draft Did, and Still Leaves Its Contractual-Obligations Table Blank
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Two earthworms lying together on wet brown leaf litter, photographed close up
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 268 Posts
News 271 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Stop Open Redirects in a FastAPI Login Flow and Check the next Parameter the Way a Browser Reads It
Learning Hub

How to Stop Open Redirects in a FastAPI Login Flow and Check the next Parameter the Way a Browser Reads It

Learn why startswith, urlparse and host-allowlist checks still let open redirects through a FastAPI login page, then build, fuzz and test a path-only check against a real Edge browser.

October 23, 2026 39 Min Read
31

After you sign in to a website, it usually sends you back to the page you were trying to reach. The address of that page often travels inside the link itself, for example https://app.example/login?next=/orders. If the server redirects to whatever the next parameter says, anyone can write a link that starts with the real site’s name and ends on a different site. That bug is called an open redirect. This tutorial shows you how to find it in a FastAPI login page, how to fix it properly, and how to prove the fix with a real browser instead of trusting your own reading of the URL rules.

Table Of Content

  • What you will be able to do
  • Four ideas to know first
  • The redirect response
  • The parts of an address
  • Two parsers that do not agree
  • Why a link on your own domain is not harmless
  • Prerequisites
  • Step 1: Build the vulnerable site and attack it
  • 1a. Install the packages
  • 1b. Plumbing: two websites on one machine
  • 1c. A login page that trusts next
  • 1d. Play the attacker’s link
  • What the output teaches
  • Common mistakes in this step
  • Step 2: See how Python and a browser read the same address
  • What the output teaches
  • Common mistakes in this step
  • Step 3: Three checks people write
  • Step 4: A check that does not depend on the parsers agreeing
  • Common mistakes in this step
  • Step 5: Put every check in front of a real browser
  • What the table teaches
  • Common mistakes in this step
  • Step 6: Does the framework save you?
  • Step 7: Fuzz the checks with the browser as judge
  • What the output teaches
  • Common mistakes in this step
  • Step 8: Lock it in with tests
  • Step 9: Find the other redirects in your code
  • Where else the same bug hides
  • Quick reference: the checklist
  • Confirm the whole thing works
  • Where to go next
  • Sources

It matters because the obvious fixes fail in ways that are hard to see. Checking that the address starts with a slash, that Python finds no host in it, or that its host is on your own list all look correct, and each one can be bypassed by an address that Python reads as a harmless path and a browser reads as another website. The OWASP Unvalidated Redirects and Forwards Cheat Sheet explains the damage: “Because the server name in the modified link is identical to the original site, phishing attempts may have a more trustworthy appearance.” You will not have to take any of this on trust. You will send 22 test addresses through four checks and Django’s own helper, in two styles of redirect, and then 35,338 random strings, with Microsoft Edge deciding where each one really goes.

What you will be able to do

  • Explain what an open redirect is and why a link on your own domain can still send people somewhere else.
  • Read an address the way a browser does, and name the rules (backslashes, extra slashes, hidden tab characters, a scheme without slashes) that make browsers and Python disagree.
  • Reproduce the attack against a FastAPI login page and watch Edge follow it.
  • Break three common checks and see exactly which addresses get through each one, in a table produced by a real browser.
  • Build a path-only check and a named-destination mechanism that none of the test addresses can get past, and fuzz the check with tens of thousands of strings.
  • Pin the behavior with 29 tests and six deliberate bugs, and find the other redirects in your own code with a short audit script.

Four ideas to know first

The redirect response

When a server wants the browser to go somewhere else, it answers with a status code in the 3xx range and a Location header. After a form is submitted the usual choice is 303 See Other, which RFC 9110 describes like this: a user agent “can perform a retrieval request targeting that URI (a GET or HEAD request if using HTTP)”. The header value is an address. The same RFC defines the Location field as Location = URI-reference and says that when the value is a relative reference, “the final value is computed by resolving it against the target URI”. In plain words, the server may send a short address such as /dashboard, and the browser fills in the scheme and the host from the page it is already on.

The parts of an address

An address (a URL) has parts, and an attacker only needs to change one of them: the host.

Part Example in https://app.example:8000/orders?id=5#items What it decides
scheme https: how to talk to the server
host (and port) app.example:8000 which server answers, the part an attacker wants to replace
path /orders which page on that server
query ?id=5 extra input for the page
fragment #items a position inside the page, handled by the browser

A relative reference leaves some of those parts out. RFC 3986 names the two forms that matter here: “A relative reference that begins with two slash characters is termed a network-path reference; such references are rarely used. A relative reference that begins with a single slash character is termed an absolute-path reference.” That is the first thing to remember. /orders stays on the current site. //evil.test/orders, with two slashes, keeps the scheme and replaces the host.

Two parsers that do not agree

Your server code and the browser each turn the same string into those parts, and they follow different rules. Browsers follow the WHATWG URL Standard. Among other things it says to “Remove any leading and trailing C0 control or space from input” and to “Remove all ASCII tab or newline from input”, and it treats a backslash like a slash in addresses with a special scheme such as http or https (look for the “relative slash state” and the “authority state”). The urllib.parse documentation is candid about the gap: the WHATWG standard “describes what user facing web clients such as a web browser require”, RFC 3986 “is more general”, and Python’s functions “incorporate some aspects of both, but cannot be claimed compliant with either”. Python has adopted some browser rules, since its documentation says that “leading C0 control and space characters are stripped from the URL” and that newline, carriage return and tab characters are “removed from the URL at any position”. It has not adopted all of them. The same page recommends that you “code defensively” and do “some verification within your code before trusting a returned component part”.

A check that reads the string with one parser while the browser acts on it with the other can be fooled by exactly the strings on which the two disagree. Security people call this a parser differential. The rest of this tutorial is about finding those strings and building a check that does not depend on the parsers agreeing.

Why a link on your own domain is not harmless

An open redirect does not read your database, which is why it is often waved through in reviews. It lends your domain’s reputation to someone else’s page. It also feeds bigger attacks. RFC 9700, the OAuth 2.0 security best practice, says that “Clients and authorization servers MUST NOT expose URLs that forward the user’s browser to arbitrary URIs obtained from a query parameter (open redirectors)” and that “Open redirectors can enable exfiltration of authorization codes and access tokens.” The same document tells authorization servers to compare redirect URIs by “exact string matching except for port numbers in localhost redirection URIs of native apps”. The authorization server in our OAuth 2.0 PKCE tutorial does that by storing each client’s redirect URIs as a set of exact strings.

Prerequisites

  • Python 3.12 or newer. Starlette 1.9.0 and httpx2 2.16.0 need 3.11, and Django 6.1.2, which only the comparison steps use, needs 3.12. Everything here ran on Python 3.13.14, Windows 11.
  • These packages: FastAPI 0.161.1 (which brings Starlette 1.9.0), uvicorn 0.54.0, python-multipart 0.0.32 (FastAPI needs it to read form fields), httpx2 2.16.0 (Starlette’s test client needs it), requests 2.34.2 and pytest 9.1.1. Playwright 1.64.1 and Django 6.1.2 are needed only for the measurement steps.
  • Microsoft Edge. I used version 155.0.4283.62, driven by Playwright with channel="msedge", which, as the Playwright documentation puts it, “can operate against the branded Google Chrome and Microsoft Edge browsers available on the machine”, so there is no browser download. Chrome has its own channel name; I only ran Edge, so treat every browser result below as an Edge result.
  • curl, which comes with Windows 10 and later, macOS and most Linux systems.
  • You know how to run a Python script and what an HTML form and an HTTP status code are. Everything else is explained as it appears.
  • The lab starts and stops its own server. On Windows it stops uvicorn with taskkill; on other systems it calls terminate().

Step 1: Build the vulnerable site and attack it

You will start with a login page that does what many tutorials do: it redirects to next without asking where it points. Seeing the attack work once makes every later step concrete.

1a. Install the packages

Create a folder for the lab, open a terminal in it, and run these commands. The first creates an isolated Python environment, the second switches to it, and the third installs the pinned packages.

python -m venv labvenv
labvenv\Scripts\activate
pip install fastapi==0.161.1 uvicorn==0.54.0 python-multipart==0.0.32 httpx2==2.16.0 requests==2.34.2 pytest==9.1.1 playwright==1.64.1 django==6.1.2

# macOS or Linux: source labvenv/bin/activate

Verify it worked: pip list should show fastapi 0.161.1 and starlette 1.9.0.

1b. Plumbing: two websites on one machine

To watch a real browser follow a redirect to “another site” without owning one, the lab gives Edge two made-up host names and points both at your own machine. app.test is the site you are protecting. evil.test stands for the attacker’s site. The Chromium switch --host-resolver-rules makes both names resolve to 127.0.0.1, so a single server on port 8000 answers for both and the Host header tells it which name the browser used. Nothing leaves your computer.

"""labkit.py: start and stop a lab server, and launch the installed Edge with two made-up host names pointed at it."""
import os
import subprocess
import sys
import time

import requests

LAB = os.path.dirname(os.path.abspath(__file__))
PORT = 8000
BASE = f"http://app.test:{PORT}"  # the site we protect
RULES = "MAP app.test 127.0.0.1, MAP evil.test 127.0.0.1"  # both names reach this machine; only the Host header differs


class Server:
    """with Server("app") as s: ...   starts uvicorn on 127.0.0.1:8000 and stops it again."""

    def __init__(self, module: str):
        self.module = module
        self.proc = None

    def __enter__(self):
        os.makedirs(os.path.join(LAB, "out"), exist_ok=True)
        log = open(os.path.join(LAB, "out", f"uvicorn_{self.module}.log"), "w", encoding="utf-8")
        self.proc = subprocess.Popen(
            [sys.executable, "-m", "uvicorn", f"{self.module}:app", "--host", "127.0.0.1", "--port", str(PORT), "--log-level", "warning"],
            cwd=LAB, stdout=log, stderr=log)
        deadline = time.time() + 40
        while True:
            try:
                requests.get(f"http://127.0.0.1:{PORT}/ping", timeout=2)
                return self
            except requests.RequestException:
                if time.time() > deadline:
                    raise SystemExit("server did not start")
                time.sleep(0.3)

    def __exit__(self, *exc):
        if os.name == "nt":  # the venv's python.exe is a launcher, so stop the whole process tree
            subprocess.run(["taskkill", "/PID", str(self.proc.pid), "/T", "/F"], capture_output=True)
        else:
            self.proc.terminate()
        self.proc.wait(timeout=20)


def launch_edge(playwright):
    """Headless Edge (the one already installed); app.test and evil.test both resolve to 127.0.0.1."""
    return playwright.chromium.launch(channel="msedge", headless=True, args=[f"--host-resolver-rules={RULES}"])

Server starts uvicorn for a module and stops it afterwards. launch_edge starts a headless Edge with the host rule. Every later script uses these two helpers.

Next, the pieces both versions of the site share: a sign-in form that carries the next value in a hidden field, one demo account, and a catch-all route that answers every other path with the host name it was reached through.

"""shared.py: the pieces both versions of the app have in common."""
import html

from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse

USER = ("alice", "wonderland")  # the one account of this demo site


def login_page(next_value: str, error: str = "") -> str:
    """A sign-in form that carries the `next` value along in a hidden field (escaped, because it is user input)."""
    return f"""<!doctype html>
<title>Sign in</title>
<h1>Sign in to app.test</h1>
<form method="post" action="/login">
  <input name="username" value="alice">
  <input name="password" type="password">
  <input type="hidden" name="next" value="{html.escape(next_value, quote=True)}">
  <button>Sign in</button>
</form>{error}
"""


def add_landing_pages(app: FastAPI) -> None:
    """Every other path answers with the Host header it was reached through, so a redirect to another site is easy to see."""

    @app.get("/{path:path}", response_class=PlainTextResponse)
    def landing(path: str, request: Request):
        return f"reached host={request.headers.get('host')} path=/{path}"

Notice that login_page escapes the next value with html.escape before putting it into the form, because it is user input going into HTML. That defends against script injection. It does nothing about redirects, which is a different problem, so you need both.

1c. A login page that trusts next

Here is the vulnerable site. GET /login shows the form, and POST /login checks the demo account and then redirects.

"""app_v1.py: a login page that trusts the `next` parameter."""
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse

from shared import USER, add_landing_pages, login_page

app = FastAPI()


@app.get("/login", response_class=HTMLResponse)
def show_login(next: str = "/dashboard"):
    return login_page(next)


@app.post("/login")
def do_login(username: str = Form(), password: str = Form(), next: str = Form("/dashboard")):
    if (username, password) != USER:
        return HTMLResponse(login_page(next, "<p>Wrong user name or password.</p>"), status_code=401)
    return RedirectResponse(next, status_code=303)  # 303 See Other: the browser now GETs the new address


add_landing_pages(app)

The status 303 tells the browser to fetch the new address with a plain GET, which is the usual way to finish a form submission. The bug is the last line of do_login: RedirectResponse(next, ...) sends the browser wherever next says, and nothing in that line asks where that is.

1d. Play the attacker’s link

The script below plays the attack twice. First it sends the login form with curl and prints the two lines of the response that matter. Then it plays a victim in Edge: the victim opens the attacker’s link, signs in on the real site, and the script records every request the browser makes.

"""flow_demo.py [module]: the attack from the victim's side (default app_v1; pass app for the fixed site). Needs: pip install playwright."""
import subprocess
import sys
from urllib.parse import quote

from playwright.sync_api import sync_playwright

from labkit import BASE, PORT, Server, launch_edge

LINK = f"{BASE}/login?next=" + quote("//evil.test/fake-login", safe="")

with Server(sys.argv[1] if len(sys.argv) > 1 else "app_v1"):
    print("--- 1. what the server sends back (curl) ---")
    out = subprocess.run(
        ["curl", "-s", "-i", "-X", "POST", f"http://127.0.0.1:{PORT}/login", "-d", "username=alice", "-d", "password=wonderland",
         "--data-urlencode", "next=//evil.test/fake-login"], capture_output=True, text=True).stdout
    for line in out.splitlines():
        if line.startswith("HTTP/") or line.lower().startswith("location:"):
            print(line)
    print()
    print("--- 2. what a victim's browser does (Edge) ---")
    with sync_playwright() as p:
        browser = launch_edge(p)
        page = browser.new_page()
        log = []
        page.on("response", lambda r: log.append(f"{r.request.method:4} {r.url} -> {r.status}" + (f"  Location: {r.headers['location']}" if "location" in r.headers else "")))
        page.on("requestfailed", lambda r: log.append(f"{r.method:4} {r.url} -> failed: {r.failure}"))
        page.goto(LINK)
        log.append("--- the victim signs in on the real site ---")
        page.fill("input[name=username]", "alice")
        page.fill("input[name=password]", "wonderland")
        page.click("button")
        page.wait_for_timeout(1500)
        shown = []
        for line in log:
            if "favicon" not in line and line not in shown:
                shown.append(line)
                print(line)
        browser.close()
python flow_demo.py

You should see this:

--- 1. what the server sends back (curl) ---
HTTP/1.1 303 See Other
location: //evil.test/fake-login

--- 2. what a victim's browser does (Edge) ---
GET  http://app.test:8000/login?next=%2F%2Fevil.test%2Ffake-login -> 200
--- the victim signs in on the real site ---
POST http://app.test:8000/login -> 303  Location: //evil.test/fake-login
GET  http://evil.test/fake-login -> failed: net::ERR_CONNECTION_REFUSED

What the output teaches

  • The server answered 303 See Other with location: //evil.test/fake-login. There is no scheme, so it looks like a path, but it starts with two slashes, so by RFC 3986 it is a network-path reference.
  • Edge followed it. The last request goes to evil.test. Nothing listens there in this lab, so Edge reports net::ERR_CONNECTION_REFUSED, but a real attacker’s page would answer. The victim started on the real site, typed a real password into the real form, and was handed to someone else’s page by the site itself. A page that says “your session expired, please sign in again” is the obvious next move.
  • The address in the link began with the real site’s name, which is what makes this attack useful to phishers.

Common mistakes in this step

  • Testing only with https://evil.test. As you will see in Step 5, every check in this tutorial except “no check” stops that one, so a test suite built on it passes while the code is still open.
  • Assuming the user would notice. The browser’s address bar shows the real site until the very moment of the redirect.

Step 2: See how Python and a browser read the same address

Before writing any check, find out where the two parsers disagree. The file below holds 22 addresses in three families: L for legitimate targets your site must keep working, S for strings that look dangerous but are plain paths (percent-encoded backslash, slash and tab), and A for attacks. Each attack tries a different trick.

"""payloads.py: the redirect targets every step uses. L = legitimate, S = looks scary but is only a path, A = attack."""

LEGIT = [
    ("L1", "/dashboard", "plain path"),
    ("L2", "/orders?id=5&tab=items", "path with a query"),
    ("L3", "/search?q=a//b", "double slash inside the query"),
    ("L4", "/files/2026//report.pdf", "double slash inside the path"),
    ("L5", "/reports/Q3%202026", "percent-encoded space"),
    ("L6", "/profile#settings", "fragment"),
    ("L7", "http://app.test:8000/dashboard", "absolute URL to our own host"),
]

LOOKALIKES = [
    ("S1", "/%5Cevil.test/login", "percent-encoded backslash"),
    ("S2", "/%2F/evil.test/login", "percent-encoded slash"),
    ("S3", "/%09/evil.test/login", "percent-encoded tab"),
]

ATTACKS = [
    ("A01", "https://evil.test/login", "absolute URL to another site"),
    ("A02", "//evil.test/login", "network-path reference (two slashes)"),
    ("A03", "///evil.test/login", "three slashes"),
    ("A04", "/\\evil.test/login", "slash, then backslash"),
    ("A05", "\\\\evil.test/login", "two backslashes"),
    ("A06", "\\/evil.test/login", "backslash, then slash"),
    ("A07", "https:evil.test/login", "scheme with no slashes"),
    ("A08", "https:/evil.test/login", "scheme with one slash"),
    ("A09", "https://app.test:[email protected]/login", "our host as the user name"),
    ("A10", "https://evil.test\\@app.test:8000/login", "backslash before the @"),
    ("A11", "/\t/evil.test/login", "a tab between the slashes"),
    ("A12", "javascript:alert(1)", "script scheme"),
]

ALL = LEGIT + LOOKALIKES + ATTACKS


def show(payload: str) -> str:
    """The payload as a person would type it, with the tab made visible."""
    return payload.replace("\t", "<TAB>")

This script asks Python’s urlsplit and Edge’s new URL(...) to read each address, using the login page as the base. It needs no server: Edge parses the strings in a blank page.

"""parse_table.py: how Python's urlsplit and a real Edge each read the same string. No server needed."""
from urllib.parse import urlsplit

from playwright.sync_api import sync_playwright

from labkit import BASE, launch_edge
from payloads import ALL, show

JS = """(args) => args[0].map(s => { try { const u = new URL(s, args[1]); return [u.protocol, u.hostname]; } catch (e) { return ["error", ""]; } })"""
OUR_HOST = "app.test"

with sync_playwright() as p:
    browser = launch_edge(p)
    page = browser.new_page()
    page.goto("about:blank")
    edge = page.evaluate(JS, [[payload for _, payload, _ in ALL], BASE + "/login"])
    print(f"{'id':4} {'payload':42} {'Python urlsplit reads':33} {'Edge new URL() reads':28} verdict")
    for (pid, payload, note), (protocol, host) in zip(ALL, edge):
        parts = urlsplit(payload)
        py_host = parts.hostname if parts.netloc else "(none)"
        py = f"scheme={parts.scheme or '-'} host={py_host}"
        ed = f"{protocol} host={host or '-'}"
        if protocol not in ("http:", "https:"):
            verdict = "not an http(s) URL"
        elif host == OUR_HOST:
            verdict = "stays on our site"
        else:
            verdict = "LEAVES our site" + ("" if py_host not in ("(none)", OUR_HOST) else "  <- Python did not see this")
        print(f"{pid:4} {show(payload):42} {py:33} {ed:28} {verdict}")

    print()
    print("A scheme with no slashes depends on the page that carries it:")
    for base in ("http://app.test:8000/login", "https://app.test/login"):
        for target in ("https:evil.test/x", "http:evil.test/x"):
            href = page.evaluate("(a) => new URL(a[0], a[1]).href", [target, base])
            print(f"  page {base:28} + {target:18} -> {href}")

    print()
    print("A check written in JavaScript can use the same parser the navigation uses:")
    print("  new URL(next, location.origin).origin === location.origin")
    same = page.evaluate("""(args) => args[0].map(s => { try { return new URL(s, args[1]).origin === args[1]; } catch (e) { return false; } })""",
                         [[payload for _, payload, _ in ALL], "http://app.test:8000"])
    print("  allowed:", " ".join(pid for (pid, _, _), ok in zip(ALL, same) if ok))
    print("  refused:", " ".join(pid for (pid, _, _), ok in zip(ALL, same) if not ok))
    browser.close()
python parse_table.py

The first table:

id   payload                                    Python urlsplit reads             Edge new URL() reads         verdict
L1   /dashboard                                 scheme=- host=(none)              http: host=app.test          stays on our site
L2   /orders?id=5&tab=items                     scheme=- host=(none)              http: host=app.test          stays on our site
L3   /search?q=a//b                             scheme=- host=(none)              http: host=app.test          stays on our site
L4   /files/2026//report.pdf                    scheme=- host=(none)              http: host=app.test          stays on our site
L5   /reports/Q3%202026                         scheme=- host=(none)              http: host=app.test          stays on our site
L6   /profile#settings                          scheme=- host=(none)              http: host=app.test          stays on our site
L7   http://app.test:8000/dashboard             scheme=http host=app.test         http: host=app.test          stays on our site
S1   /%5Cevil.test/login                        scheme=- host=(none)              http: host=app.test          stays on our site
S2   /%2F/evil.test/login                       scheme=- host=(none)              http: host=app.test          stays on our site
S3   /%09/evil.test/login                       scheme=- host=(none)              http: host=app.test          stays on our site
A01  https://evil.test/login                    scheme=https host=evil.test       https: host=evil.test        LEAVES our site
A02  //evil.test/login                          scheme=- host=evil.test           http: host=evil.test         LEAVES our site
A03  ///evil.test/login                         scheme=- host=(none)              http: host=evil.test         LEAVES our site  <- Python did not see this
A04  /\evil.test/login                          scheme=- host=(none)              http: host=evil.test         LEAVES our site  <- Python did not see this
A05  \\evil.test/login                          scheme=- host=(none)              file: host=evil.test         not an http(s) URL
A06  \/evil.test/login                          scheme=- host=(none)              http: host=evil.test         LEAVES our site  <- Python did not see this
A07  https:evil.test/login                      scheme=https host=(none)          https: host=evil.test        LEAVES our site  <- Python did not see this
A08  https:/evil.test/login                     scheme=https host=(none)          https: host=evil.test        LEAVES our site  <- Python did not see this
A09  https://app.test:[email protected]/login      scheme=https host=evil.test       https: host=evil.test        LEAVES our site
A10  https://evil.test\@app.test:8000/login     scheme=https host=app.test        https: host=evil.test        LEAVES our site  <- Python did not see this
A11  /<TAB>/evil.test/login                     scheme=- host=evil.test           http: host=evil.test         LEAVES our site
A12  javascript:alert(1)                        scheme=javascript host=(none)     javascript: host=-           not an http(s) URL

What the output teaches

  • Rows A01 and A02 are read the same way by both parsers: an absolute address and a two-slash address both name evil.test. Any check that looks at the host Python reports will catch these two.
  • Rows A03, A04, A06, A07, A08 and A10 are the dangerous ones, marked “Python did not see this”. In A03 the browser skips every extra slash before the host. In A04, A06 and A10 it treats the backslash as a slash, and in A10 the backslash ends the host before the @, so Python thinks the host is app.test while the browser goes to evil.test. In A07 and A08 Python sees a scheme and a path, while Edge reads https:evil.test/login, on a page served over http, as a full address.
  • Row A11 has a tab between the slashes. Both parsers remove the tab, so both see evil.test. A check that looks at the first characters of the raw string does not.
  • Row A05 (two backslashes) is an Edge-on-Windows detail. Edge read it as a file: address, which cannot be followed from a web page (Step 5 shows the browser refusing it). The WHATWG algorithm would make it an http address on evil.test, so do not count on this quirk to protect you.
  • Rows S1 to S3 stay on the site: a percent-encoded backslash, slash or tab is just text in a path.

The same script also shows that a scheme without slashes depends on the page that carries it:

A scheme with no slashes depends on the page that carries it:
  page http://app.test:8000/login   + https:evil.test/x  -> https://evil.test/x
  page http://app.test:8000/login   + http:evil.test/x   -> http://app.test:8000/evil.test/x
  page https://app.test/login       + https:evil.test/x  -> https://app.test/evil.test/x
  page https://app.test/login       + http:evil.test/x   -> http://evil.test/x

On an http page, https:evil.test/x leaves the site and http:evil.test/x becomes a path. On an https page it is the other way around. A relative reference may repeat the current page’s scheme, so only the other scheme is a different site. A check that blocks only one of the two spellings fails in production when your site moves from http to https, or the other way around.

The last lines show something useful for later. A check written in JavaScript can use the very parser that performs the navigation:

A check written in JavaScript can use the same parser the navigation uses:
  new URL(next, location.origin).origin === location.origin
  allowed: L1 L2 L3 L4 L5 L6 L7 S1 S2 S3
  refused: A01 A02 A03 A04 A05 A06 A07 A08 A09 A10 A11 A12

That one-liner allowed all ten addresses that stay on the site and refused all twelve attacks. Keep it for client-side redirects in Step 9.

Common mistakes in this step

  • Unit-testing a validator with the same parser the validator uses. The test cannot find a string that the parser reads wrongly, because the test reads it wrongly too.
  • Assuming these differences are historical. They are in the current Python documentation and the current WHATWG standard, and Edge 155 reproduced them.

Step 3: Three checks people write

Here are the three checks that appear most often in answers and code reviews. Each is a short function that takes the raw next value and answers one question: may we redirect to it?

"""validators.py: four checks for a redirect target (the first three are the ones people usually write), plus named destinations.

Every check takes the raw `next` value and answers one question: may we redirect to it?
"""
import re
from urllib.parse import urlparse

OUR_HOSTS = {"app.test"}  # the host names this site answers to (no port)


def starts_with_slash(value: str) -> bool:
    """Check 1: only allow paths such as /dashboard."""
    return value.startswith("/")


def no_host_part(value: str) -> bool:
    """Check 2: allow anything Python believes has no host (netloc) part."""
    return urlparse(value).netloc == ""


def host_allowlist(value: str) -> bool:
    """Check 3: relative URLs are fine; absolute ones must name one of our hosts."""
    parts = urlparse(value)
    if parts.netloc == "":
        return True
    return parts.scheme in ("http", "https") and parts.hostname in OUR_HOSTS

starts_with_slash is the first idea: only allow paths. no_host_part asks Python whether the address has a host (called netloc in urllib.parse) and allows it only if it has none. host_allowlist is the check people reach for when they also want to allow absolute links to their own site: relative addresses are fine, absolute ones must name a host from a list.

Before you run anything, use the output of Step 2 to predict how these three will fare. Which addresses does each one accept? Write your answer down. Step 5 puts them in front of a browser.

Step 4: A check that does not depend on the parsers agreeing

The three checks above ask a parser what the address means. The check below does not ask. It accepts only strings that cannot contain a host at all, so the browser has nothing to disagree about. Add the rest of validators.py now.

# validators.py (continued)
MAX_LENGTH = 2048
# One leading slash that is not followed by another slash, then only characters that RFC 3986
# allows in a path, query or fragment. No backslash, space, control character or non-ASCII letter.
_SAFE_PATH = re.compile(r"/(?!/)[A-Za-z0-9._~!$&'()*+,;=:@%/?#-]*")


def strict_path(value: str) -> bool:
    """Check 4: a same-site path spelled with a short list of safe characters, and nothing else."""
    return len(value) <= MAX_LENGTH and _SAFE_PATH.fullmatch(value) is not None


def safe_next(value: str | None, default: str = "/dashboard") -> str:
    """The value to redirect to: the caller's path if it passes strict_path, otherwise the default."""
    if value is not None and strict_path(value):
        return value
    return default


# Better still: the caller names a destination and the server maps the name to a path.
DESTINATIONS = {"dashboard": "/dashboard", "orders": "/orders", "settings": "/settings/profile"}


def destination(name: str | None, default: str = "/dashboard") -> str:
    return DESTINATIONS.get(name or "", default)


CHECKS = {
    "none": lambda value: True,
    "starts_with_slash": starts_with_slash,
    "no_host_part": no_host_part,
    "host_allowlist": host_allowlist,
    "strict_path": strict_path,
}

Each part of the regular expression closes one of the tricks from Step 2.

  1. /(?!/) means one leading slash that is not followed by another slash. That rejects //evil.test and ///evil.test, and every string that does not start with a slash, such as https:evil.test and javascript:alert(1).
  2. The character list contains what RFC 3986 allows in a path, query or fragment, plus the # that starts a fragment and the % that starts a percent-encoded byte: letters, digits, ._~, !$&'()*+,;=, :@, /, ?, # and %. There is no backslash, space, tab, newline, bracket or non-ASCII character. Every special rule from Step 2 (backslash as slash, tab removal, control-character stripping) needs a character that is not on the list.
  3. fullmatch instead of match with a $: the Python re documentation says that $ “Matches the end of the string or just before the newline at the end of the string”, so a pattern that ends in $ would let /dashboard followed by a newline through. Step 8 proves that a test notices.
  4. The length cap keeps the check cheap and the redirect sane. 2048 is a round number, not a rule.
  5. safe_next returns the caller’s value only if it passes and otherwise a default. It never raises and never explains, so an attacker gets no feedback.

The check is deliberately narrow. It refuses an absolute address to your own site, a path with a raw space (the encoded %20 works), and any non-ASCII character. If your site needs more, add one character at a time and re-run the fuzz in Step 7.

The better option, when you control both ends, is not to accept an address at all. OWASP’s advice is: “Where possible, have the user provide short name, ID or token which is mapped server-side to a full target URL. This provides the highest degree of protection against the attack tampering with the URL.” That is what DESTINATIONS and destination do. The caller sends orders, the server answers /orders, and an unknown name gets the default. Here is the fixed site, with both mechanisms in place:

"""app.py: the same site with a checked `next` parameter and a named destination after logout."""
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse, RedirectResponse

from shared import USER, add_landing_pages, login_page
from validators import destination, safe_next

app = FastAPI()


@app.get("/login", response_class=HTMLResponse)
def show_login(next: str = "/dashboard"):
    return login_page(next)


@app.post("/login")
def do_login(username: str = Form(), password: str = Form(), next: str = Form("/dashboard")):
    if (username, password) != USER:
        return HTMLResponse(login_page(next, "<p>Wrong user name or password.</p>"), status_code=401)
    return RedirectResponse(safe_next(next), status_code=303)  # checked where it is used


@app.get("/logout")
def logout(to: str | None = None):
    return RedirectResponse(destination(to), status_code=303)  # the caller sends a name, never a URL


add_landing_pages(app)

The login form still accepts a next path because that is what users expect, and the value is checked at the point where it is used. The logout route takes a destination name and never an address.

Common mistakes in this step

  • Validating in one place and redirecting in another. The hidden next field comes back from the browser on POST, so an attacker can change it after you validated it when rendering the form. Validate where you redirect.
  • Changing the value after checking it. OWASP’s guidance is to “redirect using the validated URL rather than transforming it afterward”. Decoding, trimming or joining after the check reopens whatever the check closed.
  • Adding “absolute URLs to our own host” to the allowed set to be helpful. That brings the whole host-parsing problem back.

Step 5: Put every check in front of a real browser

Now the experiment. The lab server below has one extra route that applies any check by name and then redirects in one of two styles. quoted uses RedirectResponse, which is what most FastAPI code does. raw writes the Location header exactly as given, which is what you get when code builds the response by hand. The Django column is a maintained helper, url_has_allowed_host_and_scheme, added for comparison with the host app.test:8000 allowed.

"""lab_server.py: lab-only app. /lab/go?check=NAME&style=raw|quoted&next=URL applies one check and then redirects."""
from django.utils.http import url_has_allowed_host_and_scheme  # a maintained helper, for comparison
from fastapi import FastAPI, Response
from fastapi.responses import RedirectResponse

from shared import add_landing_pages
from validators import CHECKS as OUR_CHECKS

CHECKS = dict(OUR_CHECKS, django=lambda value: url_has_allowed_host_and_scheme(value, allowed_hosts={"app.test:8000"}))
app = FastAPI()


@app.get("/lab/go")
def go(check: str, style: str, next: str):
    if not CHECKS[check](next):
        return RedirectResponse("/blocked", status_code=303)
    if style == "quoted":
        return RedirectResponse(next, status_code=303)  # what most FastAPI code does
    return Response(status_code=303, headers={"location": next})  # the header exactly as written


add_landing_pages(app)

The browser script sends all 22 addresses through all six columns in both styles, 264 real navigations, and watches the requests Edge makes. The cell says ok when the browser ends on app.test, block when the check refused and the site sent the visitor to its own /blocked page, LEAK when the browser went to another host, and refused when the browser itself would not follow the redirect. At the end it also checks whether new URL() would have predicted the real redirect, because Step 7 relies on that.

"""browser_matrix.py: send every payload through every check and watch where a real Edge ends up."""
import re
from urllib.parse import quote, urlsplit

from playwright.sync_api import sync_playwright
from starlette.responses import RedirectResponse

from lab_server import CHECKS
from labkit import BASE, Server, launch_edge
from payloads import ALL, show

NAMES = list(CHECKS)
SHORT = {"none": "none", "starts_with_slash": "slash", "no_host_part": "nohost", "host_allowlist": "allow", "strict_path": "strict", "django": "django"}
JS = """(args) => args[0].map(s => { try { const u = new URL(s, args[1]); return [u.protocol, u.hostname]; } catch (e) { return ["error", ""]; } })"""
refused = {}


def run_case(page, reqs, check, style, pid, payload):
    reqs.clear()
    url = f"{BASE}/lab/go?check={check}&style={style}&next={quote(payload, safe='')}"
    try:
        page.goto(url, wait_until="commit", timeout=8000)
    except Exception as exc:  # a refused or failed navigation raises; the request list still tells us where the browser went
        code = re.search(r"net::ERR_\w+", str(exc))
        if len(reqs) == 1 and check == "none" and code:
            refused[(pid, style)] = code.group(0)
    hops = [u for u in reqs if not u.endswith("/favicon.ico")]
    if len(hops) <= 1:
        return "refused"
    last = urlsplit(hops[-1])
    if last.hostname == "app.test" and last.path == "/blocked":
        return "block"
    return "ok" if last.hostname == "app.test" else "LEAK"


with Server("lab_server"), sync_playwright() as p:
    browser = launch_edge(p)
    page = browser.new_page()
    reqs = []
    page.on("request", lambda r: reqs.append(r.url))
    agree = total = 0
    for style, title in (("quoted", "RedirectResponse(next): the style most FastAPI code uses"), ("raw", "Response(headers={'location': next}): the header exactly as written")):
        print(f"=== {title} ===")
        print(f"{'id':4} {'payload':42} " + " ".join(f"{SHORT[n]:>7}" for n in NAMES))
        truth = {}
        for pid, payload, note in ALL:
            cells = []
            for name in NAMES:
                cell = run_case(page, reqs, name, style, pid, payload)
                cells.append(cell)
                if name == "none":
                    truth[pid] = cell
            print(f"{pid:4} {show(payload):42} " + " ".join(f"{c:>7}" for c in cells))
        # does new URL() predict the real redirect? (the fuzz test relies on it)
        sent = [RedirectResponse(pl).headers["location"] if style == "quoted" else pl for _, pl, _ in ALL]
        page.goto("about:blank")
        parsed = page.evaluate(JS, [sent, BASE + "/lab/go"])
        for (pid, payload, _), (protocol, host) in zip(ALL, parsed):
            predicted = "refused" if protocol not in ("http:", "https:") else ("ok" if host == "app.test" else "LEAK")
            total += 1
            agree += predicted == truth[pid]
            if predicted != truth[pid]:
                print(f"   new URL() disagrees for {pid}: predicted {predicted}, real redirect {truth[pid]}")
        print()
    print(f"new URL() predicted the real redirect on {agree} of {total} cases")
    print("redirects the browser itself refused to follow:")
    for (pid, style), code in sorted(refused.items()):
        print(f"  {pid} {style:7} {code}")
    browser.close()
python browser_matrix.py

First the style most FastAPI code uses:

=== RedirectResponse(next): the style most FastAPI code uses ===
id   payload                                       none   slash  nohost   allow  strict  django
L1   /dashboard                                      ok      ok      ok      ok      ok      ok
L2   /orders?id=5&tab=items                          ok      ok      ok      ok      ok      ok
L3   /search?q=a//b                                  ok      ok      ok      ok      ok      ok
L4   /files/2026//report.pdf                         ok      ok      ok      ok      ok      ok
L5   /reports/Q3%202026                              ok      ok      ok      ok      ok      ok
L6   /profile#settings                               ok      ok      ok      ok      ok      ok
L7   http://app.test:8000/dashboard                  ok   block   block      ok   block      ok
S1   /%5Cevil.test/login                             ok      ok      ok      ok      ok      ok
S2   /%2F/evil.test/login                            ok      ok      ok      ok      ok      ok
S3   /%09/evil.test/login                            ok      ok      ok      ok      ok      ok
A01  https://evil.test/login                       LEAK   block   block   block   block   block
A02  //evil.test/login                             LEAK    LEAK   block   block   block   block
A03  ///evil.test/login                            LEAK    LEAK    LEAK    LEAK   block   block
A04  /\evil.test/login                               ok      ok      ok      ok   block   block
A05  \\evil.test/login                               ok   block      ok      ok   block   block
A06  \/evil.test/login                               ok   block      ok      ok   block   block
A07  https:evil.test/login                         LEAK   block    LEAK    LEAK   block   block
A08  https:/evil.test/login                        LEAK   block    LEAK    LEAK   block   block
A09  https://app.test:[email protected]/login         LEAK   block   block   block   block   block
A10  https://evil.test\@app.test:8000/login          ok   block   block      ok   block   block
A11  /<TAB>/evil.test/login                          ok      ok   block   block   block   block
A12  javascript:alert(1)                        refused   block refused refused   block   block

Then the header exactly as written:

=== Response(headers={'location': next}): the header exactly as written ===
id   payload                                       none   slash  nohost   allow  strict  django
L1   /dashboard                                      ok      ok      ok      ok      ok      ok
L2   /orders?id=5&tab=items                          ok      ok      ok      ok      ok      ok
L3   /search?q=a//b                                  ok      ok      ok      ok      ok      ok
L4   /files/2026//report.pdf                         ok      ok      ok      ok      ok      ok
L5   /reports/Q3%202026                              ok      ok      ok      ok      ok      ok
L6   /profile#settings                               ok      ok      ok      ok      ok      ok
L7   http://app.test:8000/dashboard                  ok   block   block      ok   block      ok
S1   /%5Cevil.test/login                             ok      ok      ok      ok      ok      ok
S2   /%2F/evil.test/login                            ok      ok      ok      ok      ok      ok
S3   /%09/evil.test/login                            ok      ok      ok      ok      ok      ok
A01  https://evil.test/login                       LEAK   block   block   block   block   block
A02  //evil.test/login                             LEAK    LEAK   block   block   block   block
A03  ///evil.test/login                            LEAK    LEAK    LEAK    LEAK   block   block
A04  /\evil.test/login                             LEAK    LEAK    LEAK    LEAK   block   block
A05  \\evil.test/login                          refused   block refused refused   block   block
A06  \/evil.test/login                             LEAK   block    LEAK    LEAK   block   block
A07  https:evil.test/login                         LEAK   block    LEAK    LEAK   block   block
A08  https:/evil.test/login                        LEAK   block    LEAK    LEAK   block   block
A09  https://app.test:[email protected]/login         LEAK   block   block   block   block   block
A10  https://evil.test\@app.test:8000/login        LEAK   block   block    LEAK   block   block
A11  /<TAB>/evil.test/login                        LEAK    LEAK   block   block   block   block
A12  javascript:alert(1)                        refused   block refused refused   block   block

And the closing lines of the output:

new URL() predicted the real redirect on 44 of 44 cases
redirects the browser itself refused to follow:
  A05 raw     net::ERR_UNSAFE_REDIRECT
  A12 quoted  net::ERR_ABORTED
  A12 raw     net::ERR_ABORTED

What the table teaches

  • Without a check (none), 6 of the 12 attack addresses leave the site through RedirectResponse, and 10 do when the header is written as given. A05 (as written) and A12 are refused by the browser itself, with net::ERR_UNSAFE_REDIRECT and net::ERR_ABORTED, as the list at the end of the output shows.
  • starts_with_slash stops the absolute address (A01), everything that begins with a letter or a backslash, and the legitimate absolute address to our own host (L7, a false positive). It leaks //evil.test/login and ///evil.test/login (A02, A03) in both styles, and the backslash and tab forms (A04, A11) when the header is written as given. A leading slash is the one thing a network-path reference has in common with a path.
  • no_host_part fixes the two-slash form because Python does see a host there. It leaks A03, A07 and A08 in both styles, and A04 and A06 as written. Python reports no host for three slashes, for a backslash and for a scheme without slashes, and Edge reports one. It also accepts javascript:alert(1) (A12). The browser will not follow that as a redirect, but it is a string the check approved.
  • host_allowlist leaks the same three addresses as no_host_part in the first style and one more, A10, in the second. The allowlist protects the branch Python already understands and leaves the relative branch as open as check 2.
  • strict_path and Django’s helper let none of the 12 attacks through in either style. strict_path accepts the plain paths and the three look-alikes, and refuses L7 on purpose. Django’s helper accepts L7, which is allowed by design.
  • The two absolute addresses, A01 and A09, are the only attacks that all four checks refuse outright. They are also the first thing most hand-written tests try, so a suite that tries only those passes all four checks.
  • new URL() predicted the real redirect in 44 of 44 cases (22 addresses, two styles). That makes it a trustworthy judge for the fuzz test in Step 7.

Common mistakes in this step

  • Reading only the first table and concluding that the raw bugs do not matter. They matter the day someone changes how the header is written, adds a proxy that rewrites it, or moves the code to another framework.
  • Ignoring the false positives. A check that blocks your own legitimate links will be “fixed” by whoever gets the bug report, usually by loosening it.

Step 6: Does the framework save you?

Partly, and that is the trap. The first table was much shorter than the second because RedirectResponse changes the address before it becomes a header. This script shows what it does.

"""step_quote.py: what Starlette's RedirectResponse does to the address before it becomes a Location header."""
import inspect

import starlette
from starlette.responses import RedirectResponse

from payloads import ALL, show

print("starlette", starlette.__version__)
for line in inspect.getsource(RedirectResponse.__init__).splitlines():
    if "quote" in line:
        print("source:", line.strip())
print()
changed = same = 0
for pid, payload, note in ALL:
    sent = RedirectResponse(payload).headers["location"]
    if sent != payload:
        changed += 1
        print(f"{pid:4} {show(payload):40} -> {sent}")
    else:
        same += 1
print()
print(f"changed by RedirectResponse: {changed}   sent as written: {same}")
print("sent as written:", " ".join(pid for pid, payload, _ in ALL if RedirectResponse(payload).headers["location"] == payload))
python step_quote.py
starlette 1.9.0
source: self.headers["location"] = quote(str(url), safe=":/%#?=@[]!$&'()*+,;")

A04  /\evil.test/login                        -> /%5Cevil.test/login
A05  \\evil.test/login                        -> %5C%5Cevil.test/login
A06  \/evil.test/login                        -> %5C/evil.test/login
A10  https://evil.test\@app.test:8000/login   -> https://evil.test%[email protected]:8000/login
A11  /<TAB>/evil.test/login                   -> /%09/evil.test/login

changed by RedirectResponse: 5   sent as written: 17
sent as written: L1 L2 L3 L4 L5 L6 L7 S1 S2 S3 A01 A02 A03 A07 A08 A09 A12

Starlette 1.9.0 runs the address through quote with a list of safe characters that does not include the backslash, the space or the tab, so those become %5C, %20 and %09. Five of the 22 addresses change, which is why the backslash and tab tricks (A04, A05, A06, A10, A11) are harmless through RedirectResponse. The other 17 go out as written, including //evil.test/login, ///evil.test/login, https:evil.test/login and the absolute address. Quoting is a useful accident, not a defense: it does not touch the most common bypass, and it depends on one line of a library you do not control.

Django’s helper makes the same assumption about its own framework. Its docstring says that a True result does not mean the address is safe: “It may still be e.g. quoted incorrectly.” Django’s redirect response applies iri_to_uri to the address and raises an error for a scheme other than http, https or ftp, as you can read in django/http/response.py. The helper’s source also shows what a maintained check has to know. One comment says that “Chrome treats \ completely as / in paths but it could be part of some basic auth credentials so we need to check both URLs”, and another says that “Chrome considers any URL with more than two slashes to be absolute, but urlsplit is not so flexible”. Both are rows of the table you just produced. You can read them in django/utils/http.py. The Starlette line is in starlette/responses.py.

Step 7: Fuzz the checks with the browser as judge

Twenty-two addresses prove that the checks behave on the tricks you thought of. They say nothing about the tricks you did not. This script builds random strings of one to seven tokens from a list of 30 URL-flavored ones (slashes, backslashes, colons, @, dots, tabs, spaces, percent-encoded slashes, backslashes and tabs, scheme prefixes, evil.test, app.test, javascript: and three non-ASCII look-alikes for the dot and the slash), runs every string through every check, and asks Edge where the string would go. A check leaks a string when it accepts it and Edge sends the browser to an http or https host other than app.test. The seed is fixed, so a re-run on the same Python version gives the same strings. For the as-written column the script skips strings that contain characters outside Latin-1, because a header value cannot carry them.

"""fuzz_browser.py: 50,000 random strings through every check, with Edge's URL parser as the judge."""
import random

from django.utils.encoding import iri_to_uri
from django.utils.http import url_has_allowed_host_and_scheme
from playwright.sync_api import sync_playwright
from starlette.responses import RedirectResponse

from labkit import BASE, launch_edge
from payloads import show
from validators import CHECKS

SEED, DRAWS = 20261023, 50000
TOKENS = ["/", "/", "//", "\\", ":", "@", ".", "?", "#", "\t", " ", "%2f", "%5c", "%09", "https:", "http:", "evil.test",
          "app.test", "app.test:8000", "x", "a", "0", "-", "[", "]", "%", "javascript:", chr(0x3002), chr(0xFF0F), chr(0x2215)]
JS = """(args) => args[0].map(s => { try { const u = new URL(s, args[1]); return [u.protocol, u.hostname]; } catch (e) { return ["error", ""]; } })"""

rng = random.Random(SEED)
strings = list(dict.fromkeys("".join(rng.choice(TOKENS) for _ in range(rng.randint(1, 7))) for _ in range(DRAWS)))
checks = dict(CHECKS)
checks.pop("none")
checks["django"] = lambda s: url_has_allowed_host_and_scheme(s, allowed_hosts={"app.test:8000"})


def accepted(fn, s):
    try:
        return fn(s)
    except ValueError:  # urlparse refuses some inputs (for example a lone '['); treat that as "rejected"
        return False


def judge(page, urls):
    out = []
    for i in range(0, len(urls), 10000):
        out += page.evaluate(JS, [urls[i:i + 10000], BASE + "/lab/go"])
    return out


def leaves(results, idx):
    return [i for i in idx if results[i][0] in ("http:", "https:") and results[i][1] != "app.test"]


with sync_playwright() as p:
    browser = launch_edge(p)
    page = browser.new_page()
    page.goto("about:blank")
    fits_latin1 = [max(map(ord, s)) < 256 for s in strings]  # a header value must fit in Latin-1 to be sent as written
    raw_res = judge(page, strings)
    starlette_res = judge(page, [RedirectResponse(s).headers["location"] for s in strings])
    django_res = judge(page, [iri_to_uri(s) for s in strings])  # Django's own redirect response quotes with iri_to_uri
    print(f"seed {SEED}: {len(strings)} distinct strings from {DRAWS} draws, Edge {browser.version}")
    print(f"{'check':18} {'accepted':>8} | {'leave site':^17} | {'reach evil.test':^17} | {'non-http scheme':^17}")
    print(f"{'':18} {'':>8} | {'raw':>8} {'quoted':>8} | {'raw':>8} {'quoted':>8} | {'raw':>8} {'quoted':>8}")
    detail = {}
    for name, fn in checks.items():
        quoted_res = django_res if name == "django" else starlette_res
        acc = [i for i, s in enumerate(strings) if accepted(fn, s)]
        raw_acc = [i for i in acc if fits_latin1[i]]
        lr, lq = leaves(raw_res, raw_acc), leaves(quoted_res, acc)
        er = [i for i in lr if raw_res[i][1] == "evil.test"]
        eq = [i for i in lq if quoted_res[i][1] == "evil.test"]
        sr = [i for i in raw_acc if raw_res[i][0] not in ("http:", "https:", "error")]
        sq = [i for i in acc if quoted_res[i][0] not in ("http:", "https:", "error")]
        print(f"{name:18} {len(acc):>8} | {len(lr):>8} {len(lq):>8} | {len(er):>8} {len(eq):>8} | {len(sr):>8} {len(sq):>8}")
        detail[name] = (er, eq, lr, lq, raw_res, quoted_res)
    print()
    for name, (er, eq, lr, lq, rr, qr) in detail.items():
        short_raw = sorted((strings[i] for i in er), key=len)[:1]
        short_q = sorted((strings[i] for i in eq), key=len)[:1]
        if short_raw or short_q:
            print(f"{name}: shortest string that reaches evil.test  raw: {[show(s) for s in short_raw]}  quoted: {[show(s) for s in short_q]}")
    print()
    _, _, lr, lq, rr, qr = detail["django"]
    print("every string Django's helper accepted that still left the site:")
    for i in lr:
        print(f"  raw    {show(strings[i])!r:36} Edge host: {rr[i][1]!r}")
    for i in lq:
        print(f"  quoted {show(strings[i])!r:36} Edge host: {qr[i][1]!r}")
    browser.close()
python fuzz_browser.py
seed 20261023: 35338 distinct strings from 50000 draws, Edge 155.0.4283.62
check              accepted |    leave site     |  reach evil.test  |  non-http scheme 
                            |      raw   quoted |      raw   quoted |      raw   quoted
starts_with_slash      3465 |      238      267 |       12        5 |        0        0
no_host_part          34050 |      260      231 |       12        6 |     2450     3211
host_allowlist        34053 |      260      231 |       12        6 |     2450     3211
strict_path             606 |        0        0 |        0        0 |        0        0
django                27766 |        2        2 |        0        0 |        3        0

starts_with_slash: shortest string that reaches evil.test  raw: ['//evil.test']  quoted: ['//evil.test']
no_host_part: shortest string that reaches evil.test  raw: ['/\\evil.test']  quoted: ['https:evil.test']
host_allowlist: shortest string that reaches evil.test  raw: ['/\\evil.test']  quoted: ['https:evil.test']

every string Django's helper accepted that still left the site:
  raw    '/<TAB>//./javascript:'              Edge host: '.'
  raw    '/<TAB>\\\\evil.test0https:'         Edge host: 'evil.test0https'
  quoted '// '                                Edge host: '%20'
  quoted '//  '                               Edge host: '%20%20'

What the output teaches

  • The seed gave 35,338 distinct strings. starts_with_slash accepted 3,465 and leaked 238 (as written) and 267 (quoted), of which 12 and 5 went to evil.test itself. The shortest is //evil.test.
  • no_host_part and host_allowlist accepted over 34,000 strings, and leaked 260 as written and 231 quoted, 12 and 6 of them to evil.test. In this sample the allowlist did not change the leak counts. Both checks also accepted thousands of strings that Edge reads with a scheme other than http or https (2,450 and 3,211), which is the “non-http scheme” column.
  • strict_path accepted 606 strings and leaked none, in either style. This is evidence, not a proof. The argument for it is the one in Step 4: the browser decides the host from the first characters, and a single leading slash, no backslash and no control character leave it no way to find one.
  • Django’s helper accepted 27,766 strings and let 2 through in each style. The two as-written ones put a tab right after the first slash, which its three-slash test (made on the string before the tab is removed) cannot see, and Django’s own response percent-encodes a tab, so a Django app does not send it as written. The two quoted ones are // followed by one or two spaces, which Edge reports as the host %20 or %20%20. Those look like parsing leftovers rather than hosts an attacker could point at a server, but the helper is still wrong about them, so I count them. I am not claiming a Django bug: the docstring itself tells callers to use iri_to_uri on untrusted URLs as well.
  • The price of the narrow check is visible too: 606 accepted against 27,766. Django hands many more strings to the browser to interpret. Whether that trade suits you depends on how many legitimate redirect shapes your site really has.

Common mistakes in this step

  • Judging a check by how many strings it blocks. Measure how many leak and how many legitimate addresses it still accepts.
  • Using the same parser as judge and as check, which is the mistake from Step 2 again. The judge here is the browser.

Step 8: Lock it in with tests

The tests below run against the fixed site through Starlette’s test client. They send each of the 12 attacks and expect the default page, send the legitimate and look-alike paths and expect them back unchanged, expect the absolute address to be refused, replay 4,000 seeded random strings, and cover the edge cases: a trailing newline, a missing value, an over-long value, unknown destination names, the logout route, a wrong password and HTML escaping in the login form.

"""test_redirect.py: the final app never redirects off site, and still sends people where they were going."""
import random
from urllib.parse import urlsplit

import pytest
from starlette.testclient import TestClient

import app as app_module
from payloads import ATTACKS, LEGIT, LOOKALIKES
from validators import DESTINATIONS, MAX_LENGTH, destination, safe_next, strict_path

client = TestClient(app_module.app, follow_redirects=False)
LOGIN = {"username": "alice", "password": "wonderland"}
DEFAULT = "/dashboard"


def post_login(next_value):
    return client.post("/login", data={**LOGIN, "next": next_value})


@pytest.mark.parametrize("pid,payload,note", ATTACKS, ids=[a[0] for a in ATTACKS])
def test_attack_goes_to_the_default_page(pid, payload, note):
    response = post_login(payload)
    assert response.status_code == 303
    assert response.headers["location"] == DEFAULT


@pytest.mark.parametrize("pid,payload,note", LEGIT + LOOKALIKES, ids=[a[0] for a in LEGIT + LOOKALIKES])
def test_real_paths_still_work(pid, payload, note):
    response = post_login(payload)
    assert response.status_code == 303
    expected = payload if payload.startswith("/") else DEFAULT  # an absolute URL is refused on purpose
    assert response.headers["location"] == expected


def test_random_strings_never_leave_the_site():
    rng = random.Random(7)
    alphabet = "/\\:@.?#%\t aAbB0-_~;=&+!$'()*,[]" + chr(0x3002) + chr(0xFF0F)
    for _ in range(4000):
        value = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 12)))
        result = safe_next(value, default="/home")
        assert result == "/home" or (result == value and value.startswith("/") and not value.startswith("//"))
        parts = urlsplit(result)
        assert parts.scheme == "" and parts.netloc == ""


def test_trailing_newline_is_rejected():
    assert safe_next("/dashboard\n", default="/home") == "/home"


def test_missing_empty_and_overlong_values_use_the_default():
    assert safe_next(None, default="/home") == "/home"
    assert safe_next("", default="/home") == "/home"
    assert safe_next("/" + "a" * MAX_LENGTH, default="/home") == "/home"
    assert strict_path("/" + "a" * (MAX_LENGTH - 1))


def test_named_destinations():
    for name, path in DESTINATIONS.items():
        assert destination(name) == path
    assert destination(None) == DEFAULT
    assert destination("https://evil.test/") == DEFAULT
    assert destination("../etc/passwd") == DEFAULT


def test_logout_uses_names_only():
    assert client.get("/logout", params={"to": "orders"}).headers["location"] == "/orders"
    assert client.get("/logout", params={"to": "https://evil.test/"}).headers["location"] == DEFAULT
    assert client.get("/logout").headers["location"] == DEFAULT


def test_wrong_password_does_not_redirect():
    response = client.post("/login", data={"username": "alice", "password": "nope", "next": "/orders"})
    assert response.status_code == 401
    assert "location" not in response.headers


def test_login_page_escapes_the_next_value():
    page = client.get("/login", params={"next": '"><script>alert(1)</script>'}).text
    assert "<script>" not in page
    assert "&quot;&gt;&lt;script&gt;" in page
python -m pytest -q test_redirect.py
python mutations.py
............................. [100%]
29 passed in 0.32s

A test suite that cannot fail proves nothing, so the next script breaks validators.py six ways, one at a time, and counts the failing tests each time. It restores the file afterwards.

"""mutations.py: break validators.py six ways and check that the tests notice every time."""
import os
import re
import subprocess
import sys

LAB = os.path.dirname(os.path.abspath(__file__))
TARGET = os.path.join(LAB, "validators.py")
MUTANTS = [
    ("allow backslash", "=:@%/?#-]*", "=:@%/?#\\\\-]*"),
    ("allow a second leading slash", 'r"/(?!/)[', 'r"/['),
    ("use $ instead of fullmatch", "_SAFE_PATH.fullmatch(value) is not None", 're.match(_SAFE_PATH.pattern + "$", value) is not None'),
    ("drop the length cap", "len(value) <= MAX_LENGTH and ", ""),
    ("allow spaces and tabs", "=:@%/?#-]*", "=:@%/?#\\s-]*"),
    ("fall back to the caller's value", "    return default\n\n\n# Better still", "    return value or default\n\n\n# Better still"),
]
original = open(TARGET, encoding="utf-8", newline="").read()
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", COLUMNS="170")


def run_tests():
    p = subprocess.run([sys.executable, "-m", "pytest", "-q", "test_redirect.py", "-p", "no:cacheprovider"],
                       cwd=LAB, env=env, capture_output=True, text=True)
    last = (p.stdout.strip().splitlines() or [""])[-1]
    failed = re.search(r"(\d+) failed", last)
    return int(failed.group(1)) if failed else 0, last


try:
    base_failed, base_line = run_tests()
    print(f"unmodified code: {base_failed} failing tests ({base_line})")
    caught = 0
    for name, old, new in MUTANTS:
        assert original.count(old) == 1, f"{name}: pattern found {original.count(old)} times"
        with open(TARGET, "w", encoding="utf-8", newline="") as f:
            f.write(original.replace(old, new))
        failed, line = run_tests()
        caught += failed > 0
        print(f"{name:34} -> {failed:2} failing tests {'(caught)' if failed else '(NOT CAUGHT)'}")
    print(f"{caught} of {len(MUTANTS)} mutants caught")
finally:
    with open(TARGET, "w", encoding="utf-8", newline="") as f:
        f.write(original)
unmodified code: 0 failing tests (29 passed in 0.31s)
allow backslash                    ->  1 failing tests (caught)
allow a second leading slash       ->  3 failing tests (caught)
use $ instead of fullmatch         ->  1 failing tests (caught)
drop the length cap                ->  1 failing tests (caught)
allow spaces and tabs              ->  2 failing tests (caught)
fall back to the caller's value    -> 16 failing tests (caught)
6 of 6 mutants caught

All six mistakes are caught. The most instructive one is the mutant that uses $ instead of fullmatch: a single test, the trailing-newline test, stands between it and production. The last mutant fails 16 tests, among them all 12 attack tests, because every rejected value now comes back unchanged.

Step 9: Find the other redirects in your code

The login page is rarely the only place. Search for every redirect whose address is not a literal. The script below walks the syntax tree of the files you give it and reports each RedirectResponse whose address is neither a string literal nor the result of one of our helpers, and each response with a hand-written Location header.

"""audit_redirects.py FILE...: list every redirect whose address is neither a literal nor produced by one of our safe helpers."""
import ast
import sys

SAFE_CALLS = {"safe_next", "destination", "url_for"}  # helpers that return a path we built or checked
RESPONSES = {"Response", "HTMLResponse", "PlainTextResponse", "JSONResponse"}


def name_of(node):
    return node.id if isinstance(node, ast.Name) else node.attr if isinstance(node, ast.Attribute) else ""


def is_safe(arg):
    if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
        return True
    return isinstance(arg, ast.Call) and name_of(arg.func) in SAFE_CALLS


def findings(path):
    tree = ast.parse(open(path, encoding="utf-8").read())
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        fn = name_of(node.func)
        if fn == "RedirectResponse":
            arg = node.args[0] if node.args else next((k.value for k in node.keywords if k.arg == "url"), None)
            if arg is not None and not is_safe(arg):
                yield node.lineno, f"RedirectResponse({ast.unparse(arg)})"
        elif fn in RESPONSES:  # a Location header written by hand
            for k in node.keywords:
                if k.arg == "headers" and isinstance(k.value, ast.Dict):
                    for key, value in zip(k.value.keys, k.value.values):
                        if isinstance(key, ast.Constant) and str(key.value).lower() == "location" and not is_safe(value):
                            yield node.lineno, f"{fn}(headers={{'location': {ast.unparse(value)}}})"


total = 0
for path in sys.argv[1:]:
    found = sorted(findings(path))
    for line, text in found:
        print(f"{path}:{line}: {text}")
    if not found:
        print(f"{path}: nothing to review")
    total += len(found)
print(f"{total} redirect(s) to review")
python audit_redirects.py app_v1.py app.py lab_server.py
app_v1.py:19: RedirectResponse(next)
app.py: nothing to review
lab_server.py:18: RedirectResponse(next)
lab_server.py:19: Response(headers={'location': next})
3 redirect(s) to review

It flags the vulnerable site and the two lab-only redirects, and finds nothing to review in the fixed site. The script is a starting point, not a verdict: it cannot follow a value through three function calls, and it only knows FastAPI’s response classes. Adapt the list for your framework.

Where else the same bug hides

  • Logout and return-to links. Use destination names, as the fixed /logout route does.
  • OAuth and single sign-on. Compare redirect URIs with exact string matching, as RFC 9700 requires, and never expose an endpoint that forwards to a URL from a query parameter. See our OAuth 2.0 PKCE tutorial.
  • Links that leave your site on purpose (click trackers and external-link pages). OWASP’s suggestion is to “force all redirects to first go through a page notifying users that they are going off of your site, with the destination clearly displayed, and have them click a link to confirm.” For approved destinations, store the full address in configuration and look it up by name.
  • Redirects written in JavaScript. If a single-page app sends the user to a next value, check it in the browser with new URL(next, location.origin).origin === location.origin. Step 2 showed that this one-liner allowed the ten addresses that stay on the site and refused all twelve attacks.
  • The Host header. A redirect or a link built from the request’s own Host header is a cousin of this bug. Our password reset tutorial measures it.

Quick reference: the checklist

Rule Why Where you saw it
Prefer a name to an address A name cannot point anywhere you did not choose Step 4, destination
If you must accept an address, accept only a path with one leading slash and a short character list No character is left that a browser treats specially Steps 4 and 7, strict_path
Check where you redirect, and redirect with the checked value unchanged The hidden field comes back from the browser; transforming after the check reopens the hole Step 4, mistakes
Do not count on the framework’s quoting It changed 5 of 22 addresses and left the common bypass alone Step 6
Test with several attacks, not one Every check except none stopped the absolute address Step 5
Let a browser be the judge Python’s parser and the browser disagree on the strings that matter Steps 2 and 7
Compare external destinations as exact strings, or add a warning page Parsing a host is where checks fail Step 9, RFC 9700 and OWASP
Audit every redirect, not only the login page The bug lives wherever an address comes from a request Step 9

Confirm the whole thing works

This script runs every step of the lab in order and saves each output to out/NAME.txt. Run it from the lab folder.

"""run_all.py: run every step and save its output to out/NAME.txt. Run from this folder: python run_all.py [step names]"""
import os
import subprocess
import sys
import time

LAB = os.path.dirname(os.path.abspath(__file__))
STEPS = [
    ("step1_flow", ["flow_demo.py"]),
    ("step2_parse", ["parse_table.py"]),
    ("step3_quote", ["step_quote.py"]),
    ("step4_matrix", ["browser_matrix.py"]),
    ("step5_fuzz", ["fuzz_browser.py"]),
    ("step6_tests", ["-m", "pytest", "-q", "test_redirect.py", "-p", "no:cacheprovider"]),
    ("step7_mutations", ["mutations.py"]),
    ("step8_audit", ["audit_redirects.py", "app_v1.py", "app.py", "lab_server.py"]),
    ("step9_fixed", ["flow_demo.py", "app"]),
]
os.makedirs(os.path.join(LAB, "out"), exist_ok=True)
env = dict(os.environ, PYTHONIOENCODING="utf-8", PYTHONDONTWRITEBYTECODE="1", COLUMNS="170")
wanted = set(sys.argv[1:])
for name, args in STEPS:
    if wanted and name not in wanted:
        continue
    start = time.time()
    p = subprocess.run([sys.executable] + args, cwd=LAB, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
    text = (p.stdout or "") + (("\n[stderr]\n" + p.stderr) if p.stderr.strip() else "")
    with open(os.path.join(LAB, "out", name + ".txt"), "w", encoding="utf-8", newline="\n") as f:
        f.write(text)
    lines = text.strip().splitlines()
    print(f"{name}: exit {p.returncode}, {time.time() - start:.1f}s, {len(lines)} lines; last: {lines[-1][:110] if lines else ''}")
python run_all.py

It takes under a minute on my machine. Every step should exit with code 0, the tests should report 29 passed, and the mutation run should report 6 of 6 mutants caught. Finally, replay the attack from Step 1 against the fixed site:

python flow_demo.py app
--- 1. what the server sends back (curl) ---
HTTP/1.1 303 See Other
location: /dashboard

--- 2. what a victim's browser does (Edge) ---
GET  http://app.test:8000/login?next=%2F%2Fevil.test%2Ffake-login -> 200
--- the victim signs in on the real site ---
POST http://app.test:8000/login -> 303  Location: /dashboard
GET  http://app.test:8000/dashboard -> 200

The server now answers location: /dashboard. The victim signs in and ends up on the site’s own dashboard, and the request to evil.test is gone. That is the whole difference between Step 1 and Step 9.

Where to go next

  • Add your own bypass strings to payloads.py and re-run the matrix, then extend the character list in strict_path only if your site needs a character it forbids.
  • Repeat Step 5 behind a reverse proxy that rewrites the Location header, to see which style of redirect you really have in production.
  • Read How to Prevent Server-Side Request Forgery (SSRF) in a Python Web App for the mirror-image problem: your server following an address that a user supplied.
  • Read How to Fix CORS Errors in FastAPI for another bug that only a real browser can show you, and How to Stop CSRF Attacks in a Python Web App for the other half of securing a login form.

Sources

  • OWASP, Unvalidated Redirects and Forwards Cheat Sheet (it lists the weakness as CWE entry 601).
  • WHATWG, URL Standard.
  • IETF, RFC 3986, section 4.2, RFC 9110, section 10.2.2 and section 15.4.4, and RFC 9700.
  • Python documentation, urllib.parse and re.
  • Django source, django/utils/http.py and django/http/response.py.
  • Starlette source, starlette/responses.py.
  • Playwright, Browsers.

Tags:

Application SecurityBrowser SecurityFastAPIOpen RedirectPythonWeb Security

Share

A dual carriageway seen from above, with a grass and concrete-barrier median strip separating the two carriageways and a 100 km/h speed limit sign standing on the median
Previous Post

OpenAI’s Habitat Sequel Turns Cosmos DB Multi-Tenancy Into a Co-Managed Partnership, and Its 12x Is a Median With No Hit Rate

A TV remote control beside a bowl of popcorn on a dark wooden table
Next Post

Eye Security Says It Used Popcorn Time’s Abandoned Updater to Disable It on Over a Million Systems, and Three of the 20 Domains It Lists Are Still Outside the Sinkhole

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
27 Oct
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
27 Oct
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Trending
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
October 27, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
October 27, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026