From 5337b39bd0eab761e7dae7441a5832dd324f108d Mon Sep 17 00:00:00 2001 From: Bugsy Hewitt Date: Sat, 30 May 2026 00:18:00 -0400 Subject: [PATCH] Add --open-redirect mutator (CWE-601 unvalidated redirect) Targets 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, --open-redirect attacks what destination the application bounces the caller's browser to: a redirect-destination value (next, redirect_uri, returnTo, callback, ...) is reflected into a Location: header reaching an attacker-controlled external site, turning the trusted host into a phishing-redirect surface or leaking OAuth authorization codes via the redirect_uri. Four surfaces (query, urlencoded body, top-level JSON string, Referer header when present) cross-producted with seven disjoint payload techniques: backslash-host, cross-origin, data-uri, javascript-uri, protocol-relative, userinfo-confusion, whitespace-prefix. The Referer surface emits only the header-safe subset (net/http rejects/trims the other two). Eligibility by name substring match against a redirect-destination token list, or by value shape (absolute http(s) URL). Every variant keeps the caller's own credentials (Identity == nil); requests with no redirect-destination parameter and no Referer emit zero variants. Findings are class open-redirect, severity MEDIUM. Off by default (payloads point browsers at attacker URLs and embed XSS-via-redirect shapes). Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 46 ++ README.md | 82 ++++ internal/cli/buildregistry_forbidden_test.go | 66 +-- internal/cli/scan.go | 12 +- internal/mutate/open_redirect.go | 489 +++++++++++++++++++ internal/mutate/open_redirect_test.go | 439 +++++++++++++++++ 6 files changed, 1098 insertions(+), 36 deletions(-) create mode 100644 internal/mutate/open_redirect.go create mode 100644 internal/mutate/open_redirect_test.go 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) + } +}