Open redirect vulnerability: testing a ?next= login fix

An open redirect is a redirect whose destination an attacker can choose. In a local fixture, a login endpoint sent the browser to https://evil.test/phish when ?next= asked for it. Two common fixes still returned an off-site Location header: "must start with /" for //evil.test/phish and /\evil.test/phish, and "must contain the site host" for https://site.example.evil.test/phish. A check that parses the value and keeps only same-site paths held in all 12 fixed-mode requests, including the legitimate ones.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

What an open redirect vulnerability is

CWE-601, URL Redirection to Untrusted Site, describes a web application that accepts user-controlled input specifying a link to an external site and uses that link in a redirect. CWE lists "open redirect" and "unvalidated redirect" as alternate names. The OWASP Unvalidated Redirects and Forwards Cheat Sheet covers the same class and says that an attacker who can point the input at a malicious site may launch a phishing scam.

A login page is a common place to find one. After sign-in, the app sends the user back to the page they wanted, and a next parameter names that page. RFC 9700, the OAuth 2.0 Security Best Current Practice, lists this pattern, redirecting users to a URL they intended to visit before a login prompt interrupted them, as one way open redirectors arise. The link in a phishing message starts with the real site's host, which is what makes the redirect useful to an attacker.

Scope of the fixture

  • Target: four short-lived HTTP servers on 127.0.0.1, one per mode, on free ports chosen by the operating system.
  • Request: GET /login?next=<value>. The server answers 302 with a Location header, as an app does for a user who is already signed in. There are no credentials.
  • Names: the fixture's configured site host is site.example and the attacker host is evil.test. RFC 2606 reserves both top-level names. curl does not follow redirects, so nothing resolves or contacts either name.
  • Modes: vulnerable redirects to whatever next holds. naive-slash requires a leading /. naive-host requires the string site.example somewhere in the value. fixed parses the value and keeps same-site paths.

The fixture is deliberately insecure and cannot bind to another address. The Excession Labs Acceptable Use Policy describes authorized use, and this article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. The Python code uses only the standard library. Read the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts each mode in turn, sends the requests with curl, saves every raw response and stops the servers. Each request bypasses proxies, has a one-second connection timeout and a three-second total timeout, and does not follow redirects.

Our recorded command was python3 verify.py --output recorded-run. It exited with {"checks": 24, "passed": true, "failures": []}. That means all 24 requests matched their expected Location values and expected site-exit outcomes, including the intentionally unsafe ones. The run on 10 November 2026 used fixture version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. Ports were 53848 on vulnerable, 53851 on naive-slash, 53858 on naive-host, 53864 on fixed. Timestamps and source hashes are in results.json.

Observed results: the plain redirect and two naive fixes

The first two rows are the plain open redirect and its control. The rest show each naive check blocking the obvious attacker URL and accepting the variants. Every response was 302. The <TAB> marker stands for one tab character, and the Location column is the header exactly as the server sent it.

Locations returned by the vulnerable and naive modes
ModeValue sentStatusLocation headerHost used (modeled)Leaves site
vulnerable/account302/accountsite.exampleno
vulnerablehttps://evil.test/phish302https://evil.test/phishevil.testyes
naive-slash/account302/accountsite.exampleno
naive-slashhttps://evil.test/phish302/accountsite.exampleno
naive-slash//evil.test/phish302//evil.test/phishevil.testyes
naive-slash/\evil.test/phish302/\evil.test/phishevil.testyes
naive-slash/<TAB>/evil.test/phish302/<TAB>/evil.test/phishevil.testyes
naive-hosthttps://site.example/account302https://site.example/accountsite.exampleno
naive-hosthttps://evil.test/phish302/accountsite.exampleno
naive-hosthttps://site.example.evil.test/phish302https://site.example.evil.test/phishsite.example.evil.testyes
naive-hosthttps://evil.test/?site.example302https://evil.test/?site.exampleevil.testyes
naive-hosthttps://site.example@evil.test/phish302https://site.example@evil.test/phishevil.testyes

The last two columns need a rule for how a browser reads a Location value. A relative Location is resolved against the request URL (RFC 9110, section 10.2.2). The WHATWG URL Standard treats a backslash like a slash for special schemes such as http: its relative slash state accepts U+005C as well as U+002F, so /\evil.test reads like //evil.test. The parser also removes ASCII tab and newline from the input first, so /<TAB>/evil.test becomes //evil.test. A value that begins with two slashes carries a host.

The runner applies those rules in a small function. No browser ran.

The slash check blocked only the plain absolute URL. The host check kept the legitimate same-site URL and blocked https://evil.test/phish, then accepted https://site.example.evil.test/phish, https://evil.test/?site.example and https://site.example@evil.test/phish. Each of those strings contains site.example. In the last one it is only userinfo, and the host is evil.test.

Parsers also disagree. The runner recorded Python's urlsplit returning an empty network location for /\evil.test/phish. A server-side check that rejects a non-empty network location would accept the value, and a browser would leave the site.

How to verify an open redirect before reporting it

Keep the raw request and the unmodified response. The recorded backslash case on the naive-slash server used this command, reformatted across lines:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 \
  --get --data-urlencode 'next=/\evil.test/phish' \
  http://127.0.0.1:53851/login

The recorded response contains these lines among its headers:

HTTP/1.0 302 Found
Location: /\evil.test/phish

Quote the Location header, not the address bar. Show that its host differs from the application's host and that a same-site next still works, as the first row of each mode does. Say which host the destination reaches and who controls it. Do not follow the redirect to a system you do not own. If the endpoint also sets a session or issues a code or token, test separately whether that credential travels to the destination. This fixture issues none, so it shows nothing about that. A page that redirects with JavaScript or a meta refresh needs its own check.

Fix the open redirect and retest

The fixed mode uses the two functions below, copied from fixture.py. The first handles next. It rejects control characters and backslashes before parsing, because parsers and browsers disagree about them. It then requires a value with no scheme and no host that starts with one slash and not two.

def safe_next(value):
    """Real fix: keep only a same-site relative path, judged after parsing."""
    if any(ord(c) < 32 or ord(c) == 127 or c == "\\" for c in value):
        return DEFAULT
    parts = urlsplit(value)
    if parts.scheme or parts.netloc or not value.startswith("/") or value.startswith("//"):
        return DEFAULT
    return value


def destination(key):
    """Alternative fix: the client sends a short key, the server owns the URL."""
    return DESTINATIONS.get(key, DEFAULT)

The retest sent the same values, plus a legitimate path with a query string, to a fresh server on port 53864.

Locations returned by the fixed mode
ModeValue sentStatusLocation headerHost used (modeled)Leaves site
fixed/account302/accountsite.exampleno
fixed/account/billing?tab=invoices302/account/billing?tab=invoicessite.exampleno
fixedhttps://evil.test/phish302/accountsite.exampleno
fixedhttps://site.example/account302/accountsite.exampleno
fixed//evil.test/phish302/accountsite.exampleno
fixed/\evil.test/phish302/accountsite.exampleno
fixed/<TAB>/evil.test/phish302/accountsite.exampleno
fixedhttps://site.example.evil.test/phish302/accountsite.exampleno
fixedhttps://evil.test/?site.example302/accountsite.exampleno
fixedhttps://site.example@evil.test/phish302/accountsite.exampleno
fixeddest=billing302/account/billingsite.exampleno
fixeddest=https://evil.test/phish302/accountsite.exampleno

Every attacker value fell back to /account, and both legitimate paths passed through. The fixed mode also rejects the absolute same-site URL https://site.example/account. We chose this on purpose. The application writes relative paths into next, so it never needs an absolute URL there.

The last two rows send dest in place of next and show the alternative fix. The client sends a short key and the server owns the URL. The OWASP cheat sheet prefers this: have the user supply a short name or ID that the server maps to a full target URL. It also says not to use raw prefix or suffix matching, and to use a URL parser that agrees with browser interpretation. The two naive modes are the matching it warns about.

Why this matters for OAuth redirect_uri

RFC 9700 requires an authorization server to compare a client's redirect_uri with its pre-registered values by exact string matching, except for the port in localhost redirect URIs of native apps (section 2.1). A prefix or substring comparison would accept the same shapes of value that naive-host accepted, such as a registered client host followed by .evil.test. The same section says clients and authorization servers must not expose open redirectors that forward the browser to arbitrary URIs from a query parameter, because they can enable exfiltration of authorization codes and access tokens. An exact match on a registered client URL therefore leaves a gap if that client also hosts a ?next= endpoint that forwards anywhere. We did not run an OAuth flow in this fixture.

Inspect the evidence bundle

All redirect targets are synthetic. The raw responses include the server header and response dates.

What this experiment does not cover

This is one toy endpoint and 24 requests. We did not drive a real browser, so the host column is a model of the WHATWG rules, not an observed browser result. We did not test other redirect sinks such as Refresh headers, JavaScript or meta redirects, other parameters, POST bodies, javascript: and data: URLs, double-encoded values, IDN homographs, redirects chained through a second service, framework redirect helpers, or CDN and proxy rewriting. The fixed check is sufficient for this toy because it only ever emits relative paths. It says nothing about a different framework's URL handling.

API authorization testing uses the same evidence format. Redirects also interact with session fixation around login and with host header injection when an app builds absolute URLs from the request. Excession Labs runs this kind of redirect testing in its security assessments. See how a finding like this looks in a sample report.