Refuse backslashes, which browsers read as slashes and urllib.parse does not, 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. Never trust startswith('/') or a host substring test.
- Find every redirect built from request data: redirect(request.args.get('next')), redirect(request.form[...]), request.referrer 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, and on any refusal to a fixed page such as /account; log refusals without the full URL, which may carry tokens.
- When next only ever points at your own pages, consider keeping the return path in the session instead of the URL.
- Add regression tests that resolve the Location header the way a browser does. ada-url, Python bindings for the WHATWG parser that Node.js uses, does it in one call (ada_url.URL(location, page).origin); urljoin cannot.
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) -> Response:
return redirect(next_url or "/account")With IMPL=vulnerable, 5 of 16 tests fail (5 exploit tests).
The fix
ALLOWED_HOSTS = {"app.example.test", "docs.example.test"}
def redirect_after_login(next_url: str | None) -> Response:
return redirect(safe_redirect_target(next_url))
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 and leave as //evil.example/.
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) -> Response:
return redirect(checked_target(next_url))
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, 2 of 16 tests fail (2 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'})"
)- urlsplit is not a browser. It reads https://evil.example\@docs.example.test/ as hostname docs.example.test while the browser goes to evil.example, so even an exact hostname check approves it unless backslashes are refused first. The Python docs say urlsplit performs no validation and is compliant with neither RFC 3986 nor the WHATWG URL Standard.
- Werkzeug rewrites the Location header with iri_to_uri, which rebuilds the URL from urlsplit's parts. In this card's vulnerable run that turned /\evil.example/, \\evil.example/ and ///evil.example/ into /%5Cevil.example/, %5C%5Cevil.example/ and /evil.example/, which stay on the site, but //evil.example/, absolute URLs and javascript: URLs still went out. Do not rely on it.
- The same rewriting is why the control-character check matters in Flask: urlsplit drops tabs, so /<TAB>/evil.example/ passes startswith('/') and not startswith('//'), and Werkzeug then sends //evil.example/.
- startswith('/') accepts //evil.example/; a test with in or endswith on the hostname accepts docs.example.test.evil.example and look-alike registrations. Compare the whole hostname against a set.
- Check the value you redirect to: validating request.args but redirecting request.form, 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.
- Semgrep's Flask rule only looks at @app.route functions, misses @app.post and @app.get, and flags urllib.parse-based checks because the only fix it recognises is werkzeug.urls.url_parse, which Werkzeug 3.0 removed. Bandit has no open-redirect check. 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-flask does this for you).
- Against your own app, sign in through /login?next=%2F%2Fevil.example%2F and /login?next=https%3A%2F%2Fevil.example%5C%40docs.example.test%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 redirect(, request.referrer and Location; Semgrep found nothing on this card and Bandit has no rule for this weakness, so the regression tests are the control.
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 | python.flask.security.open-redirect.open-redirect | No findings on this card, for any implementation: the rule looks only inside functions decorated with @app.route, and this card's routes use @app.post with the redirect in impl/*.py. On a single-file probe it flagged redirect(request.form.get('next') or '/account') under @app.route but not under @app.post, and it also flagged a startswith('/') check and a urlsplit check. Its only recognised fix is a werkzeug.urls.url_parse check, a function Werkzeug 2.3 deprecated and 3.0 removed.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.py impl |
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No redirect findings. The packs flagged python.lang.security.audit.non-literal-import on the IMPL switch (importlib.import_module) and python.flask.security.audit.render-template-string on the sign-in form, which Jinja autoescapes; neither concerns the redirect.Checked: Semgrep 1.180.0 (CE); on app.py and impl |
| CodeQL | py/url-redirection | From the query source: sinks are HTTP redirect responses such as Flask's redirect(). Its barriers are comparisons with constants, a value placed after a fixed prefix and modelled sanitizers, and it tracks separately whether backslashes were replaced; a urlsplit-based check is not among the barriers. This card picks the implementation with importlib.import_module, so whether CodeQL follows the request value into impl/*.py was not tested.Checked: github/codeql main at b5b8165; not executed |
| Bandit | n/aNo open-redirect plugin | No findings. Bandit 1.9.4 ships 42 plugins (B101 to B704) and none covers redirects or CWE-601.Checked: Bandit 1.9.4; bandit -r app.py impl |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| urllib.parse (standard library) | Python 3.14.8 |
| pytest (test only) | 9.1.1 |
| ada-url (test only: WHATWG URL parser) | 4.0.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/python-flaskfolder.
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-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- flask 3.1.3
- werkzeug 3.1.9
- pytest 9.1.1
- adaUrl 4.0.0
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 5 of 16 tests fail (5 exploit tests); wrong: 2 of 16 tests fail (2 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 5/16 fail (5 exploit tests: the absolute URL, //, our host as a user name, the look-alike host and javascript:); wrong 2/16 fail (2 exploit tests: // and the look-alike host), offline after install
- Dependency freshness
- requirements.txt pins all 15 packages with ==. werkzeug 3.1.9 (2026-09-27) is 13 days old and is taken because it is a security release (GHSA-g6x2-hccm-hh4m). markupsafe 3.0.3, iniconfig 2.3.0 and pycparser 3.0 are pinned instead of 3.0.4, 2.3.1 and 3.1/3.11, which are under 14 days old and are not security releases. ada-url 4.0.0 (2026-07-28) and cffi 2.1.1 (2026-08-03) are test-only. OSV listed no advisories for any pinned package. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
- Notes
- The same vulnerable code fails 9 exploit tests in the Node, Java and C# cards. Here Werkzeug's Location rewriting sent ///evil.example/ as /evil.example/, /\evil.example/ as /%5Cevil.example/, \\evil.example/ as %5C%5Cevil.example/ and https://evil.example\@docs.example.test/ as https://evil.example%5C@docs.example.test/, which a browser follows to docs.example.test, so those four stay on allowed sites. The wrong implementation passes the backslash-before-@ test for the same reason, although urlsplit approved that value.
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
- Python urllib.parse: URL parsing security
- Werkzeug utilities: redirect
- Werkzeug changes: 2.3.0 deprecates werkzeug.urls except iri_to_uri and uri_to_iri; 3.0.0 removes deprecated code
- 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')