Refuse backslashes and control characters, then split next with urlsplit: accept a path that starts with exactly one slash, or an https URL whose hostname is in an exact allowlist, with no port and no @. Otherwise redirect to a fixed page. Return RedirectResponse(target, status_code=303) with the value you checked; never trust startswith('/') or a host substring test.
- Find every RedirectResponse built from request data: query and form parameters such as next or return_to, the Referer header, and return URLs kept in the session or a cookie.
- Refuse a value that contains a backslash or an ASCII control character before parsing it: browsers read \ as / and drop tabs and newlines, and urllib.parse treats a backslash as an ordinary character.
- Split the value with urllib.parse.urlsplit and treat ValueError (a malformed host or port) as a refusal. With no scheme and no netloc it is a path: accept it only if it starts with exactly one slash, because urlsplit reports no host for ///evil.example/.
- Otherwise require scheme https, a hostname in an exact allowlist (here app.example.test and docs.example.test), no explicit port and no @ in the netloc.
- Redirect to the value you checked with RedirectResponse(target, status_code=303), and on any refusal to a fixed page such as /account.
- Add regression tests that sign in through the form and resolve the Location header the way a browser does, for example with ada_url.URL(location, page).origin.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def redirect_after_login(next_url: str | None) -> RedirectResponse:
# 303 so the browser follows with GET; RedirectResponse defaults to 307, which repeats the POST.
return RedirectResponse(next_url or "/account", status_code=303)With IMPL=vulnerable, 6 of 16 tests fail (6 exploit tests).
The fix
ALLOWED_HOSTS = {"app.example.test", "docs.example.test"}
def redirect_after_login(next_url: str | None) -> RedirectResponse:
return RedirectResponse(safe_redirect_target(next_url), status_code=303)
def safe_redirect_target(next_url: str | None, fallback: str = "/account") -> str:
# Browsers read "\" as "/" and urllib.parse does not. Both drop tabs and newlines, which would let
# /<TAB>/evil.example/ pass the "//" check below.
if not next_url or "\\" in next_url or any(ord(c) < 0x20 or ord(c) == 0x7F for c in next_url):
return fallback
try:
parts = urlsplit(next_url)
port = parts.port
except ValueError: # a malformed host or port
return fallback
if not parts.scheme and not parts.netloc:
# A path on this site. urlsplit finds no host in ///evil.example/, so check the slashes too.
return next_url if next_url.startswith("/") and not next_url.startswith("//") else fallback
if parts.scheme == "https" and parts.hostname in ALLOWED_HOSTS and port is None and "@" not in parts.netloc:
return next_url
return fallbackWith IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
def redirect_after_login(next_url: str | None) -> RedirectResponse:
return RedirectResponse(checked_target(next_url), status_code=303)
def checked_target(next_url: str | None) -> str:
if not next_url:
return "/account"
# A path on this site...
if next_url.startswith("/"):
return next_url
# ...or a full link back to one of our sites, app.example.test or docs.example.test.
if "example.test" in (urlsplit(next_url).hostname or ""):
return next_url
return "/account"With IMPL=wrong, 3 of 16 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Each value arrives in a link to the real sign-in page, such as /login?next=%2F%2Fevil.example%2F.
# After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
ATTACKS = {
"an absolute URL to another site": "https://evil.example/",
"a scheme-relative URL": "//evil.example/",
"three slashes": "///evil.example/",
"a backslash after the slash": "/\\evil.example/",
"two backslashes": "\\\\evil.example/",
"our host name as a user name": "https://app.example.test@evil.example/",
"a look-alike host that contains an allowed name": "https://docs.example.test.evil.example/",
"a backslash before the @": "https://evil.example\\@docs.example.test/",
"encoded slashes": "/%2F%2Fevil.example/",
"a javascript: URL": "javascript:alert(document.domain)",
}
@pytest.mark.parametrize("next_url", list(ATTACKS.values()), ids=list(ATTACKS))
def test_exploit_lands_on_an_allowed_site(client, alice, next_url):
response = sign_in_with_next(client, alice, next_url)
url = landing(response)
location = response.headers["Location"]
assert url is not None and url.origin in ALLOWED_ORIGINS, (
f"next={next_url} sent the browser to {location} (origin {url.origin if url else 'none'})"
)- RedirectResponse validates nothing. Starlette only percent-encodes characters outside a safe set (quote(url, safe=":/%#?=@[]!$&'()*+,;")), so in this card's vulnerable run /\evil.example/ left as /%5Cevil.example/ and stayed on the site, but //evil.example/, ///evil.example/, absolute URLs, user-name tricks and javascript: URLs went out unchanged.
- RedirectResponse defaults to 307 Temporary Redirect, which tells the browser to repeat the POST, form fields and all, at the new address. After a sign-in form, pass status_code=303.
- urlsplit is not a browser. It reads https://evil.example\@docs.example.test/ as hostname docs.example.test while a browser goes to evil.example, so refuse backslashes before parsing; here Starlette's encoding happened to defuse that payload, which is not something to rely on.
- startswith('/') accepts //evil.example/ and ///evil.example/; a test with in or endswith on the hostname accepts docs.example.test.evil.example. Compare the whole hostname against a set.
- Check the value you redirect to: validating the query parameter but redirecting the form field, or validating before an extra unquote(), reopens the hole.
- Browsers refuse a Location header whose scheme is not http or https, but a javascript: value rendered into an href or location.href runs as script; the scheme check has to apply wherever next is used.
- No Semgrep rule covers FastAPI or Starlette redirects and Bandit has no redirect check, so the regression tests are the control.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/python-fastapi does this for you).
- Against your own app, sign in through /login?next=%2F%2Fevil.example%2F and /login?next=https%3A%2F%2Fdocs.example.test.evil.example%2F, resolve each Location header with ada_url.URL(location, 'https://your.app/login') and expect your own origin or an allowlisted one.
- Grep for RedirectResponse( and status_code=307 on form handlers; static analysis found nothing on this card.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/python, p/default, p/security-audit, p/owasp-top-ten | No redirect findings on any implementation (p/default's one finding is the deliberate importlib.import_module in app.py). A single-file probe with RedirectResponse(next) on a Form parameter and RedirectResponse(url) on a query parameter also gave no findings: semgrep-rules has redirect rules for Flask and Django but none for FastAPI or Starlette.Checked: Semgrep 1.180.0 (CE); on app.py and impl |
| CodeQL | py/url-redirection | Both ends are modelled: FastApi.qll treats routed handler parameters (Form fields included) as remote sources and the first argument of RedirectResponse as a redirect location. Its barriers are comparisons with constants, a fixed prefix and modelled sanitizers; a urlsplit-based check is not among them. This card picks the implementation with importlib.import_module, which CodeQL does not resolve.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo open-redirect plugin | No findings. Bandit has no plugin for redirects or CWE-601.Checked: Bandit 1.9.4; bandit -r app.py impl |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0) |
| fastapi | 0.142.2 |
| starlette (RedirectResponse, SessionMiddleware) | 1.7.0 |
| python-multipart (form parsing) | 0.0.32 |
| itsdangerous (signs the session cookie) | 2.2.0 |
| urllib.parse (standard library) | Python 3.14.8 |
| ada-url (test only, WHATWG URL parser) | 4.0.0 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/python-fastapifolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/python-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- fastapi 0.142.2
- starlette 1.7.0
- pythonMultipart 0.0.32
- adaUrl 4.0.0
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 6 of 16 tests fail (6 exploit tests); wrong: 3 of 16 tests fail (3 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 6/16 fail (6 exploit tests: the absolute URL, //, ///, our host as a user name, the look-alike host and javascript:); wrong 3/16 fail (3 exploit tests: //, /// and the look-alike host), offline after install
- Dependency freshness
- FastAPI 0.142.2 (2026-09-30) rather than 0.143.0 (2026-10-08, not a security release); Starlette 1.7.0 (2026-09-23), python-multipart 0.0.32 (2026-06-04), itsdangerous 2.2.0 (2024-04-16), ada-url 4.0.0 (2026-07-28). Test-only pins kept under the 7-day rule: iniconfig 2.3.0 (2.3.1 is from 2026-10-06) and pycparser 3.0 (3.1 and 3.11 are from 2026-10-08 and 2026-10-09). OSV reported no known vulnerabilities for any pinned package.
- Notes
- The same vulnerable code fails 9 exploit tests in the Node, Java and C# cards and 5 in Flask. Starlette percent-encodes backslashes in the Location header, so /\evil.example/, \\evil.example/ and https://evil.example\@docs.example.test/ stay on allowed sites, but unlike Werkzeug it leaves ///evil.example/ alone, so the wrong implementation fails three tests here against two in Flask.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- FastAPI: custom responses, RedirectResponse (307 by default)
- Starlette: RedirectResponse
- RFC 9110: 303 See Other
- Python urllib.parse: URL parsing security
- ada-url for Python (WHATWG URL parser)
- WHATWG URL Standard: basic URL parser
- CodeQL query help: py/url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')