[Blog](https://excessionlabs.ai/blog/) · 10 November 2026 · [Excession Labs](https://excessionlabs.ai/about/)

# Open redirect vulnerability: testing a ?next= login fix

Source page: https://excessionlabs.ai/blog/open-redirect/

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.

On this page: [What it is](https://excessionlabs.ai/blog/open-redirect/#definition) · [Scope](https://excessionlabs.ai/blog/open-redirect/#scope) · [Reproduce the run](https://excessionlabs.ai/blog/open-redirect/#reproduce) · [Observed results](https://excessionlabs.ai/blog/open-redirect/#results) · [Verify the finding](https://excessionlabs.ai/blog/open-redirect/#finding) · [Fix and retest](https://excessionlabs.ai/blog/open-redirect/#fix) · [OAuth redirect_uri](https://excessionlabs.ai/blog/open-redirect/#oauth) · [Evidence](https://excessionlabs.ai/blog/open-redirect/#evidence) · [Limits](https://excessionlabs.ai/blog/open-redirect/#limits)

## What an open redirect vulnerability is

[CWE-601, URL Redirection to Untrusted Site](https://cwe.mitre.org/data/definitions/601.html), 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](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) 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](https://www.rfc-editor.org/rfc/rfc9700.html), 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](https://www.rfc-editor.org/rfc/rfc2606.html) 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](https://excessionlabs.ai/aup/) describes authorized use, and this article gives no permission to test another system.

## Run the downloadable fixture

Download [fixture.py](https://excessionlabs.ai/blog/evidence/open-redirect/fixture.py) and [verify.py](https://excessionlabs.ai/blog/evidence/open-redirect/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](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/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

| Mode | Value sent | Status | Location header | Host used (modeled) | Leaves site |
| --- | --- | --- | --- | --- | --- |
| `vulnerable` | `/account` | 302 | `/account` | site.example | no |
| `vulnerable` | `https://evil.test/phish` | 302 | `https://evil.test/phish` | evil.test | yes |
| `naive-slash` | `/account` | 302 | `/account` | site.example | no |
| `naive-slash` | `https://evil.test/phish` | 302 | `/account` | site.example | no |
| `naive-slash` | `//evil.test/phish` | 302 | `//evil.test/phish` | evil.test | yes |
| `naive-slash` | `/\evil.test/phish` | 302 | `/\evil.test/phish` | evil.test | yes |
| `naive-slash` | `/<TAB>/evil.test/phish` | 302 | `/<TAB>/evil.test/phish` | evil.test | yes |
| `naive-host` | `https://site.example/account` | 302 | `https://site.example/account` | site.example | no |
| `naive-host` | `https://evil.test/phish` | 302 | `/account` | site.example | no |
| `naive-host` | `https://site.example.evil.test/phish` | 302 | `https://site.example.evil.test/phish` | site.example.evil.test | yes |
| `naive-host` | `https://evil.test/?site.example` | 302 | `https://evil.test/?site.example` | evil.test | yes |
| `naive-host` | `https://site.example@evil.test/phish` | 302 | `https://site.example@evil.test/phish` | evil.test | yes |

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](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2)). The [WHATWG URL Standard](https://url.spec.whatwg.org/) 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](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/naive-slash-backslash.http) 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

| Mode | Value sent | Status | Location header | Host used (modeled) | Leaves site |
| --- | --- | --- | --- | --- | --- |
| `fixed` | `/account` | 302 | `/account` | site.example | no |
| `fixed` | `/account/billing?tab=invoices` | 302 | `/account/billing?tab=invoices` | site.example | no |
| `fixed` | `https://evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `https://site.example/account` | 302 | `/account` | site.example | no |
| `fixed` | `//evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `/\evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `/<TAB>/evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `https://site.example.evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `https://evil.test/?site.example` | 302 | `/account` | site.example | no |
| `fixed` | `https://site.example@evil.test/phish` | 302 | `/account` | site.example | no |
| `fixed` | `dest=billing` | 302 | `/account/billing` | site.example | no |
| `fixed` | `dest=https://evil.test/phish` | 302 | `/account` | site.example | no |

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](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) 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

- [Machine-readable results](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/results.json): all 24 cases with inputs, expected and observed Locations, modeled hosts, response filenames and hashes.

- [Exact recorded curl commands](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/commands.sh): the ports refer to the finished run. Use the runner to start fresh servers.

- [SHA-256 manifest](https://excessionlabs.ai/blog/evidence/open-redirect/SHA256SUMS): compare downloaded sources and evidence. This is an integrity check, not independent attestation.

- [Fixture instructions and limitations](https://excessionlabs.ai/blog/evidence/open-redirect/README.txt), [server source](https://excessionlabs.ai/blog/evidence/open-redirect/fixture.py) and [verification source](https://excessionlabs.ai/blog/evidence/open-redirect/verify.py).

- Raw responses, for example [the host-check lookalike](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/naive-host-lookalike-host.http) and [the fixed backslash case](https://excessionlabs.ai/blog/evidence/open-redirect/recorded-run/fixed-backslash.http).

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](https://excessionlabs.ai/blog/api-authorization-testing/) uses the same evidence format. Redirects also interact with [session fixation](https://excessionlabs.ai/blog/session-fixation/) around login and with [host header injection](https://excessionlabs.ai/blog/host-header-injection/) when an app builds absolute URLs from the request. Excession Labs runs this kind of redirect testing in its [security assessments](https://excessionlabs.ai/security-assessment/). See how a finding like this looks in a [sample report](https://excessionlabs.ai/sample-report/).
