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.
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 callsterminate().
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 Otherwithlocation: //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 reportsnet::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 isapp.testwhile the browser goes toevil.test. In A07 and A08 Python sees a scheme and a path, while Edge readshttps: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 onevil.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.
/(?!/)means one leading slash that is not followed by another slash. That rejects//evil.testand///evil.test, and every string that does not start with a slash, such ashttps:evil.testandjavascript:alert(1).- 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. fullmatchinstead ofmatchwith 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/dashboardfollowed by a newline through. Step 8 proves that a test notices.- The length cap keeps the check cheap and the redirect sane. 2048 is a round number, not a rule.
safe_nextreturns 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
nextfield comes back from the browser onPOST, 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 throughRedirectResponse, and 10 do when the header is written as given.A05(as written) andA12are refused by the browser itself, withnet::ERR_UNSAFE_REDIRECTandnet::ERR_ABORTED, as the list at the end of the output shows. starts_with_slashstops 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/loginand///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_partfixes 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 acceptsjavascript:alert(1)(A12). The browser will not follow that as a redirect, but it is a string the check approved.host_allowlistleaks the same three addresses asno_host_partin 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_pathand Django’s helper let none of the 12 attacks through in either style.strict_pathaccepts 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_slashaccepted 3,465 and leaked 238 (as written) and 267 (quoted), of which 12 and 5 went toevil.testitself. The shortest is//evil.test. no_host_partandhost_allowlistaccepted over 34,000 strings, and leaked 260 as written and 231 quoted, 12 and 6 of them toevil.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_pathaccepted 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%20or%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 useiri_to_urion 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 ""><script>" 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
/logoutroute 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
nextvalue, check it in the browser withnew 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.pyand re-run the matrix, then extend the character list instrict_pathonly if your site needs a character it forbids. - Repeat Step 5 behind a reverse proxy that rewrites the
Locationheader, 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.








No Comment! Be the first one.