diff --git a/CHANGELOG.md b/CHANGELOG.md
index 527e8b2..bd67b12 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Open-redirect mutator** (`--open-redirect`,
+ `internal/mutate/open_redirect.go`): a new mutator targeting the
+ CWE-601 / ASVS V5.1.5 unvalidated-redirect family at the
+ request-parameter layer. Where `--ssrf-probe` attacks *what
+ server-side network resource the application fetches on the caller's
+ behalf* (the URL value is consumed by an outbound HTTP client
+ reaching internal IPs / cloud metadata), `--open-redirect` attacks
+ *what destination the application bounces the caller's browser to*
+ (the URL value is reflected into a `Location:` header reaching an
+ attacker-controlled external site). Eligible parameters are matched
+ by name (substring, case-insensitive, against a sorted token list —
+ `back`, `callback`, `continue`, `dest`, `destination`, `goto`,
+ `next`, `redir`, `redirect`, `return`, `returnto`, `success`,
+ `target`, `url` — covers `redirect_uri`, `redirect_url`, `returnTo`,
+ `next_page`, etc.) OR by value shape (an existing absolute `http(s)`
+ URL). Four surfaces (query, urlencoded body, top-level JSON string,
+ and the `Referer` header when present) are each cross-producted with
+ seven disjoint payload techniques: `backslash-host`
+ (`https://attacker.example\@target.example/` — RFC-vs-browser
+ authority-parsing disagreement), `cross-origin`
+ (`https://attacker.example/` — textbook external URL),
+ `data-uri` (`data:text/html,` — XSS via
+ redirect), `javascript-uri` (`javascript:alert(1)` — XSS via
+ redirect on legacy clients / WebViews), `protocol-relative`
+ (`//attacker.example/` — defeats same-origin-by-leading-slash
+ defenses), `userinfo-confusion`
+ (`https://target.example@attacker.example/` — naive substring/prefix
+ validators read the username as the host), and `whitespace-prefix`
+ (validators trim before matching, then pass the un-trimmed value to
+ the browser which also trims). The `Referer` surface emits only the
+ header-safe technique subset (excludes `backslash-host` and
+ `whitespace-prefix`, which `net/http` would reject or silently trim
+ from a header value). Every variant keeps the caller's own
+ credentials (`Identity == nil`) — this is a same-caller
+ destination-rewrite probe, not an identity swap. Requests with no
+ redirect-destination parameter and no `Referer` emit zero variants.
+ Findings are class `open-redirect` (ASVS V5.1.5, severity MEDIUM:
+ impact is phishing / OAuth-token leakage, not direct privilege
+ bypass). Off by default — the payloads point callers' browsers at
+ attacker-controlled URLs and embed XSS-via-redirect shapes (`data:` /
+ `javascript:`). README and scan-help text updated. Disjoint from
+ `--ssrf-probe` (server-side fetch, not client-side redirect),
+ `--origin-spoof` (spoofs `Origin`/`Referer` to bypass origin-validation
+ CSRF, not to coerce a redirect destination), and `--csrf-header`
+ (forges anti-CSRF tokens, not redirect destinations).
+
- **SSRF probe mutator** (`--ssrf-probe`,
`internal/mutate/ssrf_probe.go`): a new mutator targeting OWASP
A10:2021 Server-Side Request Forgery at the request-parameter layer.
diff --git a/README.md b/README.md
index 31d140f..553618e 100755
--- a/README.md
+++ b/README.md
@@ -1078,6 +1078,88 @@ so it only fires when you opt in — mirroring the gating of
`--forbidden-bypass`, `--method-override`, `--csrf-header`,
`--ws-hijack`, `--xxe`, and `--mass-assign`.
+## Open redirect (`--open-redirect`)
+
+Where `--ssrf-probe` attacks *what server-side network resource the application
+fetches on the caller's behalf* (the URL value is consumed by an outbound HTTP
+client → internal IPs / cloud metadata), `--open-redirect` attacks *what
+destination the application bounces the caller's browser to* (the URL value is
+reflected into a `Location:` header → an attacker-controlled external site).
+The vuln classes are disjoint: SSRF reaches internal targets; open-redirect
+reaches external attacker domains and abuses URL-parser disagreement. The
+canonical CWE-601 / ASVS V5.1.5 pattern: a post-login `next` parameter, an
+OAuth `redirect_uri`, a payment-flow `returnTo`, or a `callback` URL is taken
+from the request and echoed into a `Location:` (or a meta-refresh /
+`window.location.assign`) without validating that the destination is in-scope
+for the application. An attacker who substitutes that value with an
+attacker-controlled URL turns the trusted host into a **phishing-redirect
+surface** — the victim sees a legitimate `target.example` page that silently
+bounces to `attacker.example`. On an OAuth flow, the same primitive **leaks
+authorization codes / access tokens** to the attacker via the URL fragment.
+
+Every variant keeps the **caller's own credentials** (no identity swap — the
+caller stays themselves; they merely supply a destination URL the validator
+should refuse):
+
+```bash
+possession scan capture.har \
+ --matrix matrix.yaml \
+ --open-redirect
+```
+
+Four surfaces (query, urlencoded body, top-level JSON string, and the
+`Referer` header when present) are each cross-producted with seven disjoint
+payload techniques, emitted in deterministic sorted-by-technique order:
+
+| Technique | Wire-form payload | What it defeats |
+|---|---|---|
+| `backslash-host` | `https://attacker.example\@target.example/` | RFC 3986 forbids `\` in the authority; browsers normalise `\` → `/`, so the parsed host is `attacker.example` while a validator that splits on `@` or substring-matches the literal host reads `target.example`. |
+| `cross-origin` | `https://attacker.example/` | An app that does not validate the destination at all (or only checks presence) honours a blatantly cross-site redirect target. |
+| `data-uri` | `data:text/html,` | A browser following `Location: data:...` renders attacker HTML/JavaScript in the redirecting site's tab — XSS via redirect (legacy ecosystem and embedded WebViews still honour `data:` in top-level navigations). |
+| `javascript-uri` | `javascript:alert(1)` | An app that emits `Location: javascript:...` becomes a reflected-XSS sink via the redirect (most modern browsers refuse this in `Location`, but legacy clients and WebViews honour it). |
+| `protocol-relative` | `//attacker.example/` | A validator requiring the destination begin with `/` (assuming therefore same-origin) approves; browsers interpret the leading `//` as scheme-relative and navigate to `attacker.example` under the current scheme. The most common open-redirect bypass on same-origin-by-leading-slash defenses. |
+| `userinfo-confusion` | `https://target.example@attacker.example/` | RFC 3986 splits the authority into `userinfo@host:port`, so the parsed host is `attacker.example` while `target.example` is the username. A validator that does a naive substring / `hasPrefix` check sees `target.example` and approves. |
+| `whitespace-prefix` | ` https://attacker.example/` | Many validators trim before matching, then pass the un-trimmed value to the browser, which also trims — the validator and the browser agree on a different URL than what the validator inspected. |
+
+A parameter is eligible when its name **contains** any of the redirect-
+destination tokens (`back`, `callback`, `continue`, `dest`, `destination`,
+`goto`, `next`, `redir`, `redirect`, `return`, `returnto`, `success`, `target`,
+`url` — substring, case-insensitive — covers `redirect_uri`, `redirect_url`,
+`returnTo`, `next_page`, etc.) **or** its value already parses as an absolute
+`http(s)` URL. The `Referer` surface fires whenever a `Referer` is present on
+the captured request — an attacker who hosts a link on an attacker-controlled
+page can reliably set a victim's `Referer`, so the same primitive applies.
+
+The technique set on the `Referer` surface is a **subset** of the URL-surface
+set: `backslash-host` and `whitespace-prefix` carry bytes `net/http` would
+reject or silently trim from a header value, so they are emitted only on the
+URL surfaces (query, body, JSON) where the bytes survive transit unmangled.
+
+Detection rides the **existing comparative ladder** unchanged: the caller's
+own baseline against the unmutated request is the reference; a variant whose
+response carries a 3xx `Location:` containing the attacker payload (or a body
+that reflects the payload, for the `data:` / `javascript:` shapes) is the
+candidate open-redirect finding. Findings are class `open-redirect`
+(ASVS V5.1.5 — URL redirect validation, severity **MEDIUM**: the impact is
+phishing / OAuth-token leakage, not direct privilege bypass).
+
+Disjoint from related mutators by design:
+
+- `--ssrf-probe` (server-side fetch reaching internal IPs / cloud metadata)
+- `--origin-spoof` (spoofs `Origin` / `Referer` to bypass origin-validation
+ CSRF, not to coerce a redirect destination)
+- `--csrf-header` (forges anti-CSRF tokens, not redirect destinations)
+
+`--open-redirect` is **off by default**: the payloads point callers' browsers
+at attacker-controlled URLs and embed XSS-via-redirect shapes (`data:` /
+`javascript:`), so it only fires when you opt in — mirroring the gating of
+`--ssrf-probe`, `--path-traversal`, `--prototype-pollution`,
+`--cache-deception`, `--content-type-confusion`, `--origin-spoof`,
+`--parameter-pollution`, `--header-injection`, `--cookie-tampering`,
+`--host-header`, `--forbidden-bypass`, `--method-override`, `--csrf-header`,
+`--ws-hijack`, `--xxe`, and `--mass-assign`. Requests with no
+redirect-destination parameter and no `Referer` emit zero variants.
+
## Role matrix
The role matrix is YAML. Minimum viable shape:
diff --git a/internal/cli/buildregistry_forbidden_test.go b/internal/cli/buildregistry_forbidden_test.go
index 3343948..dc53b15 100644
--- a/internal/cli/buildregistry_forbidden_test.go
+++ b/internal/cli/buildregistry_forbidden_test.go
@@ -27,7 +27,7 @@ func protectedScanReq() *model.CapturedRequest {
// flows through buildRegistry: the mutator is always registered, but only
// emits variants when the flag is set.
func TestBuildRegistry_ForbiddenBypassGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -35,7 +35,7 @@ func TestBuildRegistry_ForbiddenBypassGating(t *testing.T) {
t.Fatalf("forbidden-bypass must always be registered, even when disabled")
}
- regOn, err := buildRegistry("", 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -60,7 +60,7 @@ func TestBuildRegistry_ForbiddenBypassWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -93,7 +93,7 @@ func wsScanReq() *model.CapturedRequest {
// buildRegistry: the mutator is always registered, but only emits variants when
// the flag is set.
func TestBuildRegistry_WSHijackGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -104,7 +104,7 @@ func TestBuildRegistry_WSHijackGating(t *testing.T) {
t.Errorf("disabled ws-hijack emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -125,7 +125,7 @@ func TestBuildRegistry_WSHijackWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -141,7 +141,7 @@ func TestBuildRegistry_WSHijackWithWordlist(t *testing.T) {
// through buildRegistry: the mutator is always registered, but only emits
// variants when the flag is set.
func TestBuildRegistry_MethodOverrideGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -152,7 +152,7 @@ func TestBuildRegistry_MethodOverrideGating(t *testing.T) {
t.Errorf("disabled method-override emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -172,7 +172,7 @@ func TestBuildRegistry_MethodOverrideWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -188,7 +188,7 @@ func TestBuildRegistry_MethodOverrideWithWordlist(t *testing.T) {
// buildRegistry: the mutator is always registered, but only emits variants when
// the flag is set.
func TestBuildRegistry_HostHeaderGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -199,7 +199,7 @@ func TestBuildRegistry_HostHeaderGating(t *testing.T) {
t.Errorf("disabled host-header emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -219,7 +219,7 @@ func TestBuildRegistry_HostHeaderWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -249,7 +249,7 @@ func cookieScanReq() *model.CapturedRequest {
// through buildRegistry: the mutator is always registered, but only emits
// variants when the flag is set.
func TestBuildRegistry_CookieTamperGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -260,7 +260,7 @@ func TestBuildRegistry_CookieTamperGating(t *testing.T) {
t.Errorf("disabled cookie-tamper emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -280,7 +280,7 @@ func TestBuildRegistry_CookieTamperWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -296,7 +296,7 @@ func TestBuildRegistry_CookieTamperWithWordlist(t *testing.T) {
// flows through buildRegistry: the mutator is always registered, but only emits
// variants when the flag is set.
func TestBuildRegistry_HeaderInjectionGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -307,7 +307,7 @@ func TestBuildRegistry_HeaderInjectionGating(t *testing.T) {
t.Errorf("disabled header-injection emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -327,7 +327,7 @@ func TestBuildRegistry_HeaderInjectionWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -357,7 +357,7 @@ func pollutionScanReq() *model.CapturedRequest {
// flows through buildRegistry: the mutator is always registered, but only emits
// variants when the flag is set.
func TestBuildRegistry_ParamPollutionGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -368,7 +368,7 @@ func TestBuildRegistry_ParamPollutionGating(t *testing.T) {
t.Errorf("disabled parameter-pollution emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -388,7 +388,7 @@ func TestBuildRegistry_ParamPollutionWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -421,7 +421,7 @@ func ctcScanReq() *model.CapturedRequest {
// --content-type-confusion flag flows through buildRegistry: the mutator is
// always registered, but only emits variants when the flag is set.
func TestBuildRegistry_ContentTypeConfusionGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -432,7 +432,7 @@ func TestBuildRegistry_ContentTypeConfusionGating(t *testing.T) {
t.Errorf("disabled content-type-confusion emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -453,7 +453,7 @@ func TestBuildRegistry_ContentTypeConfusionWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -484,7 +484,7 @@ func cdScanReq() *model.CapturedRequest {
// flows through buildRegistry: the mutator is always registered, but only
// emits variants when the flag is set.
func TestBuildRegistry_CacheDeceptionGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -495,7 +495,7 @@ func TestBuildRegistry_CacheDeceptionGating(t *testing.T) {
t.Errorf("disabled cache-deception emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -515,7 +515,7 @@ func TestBuildRegistry_CacheDeceptionWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -549,7 +549,7 @@ func ppScanReq() *model.CapturedRequest {
// flag flows through buildRegistry: the mutator is always registered, but
// only emits variants when the flag is set.
func TestBuildRegistry_PrototypePollutionGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -560,7 +560,7 @@ func TestBuildRegistry_PrototypePollutionGating(t *testing.T) {
t.Errorf("disabled prototype-pollution emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -581,7 +581,7 @@ func TestBuildRegistry_PrototypePollutionWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
@@ -613,7 +613,7 @@ func ptScanReq() *model.CapturedRequest {
// flows through buildRegistry: the mutator is always registered, but only
// emits variants when the flag is set.
func TestBuildRegistry_PathTraversalGating(t *testing.T) {
- regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
+ regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false)
if err != nil {
t.Fatalf("buildRegistry off: %v", err)
}
@@ -624,7 +624,7 @@ func TestBuildRegistry_PathTraversalGating(t *testing.T) {
t.Errorf("disabled path-traversal emitted %d variants; want 0", len(vs))
}
- regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false)
+ regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false)
if err != nil {
t.Fatalf("buildRegistry on: %v", err)
}
@@ -645,7 +645,7 @@ func TestBuildRegistry_PathTraversalWithWordlist(t *testing.T) {
if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil {
t.Fatalf("write wordlist: %v", err)
}
- reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false)
+ reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false)
if err != nil {
t.Fatalf("buildRegistry with wordlist: %v", err)
}
diff --git a/internal/cli/scan.go b/internal/cli/scan.go
index ba9680d..67e1bf4 100755
--- a/internal/cli/scan.go
+++ b/internal/cli/scan.go
@@ -71,6 +71,7 @@ var (
scanProtoPollution bool // --prototype-pollution: bury privileged properties under __proto__ / constructor.prototype / prototype keys in JSON object bodies so a deep-merge helper writes onto Object.prototype (Node.js authz bypass; off by default)
scanPathTraversal bool // --path-traversal: reshape the trailing path segment with `../` chains (literal, encoded, double-encoded, nested, null-byte-suffixed, absolute) to escape the resource scope and reach OS-sensitive files / sibling tenant directories (OWASP A01:2021 path traversal / LFI; off by default)
scanSSRFProbe bool // --ssrf-probe: rewrite URL-bearing query/body/JSON parameters to SSRF payloads (internal IPs, AWS/GCP/Azure metadata endpoints, file://, gopher://) to weaponise the server's outbound fetch (OWASP A10:2021 SSRF; off by default)
+ scanOpenRedirect bool // --open-redirect: rewrite redirect-destination query/body/JSON parameters (and the Referer header) to attacker-controlled URLs using URL-parser-disagreement payloads, testing for unvalidated-redirect / phishing-redirect / OAuth-token-leak primitives (CWE-601 / ASVS V5.1.5; off by default)
)
// scanCmd is the end-to-end scan command. Packets 1-3 contribute:
@@ -164,6 +165,8 @@ func init() {
"test directory/path-traversal scope-escape using the caller's own credentials: replace the trailing path segment with six disjoint traversal payloads (literal `../`, percent-encoded `..%2f`, double-encoded `..%252f`, nested `....//`, null-byte-suffixed `%00`, and absolute-path) pointing at three high-signal target files (etc/passwd, proc/self/environ, windows/win.ini), so the caller breaks OUT of the resource collection the route prefix was supposed to confine them to and reaches OS-sensitive files or sibling-tenant directories the application never intended to expose; disjoint from --forbidden-bypass (which reshapes the path to resolve back to the SAME handler, e.g. /admin/..;/admin) and from --swap-object / --enumerate (which stay inside the resource collection); root/empty paths are skipped; off by default because the traversal payloads are active probes that exfiltrate sensitive file contents on a vulnerable target (OWASP A01:2021)")
scanCmd.Flags().BoolVar(&scanSSRFProbe, "ssrf-probe", false,
"test Server-Side Request Forgery using the caller's own credentials: for each URL-bearing query parameter, urlencoded body parameter, and top-level JSON string field (matched by name — url, uri, redirect, callback, webhook, target, dest, endpoint, next, return, src, host, image, fetch — or by value parsing as an absolute http(s) URL), rewrite the value to seven disjoint SSRF payloads spanning internal-network probes (loopback 127.0.0.1, RFC1918 10.0.0.1), cloud-provider instance-metadata endpoints (AWS IMDSv1 169.254.169.254, GCP metadata.google.internal, Azure IMDS), and protocol smuggling (file:///etc/passwd, gopher://127.0.0.1:6379/_INFO) so the caller weaponises the server's outbound fetch helper to reach internal network resources or exfiltrate cloud-instance IAM credentials (Capital One / AWS IMDS 2019 breach shape); disjoint from --path-traversal (which reshapes the request path, not a fetch-target parameter) and --mass-assign (which injects privileged JSON properties, not URL rewrites); requests with no URL-bearing parameter emit no variants; off by default because the SSRF payloads reach the server's internal network including cloud metadata endpoints whose response can contain IAM credentials (OWASP A10:2021)")
+ scanCmd.Flags().BoolVar(&scanOpenRedirect, "open-redirect", false,
+ "test unvalidated-redirect / open-redirect using the caller's own credentials: for each redirect-destination query parameter, urlencoded body parameter, and top-level JSON string field (matched by name — next, redirect, redirect_uri, redirect_url, redir, return, returnto, return_url, goto, dest, destination, callback, continue, target, url, success, back — or by value parsing as an absolute http(s) URL) AND the Referer header when present, rewrite the value to seven disjoint open-redirect payloads spanning textbook external URLs (https://attacker.example/), URL-parser disagreement (backslash-host https://attacker.example\\@target.example/, userinfo-confusion https://target.example@attacker.example/, protocol-relative //attacker.example/), validator-bypass (whitespace-prefix), and XSS-via-redirect (data: / javascript: schemes) so the application bounces the caller's browser to an attacker-controlled destination — turning the trusted host into a phishing-redirect surface or leaking OAuth authorization codes via the redirect_uri; disjoint from --ssrf-probe (which targets server-side fetch helpers reaching internal IPs / cloud metadata, not the client-side Location header) and --origin-spoof (which spoofs the Origin/Referer to bypass origin-validation CSRF, not coerce a redirect destination); requests with no redirect-destination parameter and no Referer emit no variants; off by default because the payloads point callers' browsers at attacker URLs and embed XSS-via-redirect shapes (CWE-601 / ASVS V5.1.5)")
}
func resetScanFlags() {
@@ -210,6 +213,7 @@ func resetScanFlags() {
scanProtoPollution = false
scanPathTraversal = false
scanSSRFProbe = false
+ scanOpenRedirect = false
}
func runScan(cmd *cobra.Command, args []string) error {
@@ -333,7 +337,7 @@ func runScan(cmd *cobra.Command, args []string) error {
// derived from its representative sample's auth components.
attributionWarnings := attributeEndpoints(endpoints, matrix)
- reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception, scanProtoPollution, scanPathTraversal, scanSSRFProbe)
+ reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception, scanProtoPollution, scanPathTraversal, scanSSRFProbe, scanOpenRedirect)
if err != nil {
return err
}
@@ -1261,7 +1265,7 @@ func buildEvaluator(name string, matrix *model.RoleMatrix) (detect.Evaluator, er
// PrototypePollution, and PathTraversal are always registered but inert in
// their disabled state, so the canonical DefaultRegistry order (and the
// order test) stays unchanged.
-func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, xxe, graphql, forbidBypass, wsHijack, csrfHeader, methodOverride, hostHeader, cookieTamper, headerInjection, paramPollution, originSpoof, ctConfusion, cacheDeception, protoPollution, pathTraversal, ssrfProbe bool) (*mutate.Registry, error) {
+func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, xxe, graphql, forbidBypass, wsHijack, csrfHeader, methodOverride, hostHeader, cookieTamper, headerInjection, paramPollution, originSpoof, ctConfusion, cacheDeception, protoPollution, pathTraversal, ssrfProbe, openRedirect bool) (*mutate.Registry, error) {
enumMutator := mutate.EnumerateID{N: enumerateN}
jwtAuthMutator := mutate.JWTAuth{Enabled: jwtAttack}
massAssignMutator := mutate.MassAssign{Enabled: massAssign}
@@ -1281,6 +1285,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x
protoPollutionMutator := mutate.PrototypePollution{Enabled: protoPollution}
pathTraversalMutator := mutate.PathTraversal{Enabled: pathTraversal}
ssrfProbeMutator := mutate.SSRFProbe{Enabled: ssrfProbe}
+ openRedirectMutator := mutate.OpenRedirect{Enabled: openRedirect}
if wordlistPath == "" {
// Extend the default registry with EnumerateID + JWTAuth +
@@ -1289,7 +1294,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x
// ParamPollution + OriginSpoof + ContentTypeConfusion + CacheDeception +
// PrototypePollution + PathTraversal (all no-op when disabled).
base := mutate.DefaultRegistry()
- all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator, pathTraversalMutator, ssrfProbeMutator)
+ all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator, pathTraversalMutator, ssrfProbeMutator, openRedirectMutator)
return mutate.NewRegistry(all...), nil
}
data, err := os.ReadFile(wordlistPath)
@@ -1335,6 +1340,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x
protoPollutionMutator,
pathTraversalMutator,
ssrfProbeMutator,
+ openRedirectMutator,
), nil
}
diff --git a/internal/mutate/open_redirect.go b/internal/mutate/open_redirect.go
new file mode 100644
index 0000000..15d45e7
--- /dev/null
+++ b/internal/mutate/open_redirect.go
@@ -0,0 +1,489 @@
+package mutate
+
+import (
+ "encoding/json"
+ "net/url"
+ "sort"
+ "strings"
+
+ "github.com/bugsyhewitt/possession/internal/model"
+)
+
+// OpenRedirect is the unvalidated-redirect / open-redirect access-control
+// bypass mutator. It targets the OWASP A01:2021 (Broken Access Control) /
+// CWE-601 family at the request-parameter layer: the application accepts a
+// destination URL from the client (a post-login next parameter, a returnTo
+// after a payment flow, a callback, a redirect_uri in an OAuth dance) and
+// echoes that value into a Location: header (or a meta-refresh / JS
+// window.location.assign) without validating that the destination is
+// in-scope for the application. An attacker who substitutes the value with
+// an attacker-controlled URL turns the application into a phishing redirect
+// surface — the victim sees a legitimate target.example login page that
+// silently bounces to attacker.example, where the credentials harvested
+// look authentic because the redirect originated from the trusted host.
+// On an OAuth flow, an open redirect on the redirect_uri leaks
+// authorization codes / access tokens to attacker.example via the URL
+// fragment.
+//
+// Where SSRFProbe attacks *what server-side network resource the application
+// fetches on the caller's behalf* (the value is consumed by an outbound HTTP
+// client → internal-network probes), OpenRedirect attacks *what destination
+// the application bounces the caller's browser to* (the value is reflected
+// into a Location header → external attacker site). The vuln classes are
+// disjoint: SSRF reaches internal IPs / cloud metadata; open-redirect reaches
+// external attacker domains and abuses URL-parser disagreement. The fixes
+// are also disjoint (URL-allowlist + same-origin check vs. metadata-endpoint
+// blocking + outbound URL allowlist), so the two mutators share helpers but
+// run independently and produce separate findings.
+//
+// Four surfaces, each emitted as a separate variant family for attribution:
+//
+// - query-open-redirect: any query parameter whose name matches a
+// redirect-destination token (redirect, redirect_uri, redirect_url,
+// redirect_to, redir, return, return_url, return_to, returnto, next,
+// nexturl, next_url, url, dest, destination, goto, target, continue,
+// callback, success, success_url, back) OR whose value already parses
+// as an absolute http(s) URL. The substring match catches the
+// long-tail of in-house redirect parameters (next_page, returnUrl,
+// RedirectURI, etc.); the value-shape check catches the rest.
+//
+// - body-open-redirect: the same name+value-shape match applied to an
+// application/x-www-form-urlencoded request body — common in form
+// POST → redirect flows (legacy login forms, payment confirmations).
+//
+// - json-open-redirect: top-level JSON object keys matching the same
+// name list. Only string-valued keys are eligible; nested objects are
+// not walked.
+//
+// - header-referer: the Referer header itself. Some applications redirect
+// the caller back to the Referer after a state-change (post-action
+// redirects, "back" buttons that read the Referer), and an attacker
+// who controls the Referer (by hosting a link on an attacker-controlled
+// page) reaches the same primitive. Emitted only when a Referer is
+// present on the captured request — there is no signal otherwise.
+//
+// Seven payload techniques, each emitted as a separate variant per eligible
+// parameter for attribution. Generation order is fixed (sorted-by-technique-
+// name) so identical inputs yield an identical variant slice:
+//
+// - backslash-host: https://attacker.example\@target.example/ — RFC 3986
+// forbids backslash in the authority component, but browsers (and many
+// URL parsers) normalise '\' → '/', so the parsed authority becomes
+// attacker.example while a naive validator that splits on '@' or that
+// only checks the literal host substring reads target.example.
+//
+// - cross-origin: https://attacker.example/ — the textbook external URL.
+// An application that does not validate the destination at all (or
+// only checks presence) honours a blatantly cross-site redirect target.
+//
+// - data-uri: data:text/html, — the data: URL
+// scheme. Browsers that follow a Location: data:... can be coerced
+// into rendering attacker HTML / JavaScript in the origin of the
+// redirecting site (modern browsers block top-level navigation to
+// data: but the legacy ecosystem and many native HTTP clients honour
+// it). Same XSS-via-redirect class as javascript:.
+//
+// - javascript-uri: javascript:alert(1) — the javascript: URL scheme.
+// An app that emits Location: javascript:... can be turned into a
+// reflected-XSS sink via the redirect; browsers vary in their handling
+// (most modern browsers refuse javascript: in Location, but the legacy
+// ecosystem and embedded WebViews honour it).
+//
+// - protocol-relative: //attacker.example/ — the scheme-relative URL.
+// A validator that requires the destination begin with '/' (assuming
+// it is therefore same-origin) approves; browsers interpret the
+// leading '//' as scheme-relative and navigate to attacker.example
+// under the current scheme. The most common open-redirect bypass on
+// same-origin-by-leading-slash defenses.
+//
+// - userinfo-confusion: https://target.example@attacker.example/ — the
+// authority's userinfo component. RFC 3986 splits the authority into
+// userinfo@host:port, so the parsed host is attacker.example while
+// target.example is the username. A validator that does a naive
+// substring / hasPrefix check on the URL string sees target.example
+// and approves. Parallels the userinfo-confusion technique
+// OriginSpoof emits against the Origin header.
+//
+// - whitespace-prefix: a leading space + https://attacker.example/. RFC
+// 3986 forbids leading whitespace in URLs, but many validators trim
+// before matching, then pass the un-trimmed value to the browser,
+// which also trims — the validator and the browser agree on a
+// different URL than what the validator inspected. Disjoint from
+// CRLF/header-splitting (raw CR/LF in header values is rejected by
+// net/http before reaching the wire; we use a literal space which is
+// URL-safe to transport).
+//
+// Cross-product is (4 surfaces × 7 techniques × eligible-parameter count),
+// gated by what the captured request actually carries: a GET with no
+// redirect-shaped query parameter, no body, and no Referer emits zero
+// variants (no signal to probe). The variant ID derived by the planner
+// from the mutation Detail carries surface + technique + parameter so the
+// offline corpus tests pin every cell.
+//
+// Every variant keeps the caller's own credentials (Identity == nil): this
+// is emphatically NOT an identity swap. The bug being tested is "the same
+// legitimately-credentialed caller coerces the application into bouncing
+// their browser to an attacker-controlled destination." Detection rides
+// the existing comparative ladder: a variant whose response carries a 3xx
+// Location header containing the attacker payload (or, for data:/
+// javascript:, whose body reflects the payload) is the candidate
+// open-redirect finding. Findings are class "open-redirect" (ASVS V5.1.5
+// — URL redirect validation, severity MEDIUM: the impact is phishing /
+// OAuth-token leakage, not direct privilege bypass).
+//
+// OpenRedirect is OFF by default (Enabled == false). The payloads point
+// callers' browsers at attacker-controlled URLs and embed XSS-via-redirect
+// shapes (data:/javascript:), so it only fires when the operator
+// explicitly opts in via --open-redirect. This mirrors the off-by-default
+// gating of SSRFProbe, PathTraversal, PrototypePollution, CacheDeception,
+// ContentTypeConfusion, OriginSpoof, ParamPollution, HeaderInjection,
+// CookieTamper, HostHeader, ForbiddenBypass, MethodOverride, CSRFHeader,
+// WSHijack, XXE, and MassAssign.
+//
+// Like every mutator, Generate is pure and deterministic: parameters are
+// processed in sorted name order and techniques emitted in sorted name
+// order within each parameter, so identical inputs yield an identical
+// variant slice.
+type OpenRedirect struct {
+ Enabled bool
+}
+
+func (OpenRedirect) Name() string { return "open-redirect" }
+
+// openRedirectParamNames is the canonical, sorted list of
+// redirect-destination parameter name tokens. A parameter whose lowercased
+// name CONTAINS any of these substrings is eligible. Substring match
+// catches the long-tail of in-house redirect parameters (next_page,
+// returnUrl, RedirectURI). Sorted alphabetically so the order test pins
+// the set.
+var openRedirectParamNames = []string{
+ "back",
+ "callback",
+ "continue",
+ "dest",
+ "destination",
+ "goto",
+ "next",
+ "redir",
+ "redirect",
+ "return",
+ "returnto",
+ "success",
+ "target",
+ "url",
+}
+
+// openRedirectTechniques is the canonical, sorted list of open-redirect
+// payload technique names. The payload for each is held in
+// openRedirectPayloads. Sorted so the cross-product emission order is
+// stable regardless of map iteration; the order test pins this.
+var openRedirectTechniques = []string{
+ "backslash-host",
+ "cross-origin",
+ "data-uri",
+ "javascript-uri",
+ "protocol-relative",
+ "userinfo-confusion",
+ "whitespace-prefix",
+}
+
+// openRedirectPayloads maps each technique name to its wire payload. Kept
+// package-private so the variant ID is stable across runs and across
+// builds. The target-bearing techniques (backslash-host, userinfo-
+// confusion) use a placeholder "target.example" host that the planner
+// could later substitute with the captured request's actual host; the
+// fixed token keeps the variant ID stable regardless of input host so the
+// offline corpus pins it.
+var openRedirectPayloads = map[string]string{
+ "backslash-host": `https://attacker.example\@target.example/`,
+ "cross-origin": "https://attacker.example/",
+ "data-uri": "data:text/html,",
+ "javascript-uri": "javascript:alert(1)",
+ "protocol-relative": "//attacker.example/",
+ "userinfo-confusion": "https://target.example@attacker.example/",
+ "whitespace-prefix": " https://attacker.example/",
+}
+
+func (or OpenRedirect) Generate(base *model.CapturedRequest, _ *model.RoleMatrix) []model.Variant {
+ if !or.Enabled || base == nil {
+ return nil
+ }
+
+ techniques := append([]string(nil), openRedirectTechniques...)
+ sort.Strings(techniques)
+
+ var out []model.Variant
+ out = append(out, or.queryVariants(base, techniques)...)
+ out = append(out, or.bodyVariants(base, techniques)...)
+ out = append(out, or.jsonVariants(base, techniques)...)
+ out = append(out, or.refererVariants(base, techniques)...)
+ return out
+}
+
+func (or OpenRedirect) queryVariants(base *model.CapturedRequest, techniques []string) []model.Variant {
+ if base.URL == nil || base.URL.RawQuery == "" {
+ return nil
+ }
+ pairs, err := parseOrderedPairs(base.URL.RawQuery)
+ if err != nil || len(pairs) == 0 {
+ return nil
+ }
+ eligible := openRedirectEligibleNames(pairs)
+ if len(eligible) == 0 {
+ return nil
+ }
+ var out []model.Variant
+ for _, name := range eligible {
+ for _, tech := range techniques {
+ payload := openRedirectPayloads[tech]
+ rewritten := replaceFirstValue(pairs, name, payload)
+ req := CloneRequest(base)
+ cloneURL(req, base)
+ req.URL.RawQuery = encodeOrderedPairs(rewritten)
+ out = append(out, model.Variant{
+ Base: req,
+ Identity: nil,
+ Mutation: model.Mutation{
+ Type: "open-redirect",
+ Description: "rewrite query parameter " + name + " to open-redirect payload (" +
+ tech + ") to bounce the caller's browser to an attacker-controlled destination",
+ Detail: map[string]string{
+ "open-redirect": "query:" + name + ":" + tech,
+ "technique": "query-open-redirect:" + tech,
+ "surface": "query",
+ "parameter": name,
+ "shape": tech,
+ "payload": payload,
+ },
+ Class: "open-redirect",
+ },
+ })
+ }
+ }
+ return out
+}
+
+func (or OpenRedirect) bodyVariants(base *model.CapturedRequest, techniques []string) []model.Variant {
+ if len(base.Body) == 0 || !isFormURLEncoded(base.ContentType) {
+ return nil
+ }
+ pairs, err := parseOrderedPairs(string(base.Body))
+ if err != nil || len(pairs) == 0 {
+ return nil
+ }
+ eligible := openRedirectEligibleNames(pairs)
+ if len(eligible) == 0 {
+ return nil
+ }
+ var out []model.Variant
+ for _, name := range eligible {
+ for _, tech := range techniques {
+ payload := openRedirectPayloads[tech]
+ rewritten := replaceFirstValue(pairs, name, payload)
+ req := CloneRequest(base)
+ req.Body = []byte(encodeOrderedPairs(rewritten))
+ out = append(out, model.Variant{
+ Base: req,
+ Identity: nil,
+ Mutation: model.Mutation{
+ Type: "open-redirect",
+ Description: "rewrite form-body parameter " + name + " to open-redirect payload (" +
+ tech + ") to bounce the caller's browser to an attacker-controlled destination",
+ Detail: map[string]string{
+ "open-redirect": "body:" + name + ":" + tech,
+ "technique": "body-open-redirect:" + tech,
+ "surface": "body",
+ "parameter": name,
+ "shape": tech,
+ "payload": payload,
+ },
+ Class: "open-redirect",
+ },
+ })
+ }
+ }
+ return out
+}
+
+func (or OpenRedirect) jsonVariants(base *model.CapturedRequest, techniques []string) []model.Variant {
+ if len(base.Body) == 0 || !looksJSON(base.ContentType, base.Body) {
+ return nil
+ }
+ var doc map[string]json.RawMessage
+ if err := json.Unmarshal(base.Body, &doc); err != nil {
+ return nil
+ }
+ var eligible []string
+ for k, raw := range doc {
+ var s string
+ if err := json.Unmarshal(raw, &s); err != nil {
+ continue
+ }
+ if openRedirectNameMatches(k) || ssrfValueLooksURL(s) {
+ eligible = append(eligible, k)
+ }
+ }
+ if len(eligible) == 0 {
+ return nil
+ }
+ sort.Strings(eligible)
+
+ var out []model.Variant
+ for _, key := range eligible {
+ for _, tech := range techniques {
+ payload := openRedirectPayloads[tech]
+ rewritten := rewriteJSONStringField(base.Body, key, payload)
+ if rewritten == nil {
+ continue
+ }
+ req := CloneRequest(base)
+ req.Body = rewritten
+ out = append(out, model.Variant{
+ Base: req,
+ Identity: nil,
+ Mutation: model.Mutation{
+ Type: "open-redirect",
+ Description: "rewrite JSON field " + key + " to open-redirect payload (" +
+ tech + ") to bounce the caller's browser to an attacker-controlled destination",
+ Detail: map[string]string{
+ "open-redirect": "json:" + key + ":" + tech,
+ "technique": "json-open-redirect:" + tech,
+ "surface": "json",
+ "parameter": key,
+ "shape": tech,
+ "payload": payload,
+ },
+ Class: "open-redirect",
+ },
+ })
+ }
+ }
+ return out
+}
+
+// refererVariants emits one variant per technique when the captured request
+// carries a Referer header. The Referer is the one header an attacker can
+// reliably set on a victim's browser (by hosting a link on an attacker-
+// controlled page), so an app that redirects to the Referer post-action
+// has the same open-redirect primitive.
+//
+// Some techniques (backslash-host, whitespace-prefix) carry bytes net/http
+// would reject as raw header values; those are skipped here. The standard
+// library validates a stricter subset of byte ranges in header values than
+// in URL query / body bytes, so the technique set the Referer surface
+// covers is a subset of the technique set the URL surfaces cover. The
+// remaining techniques (cross-origin, data-uri, javascript-uri, protocol-
+// relative, userinfo-confusion) are all valid header-value bytes.
+func (or OpenRedirect) refererVariants(base *model.CapturedRequest, techniques []string) []model.Variant {
+ if base.Headers == nil {
+ return nil
+ }
+ if base.Headers.Get("Referer") == "" {
+ return nil
+ }
+ var out []model.Variant
+ for _, tech := range techniques {
+ if !openRedirectHeaderSafe(tech) {
+ continue
+ }
+ payload := openRedirectPayloads[tech]
+ req := CloneRequest(base)
+ req.Headers.Set("Referer", payload)
+ out = append(out, model.Variant{
+ Base: req,
+ Identity: nil,
+ Mutation: model.Mutation{
+ Type: "open-redirect",
+ Description: "rewrite Referer header to open-redirect payload (" +
+ tech + ") to bounce the caller's browser to an attacker-controlled destination",
+ Detail: map[string]string{
+ "open-redirect": "header:Referer:" + tech,
+ "technique": "header-referer-open-redirect:" + tech,
+ "surface": "header",
+ "parameter": "Referer",
+ "shape": tech,
+ "payload": payload,
+ },
+ Class: "open-redirect",
+ },
+ })
+ }
+ return out
+}
+
+// openRedirectEligibleNames returns the distinct parameter names (in sorted
+// order) eligible for open-redirect probing — either the name matches the
+// redirect-destination token list, or the value parses as an absolute
+// http(s) URL.
+func openRedirectEligibleNames(pairs []orderedPair) []string {
+ seen := map[string]struct{}{}
+ var out []string
+ for _, p := range pairs {
+ if _, ok := seen[p.name]; ok {
+ continue
+ }
+ if !openRedirectNameMatches(p.name) && !ssrfValueLooksURL(p.value) {
+ continue
+ }
+ seen[p.name] = struct{}{}
+ out = append(out, p.name)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// openRedirectNameMatches reports whether the lowercased parameter name
+// contains any redirect-destination token substring. Over-inclusive on
+// purpose — a noisy probe against a non-redirect parameter is harmless,
+// a missed real redirect parameter is a missed open-redirect finding.
+func openRedirectNameMatches(name string) bool {
+ low := strings.ToLower(name)
+ for _, tok := range openRedirectParamNames {
+ if strings.Contains(low, tok) {
+ return true
+ }
+ }
+ return false
+}
+
+// openRedirectHeaderSafe reports whether a technique's payload is safe to
+// transport as an HTTP header value. net/http rejects raw CR/LF and a
+// stricter byte subset in header values than in URL/body bytes;
+// backslash-host carries a literal '\' which Go's HTTP/2 transport (and
+// some HTTP/1 servers) will reject, and whitespace-prefix carries a
+// leading space which net/http trims silently — both signals would be
+// destroyed in transit. The URL-surface generators carry these techniques
+// unaffected.
+func openRedirectHeaderSafe(technique string) bool {
+ switch technique {
+ case "backslash-host", "whitespace-prefix":
+ return false
+ }
+ return true
+}
+
+// openRedirectTechniqueOf returns the technique label from a mutation's
+// Detail map. Used by tests to assert technique coverage without leaking
+// the Detail key layout outside the package. Mirrors ssrfProbeTechnique.
+func openRedirectTechniqueOf(m model.Mutation) string {
+ if m.Detail == nil {
+ return ""
+ }
+ // "query-open-redirect:cross-origin" → "cross-origin"
+ t := strings.TrimSpace(m.Detail["technique"])
+ if i := strings.LastIndex(t, ":"); i >= 0 {
+ return t[i+1:]
+ }
+ return t
+}
+
+// openRedirectKnownTechnique reports whether a technique name is recognised.
+// Used by tests; also a defensive check should the payload map ever drift
+// out of sync with the technique list.
+func openRedirectKnownTechnique(name string) bool {
+ _, ok := openRedirectPayloads[name]
+ return ok
+}
+
+// ensure url import survives (helper signature parity with ssrf-probe).
+var _ = url.Parse
diff --git a/internal/mutate/open_redirect_test.go b/internal/mutate/open_redirect_test.go
new file mode 100644
index 0000000..9f690c8
--- /dev/null
+++ b/internal/mutate/open_redirect_test.go
@@ -0,0 +1,439 @@
+package mutate
+
+import (
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/bugsyhewitt/possession/internal/model"
+)
+
+func orQueryReq(t *testing.T) *model.CapturedRequest {
+ t.Helper()
+ u, _ := url.Parse("https://target.example/login?next=%2Fdashboard&id=42")
+ h := http.Header{}
+ h.Set("Authorization", "Bearer alice-token")
+ return &model.CapturedRequest{
+ ID: "alice-login",
+ Method: "GET",
+ URL: u,
+ Headers: h,
+ }
+}
+
+func orBodyReq(t *testing.T) *model.CapturedRequest {
+ t.Helper()
+ u, _ := url.Parse("https://target.example/auth/callback")
+ h := http.Header{}
+ h.Set("Authorization", "Bearer alice-token")
+ return &model.CapturedRequest{
+ ID: "alice-cb",
+ Method: "POST",
+ URL: u,
+ Headers: h,
+ ContentType: "application/x-www-form-urlencoded",
+ Body: []byte("redirect_uri=%2Fhome&user=alice"),
+ }
+}
+
+func orJSONReq(t *testing.T) *model.CapturedRequest {
+ t.Helper()
+ u, _ := url.Parse("https://target.example/oauth/authorize")
+ h := http.Header{}
+ h.Set("Authorization", "Bearer alice-token")
+ return &model.CapturedRequest{
+ ID: "alice-oauth",
+ Method: "POST",
+ URL: u,
+ Headers: h,
+ ContentType: "application/json",
+ Body: []byte(`{"redirect_url":"/home","user":"alice"}`),
+ }
+}
+
+func orRefererReq(t *testing.T) *model.CapturedRequest {
+ t.Helper()
+ u, _ := url.Parse("https://target.example/action")
+ h := http.Header{}
+ h.Set("Authorization", "Bearer alice-token")
+ h.Set("Referer", "https://target.example/previous")
+ return &model.CapturedRequest{
+ ID: "alice-action",
+ Method: "POST",
+ URL: u,
+ Headers: h,
+ }
+}
+
+func orTechniqueKey(v model.Variant) string {
+ return v.Mutation.Detail["surface"] + ":" +
+ v.Mutation.Detail["parameter"] + ":" +
+ v.Mutation.Detail["shape"]
+}
+
+func TestOpenRedirect_DisabledByDefault(t *testing.T) {
+ if vs := (OpenRedirect{}).Generate(orQueryReq(t), nil); len(vs) != 0 {
+ t.Fatalf("open-redirect must be off by default; got %d variants", len(vs))
+ }
+}
+
+func TestOpenRedirect_NilBaseSafe(t *testing.T) {
+ if vs := (OpenRedirect{Enabled: true}).Generate(nil, nil); vs != nil {
+ t.Errorf("nil base must yield nil variants; got %v", vs)
+ }
+}
+
+func TestOpenRedirect_Name(t *testing.T) {
+ if got := (OpenRedirect{}).Name(); got != "open-redirect" {
+ t.Errorf("Name() = %q; want open-redirect", got)
+ }
+}
+
+func TestOpenRedirect_NotInDefaultRegistry(t *testing.T) {
+ for _, n := range DefaultRegistry().Names() {
+ if n == "open-redirect" {
+ t.Fatalf("open-redirect must NOT be in DefaultRegistry() (off-by-default mutator)")
+ }
+ }
+}
+
+func TestOpenRedirect_NoEligibleParameters(t *testing.T) {
+ u, _ := url.Parse("https://target.example/me?id=42&page=1")
+ req := &model.CapturedRequest{Method: "GET", URL: u, Headers: http.Header{}}
+ if vs := (OpenRedirect{Enabled: true}).Generate(req, nil); len(vs) != 0 {
+ t.Errorf("non-redirect-shaped request must yield 0 variants; got %d", len(vs))
+ }
+}
+
+func TestOpenRedirect_QueryEligibleByName(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ if len(vs) != len(openRedirectTechniques) {
+ t.Fatalf("query: %d variants; want %d (one per technique)", len(vs), len(openRedirectTechniques))
+ }
+ for _, v := range vs {
+ if v.Mutation.Type != "open-redirect" {
+ t.Errorf("Type = %q; want open-redirect", v.Mutation.Type)
+ }
+ if v.Mutation.Class != "open-redirect" {
+ t.Errorf("Class = %q; want open-redirect", v.Mutation.Class)
+ }
+ if v.Mutation.Detail["surface"] != "query" {
+ t.Errorf("surface = %q; want query", v.Mutation.Detail["surface"])
+ }
+ if v.Mutation.Detail["parameter"] != "next" {
+ t.Errorf("parameter = %q; want next", v.Mutation.Detail["parameter"])
+ }
+ if v.Identity != nil {
+ t.Errorf("Identity must be nil (same caller); got %v", v.Identity)
+ }
+ if v.Base.Headers.Get("Authorization") != "Bearer alice-token" {
+ t.Errorf("caller credentials altered: %q", v.Base.Headers.Get("Authorization"))
+ }
+ }
+}
+
+func TestOpenRedirect_QueryEligibleByValueShape(t *testing.T) {
+ u, _ := url.Parse("https://target.example/x?input=https%3A%2F%2Fpartner.example%2Fa&id=1")
+ req := &model.CapturedRequest{Method: "GET", URL: u, Headers: http.Header{}}
+ vs := (OpenRedirect{Enabled: true}).Generate(req, nil)
+ if len(vs) == 0 {
+ t.Fatal("value-shape eligibility must produce variants")
+ }
+ for _, v := range vs {
+ if v.Mutation.Detail["parameter"] != "input" {
+ t.Errorf("expected parameter=input; got %q", v.Mutation.Detail["parameter"])
+ }
+ }
+}
+
+func TestOpenRedirect_BodyVariants(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orBodyReq(t), nil)
+ if len(vs) != len(openRedirectTechniques) {
+ t.Fatalf("body: %d variants; want %d", len(vs), len(openRedirectTechniques))
+ }
+ for _, v := range vs {
+ if v.Mutation.Detail["surface"] != "body" {
+ t.Errorf("surface = %q; want body", v.Mutation.Detail["surface"])
+ }
+ if v.Mutation.Detail["parameter"] != "redirect_uri" {
+ t.Errorf("parameter = %q; want redirect_uri", v.Mutation.Detail["parameter"])
+ }
+ bodyStr := string(v.Base.Body)
+ if !strings.Contains(bodyStr, "user=alice") {
+ t.Errorf("body lost untouched param: %q", bodyStr)
+ }
+ }
+}
+
+func TestOpenRedirect_JSONVariants(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orJSONReq(t), nil)
+ if len(vs) != len(openRedirectTechniques) {
+ t.Fatalf("json: %d variants; want %d", len(vs), len(openRedirectTechniques))
+ }
+ for _, v := range vs {
+ if v.Mutation.Detail["surface"] != "json" {
+ t.Errorf("surface = %q; want json", v.Mutation.Detail["surface"])
+ }
+ if v.Mutation.Detail["parameter"] != "redirect_url" {
+ t.Errorf("parameter = %q; want redirect_url", v.Mutation.Detail["parameter"])
+ }
+ if !strings.Contains(string(v.Base.Body), `"user":"alice"`) {
+ t.Errorf("json body lost untouched key: %q", string(v.Base.Body))
+ }
+ }
+}
+
+// Referer surface fires only when a Referer is present, and emits the
+// header-safe subset of techniques (excludes backslash-host / whitespace-
+// prefix that net/http would mangle or reject).
+func TestOpenRedirect_RefererVariants(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orRefererReq(t), nil)
+ if len(vs) == 0 {
+ t.Fatal("referer-bearing request must produce variants")
+ }
+ var safeCount int
+ for _, tech := range openRedirectTechniques {
+ if openRedirectHeaderSafe(tech) {
+ safeCount++
+ }
+ }
+ if len(vs) != safeCount {
+ t.Fatalf("referer: %d variants; want %d (header-safe subset)", len(vs), safeCount)
+ }
+ for _, v := range vs {
+ if v.Mutation.Detail["surface"] != "header" {
+ t.Errorf("surface = %q; want header", v.Mutation.Detail["surface"])
+ }
+ if v.Mutation.Detail["parameter"] != "Referer" {
+ t.Errorf("parameter = %q; want Referer", v.Mutation.Detail["parameter"])
+ }
+ // The mutated Referer must actually carry the payload.
+ want := v.Mutation.Detail["payload"]
+ if got := v.Base.Headers.Get("Referer"); got != want {
+ t.Errorf("Referer = %q; want %q", got, want)
+ }
+ // Unsafe techniques must never appear on the header surface.
+ shape := v.Mutation.Detail["shape"]
+ if !openRedirectHeaderSafe(shape) {
+ t.Errorf("unsafe technique %q emitted on header surface", shape)
+ }
+ }
+}
+
+// A request with no Referer (and no eligible params) emits nothing from
+// the referer branch.
+func TestOpenRedirect_NoRefererNoHeaderVariants(t *testing.T) {
+ u, _ := url.Parse("https://target.example/x")
+ req := &model.CapturedRequest{
+ Method: "POST",
+ URL: u,
+ Headers: http.Header{},
+ }
+ for _, v := range (OpenRedirect{Enabled: true}).Generate(req, nil) {
+ if v.Mutation.Detail["surface"] == "header" {
+ t.Errorf("no-referer request must not emit header variants; got %q", v.Mutation.Detail["technique"])
+ }
+ }
+}
+
+func TestOpenRedirect_AllTechniquesEmittedOnQuery(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ got := map[string]bool{}
+ for _, v := range vs {
+ got[orTechniqueKey(v)] = true
+ }
+ for _, t1 := range openRedirectTechniques {
+ if !got["query:next:"+t1] {
+ t.Errorf("missing technique variant %q", t1)
+ }
+ }
+}
+
+func TestOpenRedirect_JSONArrayIgnored(t *testing.T) {
+ u, _ := url.Parse("https://target.example/x")
+ req := &model.CapturedRequest{
+ Method: "POST",
+ URL: u,
+ Headers: http.Header{},
+ ContentType: "application/json",
+ Body: []byte(`["/home","/away"]`),
+ }
+ if vs := (OpenRedirect{Enabled: true}).Generate(req, nil); len(vs) != 0 {
+ t.Errorf("json array body must yield 0 variants; got %d", len(vs))
+ }
+}
+
+func TestOpenRedirect_BodyMultipartIgnored(t *testing.T) {
+ u, _ := url.Parse("https://target.example/x")
+ req := &model.CapturedRequest{
+ Method: "POST",
+ URL: u,
+ Headers: http.Header{},
+ ContentType: "multipart/form-data; boundary=zzz",
+ Body: []byte("--zzz\r\nContent-Disposition: form-data; name=\"redirect\"\r\n\r\n/home\r\n--zzz--\r\n"),
+ }
+ vs := (OpenRedirect{Enabled: true}).Generate(req, nil)
+ for _, v := range vs {
+ if v.Mutation.Detail["surface"] == "body" {
+ t.Errorf("multipart body must not emit body-surface variants; got %q", v.Mutation.Detail["technique"])
+ }
+ }
+}
+
+func TestOpenRedirect_Deterministic(t *testing.T) {
+ a := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ b := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ if len(a) != len(b) {
+ t.Fatalf("non-deterministic length: %d vs %d", len(a), len(b))
+ }
+ for i := range a {
+ ta := openRedirectTechniqueOf(a[i].Mutation)
+ tb := openRedirectTechniqueOf(b[i].Mutation)
+ if ta != tb {
+ t.Errorf("pos %d: %q vs %q", i, ta, tb)
+ }
+ }
+}
+
+func TestOpenRedirect_TechniquesSorted(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ var shapes []string
+ for _, v := range vs {
+ shapes = append(shapes, v.Mutation.Detail["shape"])
+ }
+ if !sort.StringsAreSorted(shapes) {
+ t.Errorf("techniques emitted out of order: %v", shapes)
+ }
+}
+
+func TestOpenRedirect_BaselineURLNotMutated(t *testing.T) {
+ req := orQueryReq(t)
+ origRawQuery := req.URL.RawQuery
+ origPath := req.URL.Path
+ _ = (OpenRedirect{Enabled: true}).Generate(req, nil)
+ if req.URL.RawQuery != origRawQuery {
+ t.Errorf("baseline RawQuery mutated: %q → %q", origRawQuery, req.URL.RawQuery)
+ }
+ if req.URL.Path != origPath {
+ t.Errorf("baseline Path mutated: %q → %q", origPath, req.URL.Path)
+ }
+}
+
+func TestOpenRedirect_BaselineBodyNotMutated(t *testing.T) {
+ req := orBodyReq(t)
+ orig := string(req.Body)
+ _ = (OpenRedirect{Enabled: true}).Generate(req, nil)
+ if string(req.Body) != orig {
+ t.Errorf("baseline body mutated: %q → %q", orig, string(req.Body))
+ }
+}
+
+func TestOpenRedirect_BaselineRefererNotMutated(t *testing.T) {
+ req := orRefererReq(t)
+ orig := req.Headers.Get("Referer")
+ _ = (OpenRedirect{Enabled: true}).Generate(req, nil)
+ if req.Headers.Get("Referer") != orig {
+ t.Errorf("baseline Referer mutated: %q → %q", orig, req.Headers.Get("Referer"))
+ }
+}
+
+// Open-redirect variants must NOT clash with ssrf-probe's Type — they are
+// different vuln classes (server-side fetch vs. client-side redirect) with
+// different fixes (URL allowlist + same-origin check vs. metadata-endpoint
+// blocking + outbound URL allowlist).
+func TestOpenRedirect_DisjointFromSSRFProbe(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ for _, v := range vs {
+ if v.Mutation.Type == "ssrf-probe" {
+ t.Errorf("open-redirect variant must not carry ssrf-probe Type")
+ }
+ if v.Mutation.Class == "ssrf" {
+ t.Errorf("open-redirect variant must not carry ssrf Class")
+ }
+ }
+}
+
+func TestOpenRedirect_PayloadCoverage(t *testing.T) {
+ vs := (OpenRedirect{Enabled: true}).Generate(orQueryReq(t), nil)
+ byShape := map[string]model.Variant{}
+ for _, v := range vs {
+ byShape[v.Mutation.Detail["shape"]] = v
+ }
+ for name, want := range openRedirectPayloads {
+ v, ok := byShape[name]
+ if !ok {
+ t.Fatalf("missing variant for technique %q", name)
+ }
+ // payload is recorded verbatim in Detail; query body carries an
+ // url-encoded copy — decode and check.
+ if v.Mutation.Detail["payload"] != want {
+ t.Errorf("technique %q: Detail payload %q != want %q",
+ name, v.Mutation.Detail["payload"], want)
+ }
+ decoded, _ := url.QueryUnescape(v.Base.URL.RawQuery)
+ if !strings.Contains(decoded, want) {
+ t.Errorf("technique %q: decoded query %q missing payload %q",
+ name, decoded, want)
+ }
+ if !strings.Contains(decoded, "id=42") {
+ t.Errorf("technique %q: lost untouched param id=42 in %q", name, decoded)
+ }
+ }
+}
+
+func TestOpenRedirect_NameMatchOverInclusive(t *testing.T) {
+ for _, name := range []string{
+ "next", "next_page", "redirect_uri", "REDIRECT_URL",
+ "returnTo", "return_url", "ReturnUrl", "callback_url",
+ "goto", "destination", "successUrl", "back_url",
+ } {
+ if !openRedirectNameMatches(name) {
+ t.Errorf("openRedirectNameMatches(%q) = false; want true", name)
+ }
+ }
+ for _, name := range []string{"id", "page", "name", "email", "color"} {
+ if openRedirectNameMatches(name) {
+ t.Errorf("openRedirectNameMatches(%q) = true; want false", name)
+ }
+ }
+}
+
+func TestOpenRedirect_HeaderSafeExcludesUnsafeShapes(t *testing.T) {
+ if openRedirectHeaderSafe("backslash-host") {
+ t.Errorf("backslash-host must be header-unsafe")
+ }
+ if openRedirectHeaderSafe("whitespace-prefix") {
+ t.Errorf("whitespace-prefix must be header-unsafe")
+ }
+ if !openRedirectHeaderSafe("cross-origin") {
+ t.Errorf("cross-origin must be header-safe")
+ }
+}
+
+func TestOpenRedirect_KnownTechniqueMatchesTable(t *testing.T) {
+ for _, name := range openRedirectTechniques {
+ if !openRedirectKnownTechnique(name) {
+ t.Errorf("technique %q listed but missing payload", name)
+ }
+ }
+ if len(openRedirectPayloads) != len(openRedirectTechniques) {
+ t.Errorf("payload count %d != technique count %d",
+ len(openRedirectPayloads), len(openRedirectTechniques))
+ }
+}
+
+// Param-name list must stay sorted (the order test pins the set).
+func TestOpenRedirect_ParamNamesSorted(t *testing.T) {
+ if !sort.StringsAreSorted(openRedirectParamNames) {
+ t.Errorf("openRedirectParamNames not sorted: %v", openRedirectParamNames)
+ }
+}
+
+// Technique list must stay sorted.
+func TestOpenRedirect_TechniqueListSorted(t *testing.T) {
+ if !sort.StringsAreSorted(openRedirectTechniques) {
+ t.Errorf("openRedirectTechniques not sorted: %v", openRedirectTechniques)
+ }
+}