diff --git a/CHANGELOG.md b/CHANGELOG.md index c8813f5..bcbc109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 + class (Omer Gil, BlackHat 2017; refreshed in the 2024–2026 BlackHat + Cache-Confusion / CDN-Confusion research). Where + `--content-type-confusion` attacks *which body parser each layer + chooses*, `--forbidden-bypass` attacks *how the path is matched against + a deny rule*, and `--host-header` attacks *which host the gate evaluates*, + `--cache-deception` attacks *which storage tier sees the response* — the + URL is decorated with cacheable file-extension shapes (`.css`, `.js`, + `.png`, `.jpg`, `.ico`, `.gif`, `.svg`) that a fronting CDN / edge cache + stores by default, while the application router strips, ignores, or + normalises away the decoration and still returns the caller's personal + response. The cache stores that personal response under a public-looking + key, exposing it to every later caller. Four disjoint technique shapes, + each cross-producted with the cacheable extension set in deterministic + sorted-by-name order: `path-suffix` (`/api/me/possession.css`, the + Omer-Gil original), `path-extension` (`/api/me.css`, framework-extension + stripping), `semicolon-suffix` (`/api/me;.css`, Tomcat / Spring + matrix-parameter), and `encoded-suffix` (`/api/me%2fpossession.css`, + gateway/router URL-normalisation desync). Every variant keeps the + caller's own credentials (`Identity == nil`) — this is NOT an identity + swap, the same caller's same fetch is decorated with a cacheable URL + shape so the comparative ladder can flag the candidate cache-deception + finding when the response remains owner-shaped. Endpoints already at a + cacheable extension are skipped (no-op probe); on a trailing-slash path + `path-extension` and `semicolon-suffix` are skipped (no terminal + segment), and the encoded-suffix decoded form is normalised to avoid + double-slashes. The mutation Detail carries `shape`, `extension`, + `path_from`, and `path_to` so the reporter (and any future repro-snippet + generator) can quote both URLs for the operator's cold-cache confirm + step. Findings are class `authz-bypass` (ASVS V8.3.x, severity HIGH). + Off by default: the decorated variants reach the caller's own personal + endpoints by design and observably warm the upstream cache at the + decorated URL on the caller's behalf — opt in via `--cache-deception`. + Wired through `buildRegistry`; the mutator is always registered (inert + when disabled) so the canonical `DefaultRegistry` order and the order + test stay unchanged. Covered by 17 new tests across + `internal/mutate/cache_deception_test.go` and + `internal/cli/buildregistry_forbidden_test.go` — disabled-by-default + contract, nil/empty/degenerate input safety, every-credentials-preserved + contract, full cross-product cell coverage, per-shape Path/RawPath + invariants (including the `%2f` un-double-encoded wire form), the + already-cacheable-extension skip, trailing-slash handling, + determinism, sorted-emission order, the `Name()` stability contract, + the not-in-DefaultRegistry contract, and the gating end-to-end through + `buildRegistry` (both wordlist-on and wordlist-off paths). + - **Content-Type confusion mutator** (`--content-type-confusion`, `internal/mutate/content_type_confusion.go`): a new mutator in the access-control bypass family. Where `--parameter-pollution` attacks *which diff --git a/README.md b/README.md index 0bcb347..7a41081 100755 --- a/README.md +++ b/README.md @@ -826,6 +826,70 @@ validation, so it only fires when you opt in — mirroring the gating of `--method-override`, `--csrf-header`, `--ws-hijack`, `--xxe`, and `--mass-assign`. +## Web Cache Deception (`--cache-deception`) + +Where `--content-type-confusion` attacks *which body parser each layer of +the stack chooses*, `--forbidden-bypass` attacks *how the path is matched +against a deny rule*, and `--host-header` attacks *which host the gate +believes the request targets*, `--cache-deception` attacks *which storage +tier sees the response* — the canonical Web Cache Deception family (Omer +Gil, BlackHat 2017; refreshed in the 2024–2026 BlackHat +Cache-Confusion / CDN-Confusion research). The URL is decorated with +cacheable file-extension shapes a fronting CDN / edge cache stores by +default; the application router strips, ignores, or normalises away the +decoration and still returns the caller's *personal* response. The cache +then serves that personal response under a public-looking key to every +later caller — including the unauthenticated internet: + +``` +possession scan capture.har \ + --matrix matrix.yaml \ + --cache-deception +``` + +Four technique shapes, each cross-producted with the cacheable extension +set (`css`, `js`, `png`, `jpg`, `ico`, `gif`, `svg` — the file types every +CDN's default rule stores by extension), emitted in deterministic +sorted-by-name order: + +- **path-suffix** — `/api/me` → `/api/me/possession.css`. The Omer-Gil + original shape; the cache sees a `.css` URL while route-globbing + frameworks (Express, Rails, greedy Spring path variables) still hit the + personal handler. +- **path-extension** — `/api/me` → `/api/me.css`. Frameworks that strip a + known extension before routing (Rails `respond_to`, ASP.NET Core + content-negotiation) still hit the personal handler, while the cache + stores the `.css` URL. +- **semicolon-suffix** — `/api/me` → `/api/me;.css`. Tomcat / Spring strip + the matrix-parameter segment when matching the handler; many caches + keep the literal `;` in the key. +- **encoded-suffix** — `/api/me` → `/api/me%2fpossession.css`. A cache that + URL-normalises before key-construction collapses `%2f` → `/` and sees a + `.css` extension; a router that does NOT normalise treats the whole + tail as one path segment and still routes to `/api/me` (the same + gateway/handler URL-normalisation desync class `--forbidden-bypass`'s + encoded path tricks exploit, applied post-path). + +Every variant keeps the caller's own credentials (`Identity == nil`) — +this is NOT an identity swap; the same caller's same fetch is decorated +with a cacheable URL shape. Endpoints whose path already ends in a +cacheable extension are skipped (the response is already at a cacheable +URL by intent). On a trailing-slash path the `path-extension` and +`semicolon-suffix` shapes are skipped (they need a non-empty terminal +segment); `path-suffix` and `encoded-suffix` still fire. The mutation +detail records the original path and the decorated path so the operator +can re-fetch the decorated URL from a cold cache to confirm the leak. +Findings are class `authz-bypass` (ASVS V8.3.x, severity HIGH). + +`--cache-deception` is **off by default**: the decorated variants reach +the caller's *own* personal endpoints by design (the bug being tested) +and therefore observably warm an upstream cache at the decorated URL on +the caller's behalf, so it only fires when you opt in — mirroring the +gating of `--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 33632eb..ac0d75c 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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -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) + regOff, err := buildRegistry("", 0, 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) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, 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) + reg, err := buildRegistry(f, 0, 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) } @@ -464,3 +464,65 @@ func TestBuildRegistry_ContentTypeConfusionWithWordlist(t *testing.T) { t.Errorf("enabled content-type-confusion (wordlist path) emitted 0 variants") } } + +// cdScanReq returns a captured GET request authenticated as alice against a +// personal endpoint, so the cache-deception mutator has a non-cacheable URL +// to decorate. +func cdScanReq() *model.CapturedRequest { + u, _ := url.Parse("https://api.example.com/api/me") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + return &model.CapturedRequest{ + ID: "alice-me", + Method: "GET", + URL: u, + Headers: h, + } +} + +// TestBuildRegistry_CacheDeceptionGating proves the --cache-deception flag +// 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) + if err != nil { + t.Fatalf("buildRegistry off: %v", err) + } + if regOff.Get("cache-deception") == nil { + t.Fatalf("cache-deception must always be registered, even when disabled") + } + if vs := regOff.Get("cache-deception").Generate(cdScanReq(), nil); len(vs) != 0 { + 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) + if err != nil { + t.Fatalf("buildRegistry on: %v", err) + } + m := regOn.Get("cache-deception") + if m == nil { + t.Fatalf("cache-deception missing from registry when enabled") + } + if vs := m.Generate(cdScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled cache-deception emitted 0 variants; want > 0") + } +} + +// TestBuildRegistry_CacheDeceptionWithWordlist verifies the alternate +// (wordlist) construction path also wires the gated cache-deception mutator. +func TestBuildRegistry_CacheDeceptionWithWordlist(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, true) + if err != nil { + t.Fatalf("buildRegistry with wordlist: %v", err) + } + if reg.Get("cache-deception") == nil { + t.Fatalf("cache-deception missing from wordlist-path registry") + } + if vs := reg.Get("cache-deception").Generate(cdScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled cache-deception (wordlist path) emitted 0 variants") + } +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index b33cf66..bacf3c8 100755 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -67,6 +67,7 @@ var ( scanParamPollution bool // --parameter-pollution: duplicate query/form parameters (HPP) to exploit cross-layer parsing disagreement for access-control bypass, using the caller's own credentials (off by default) 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) ) // scanCmd is the end-to-end scan command. Packets 1-3 contribute: @@ -152,6 +153,8 @@ func init() { "test Origin/Referer-validation access-control bypass using the caller's own credentials: spoof the Origin (and Referer) the request claims to come from — set Origin: null (allowlists that fail-open on the null origin from sandboxed iframes / redirect laundering), a wholly-foreign attacker site (apps that do not validate Origin at all), and attacker hosts crafted to defeat naive allowlist matching of the request's own host (prefix-match .attacker.example, suffix-match embedding the trusted labels, and userinfo-confusion @attacker.example); a state-change that succeeds under a forged origin where a correct check would refuse it is an origin-validation CSRF bypass; disjoint from --csrf-header (which forges the anti-CSRF token, not the origin) and --host-header (which spoofs the Host); off by default because the spoofed-origin variants re-issue the (often state-changing) request asserting an untrusted origin") scanCmd.Flags().BoolVar(&scanCTConfusion, "content-type-confusion", false, "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") } func resetScanFlags() { @@ -194,6 +197,7 @@ func resetScanFlags() { scanParamPollution = false scanOriginSpoof = false scanCTConfusion = false + scanCacheDeception = false } func runScan(cmd *cobra.Command, args []string) error { @@ -317,7 +321,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) + reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception) if err != nil { return err } @@ -1237,13 +1241,14 @@ func buildEvaluator(name string, matrix *model.RoleMatrix) (detect.Evaluator, er // (--parameter-pollution) mutator when paramPollution is true, and enabling the // OriginSpoof (--origin-spoof) mutator when originSpoof is true, and enabling // the ContentTypeConfusion (--content-type-confusion) mutator when ctConfusion -// is true. +// is true, and enabling the CacheDeception (--cache-deception) mutator when +// cacheDeception is true. // EnumerateID, JWTAuth, MassAssign, XXE, GraphQL, ForbiddenBypass, WSHijack, // CSRFHeader, MethodOverride, HostHeader, CookieTamper, HeaderInjection, -// ParamPollution, OriginSpoof, and ContentTypeConfusion 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 bool) (*mutate.Registry, error) { +// 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) { enumMutator := mutate.EnumerateID{N: enumerateN} jwtAuthMutator := mutate.JWTAuth{Enabled: jwtAttack} massAssignMutator := mutate.MassAssign{Enabled: massAssign} @@ -1259,15 +1264,16 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x paramPollutionMutator := mutate.ParamPollution{Enabled: paramPollution} originSpoofMutator := mutate.OriginSpoof{Enabled: originSpoof} ctConfusionMutator := mutate.ContentTypeConfusion{Enabled: ctConfusion} + cacheDeceptionMutator := mutate.CacheDeception{Enabled: cacheDeception} if wordlistPath == "" { // Extend the default registry with EnumerateID + JWTAuth + // MassAssign + XXE + GraphQL + ForbiddenBypass + WSHijack + CSRFHeader + // MethodOverride + HostHeader + CookieTamper + HeaderInjection + - // ParamPollution + OriginSpoof + ContentTypeConfusion (all no-op when - // disabled). + // ParamPollution + OriginSpoof + ContentTypeConfusion + CacheDeception + // (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) + all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator) return mutate.NewRegistry(all...), nil } data, err := os.ReadFile(wordlistPath) @@ -1309,6 +1315,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x paramPollutionMutator, originSpoofMutator, ctConfusionMutator, + cacheDeceptionMutator, ), nil } diff --git a/internal/mutate/cache_deception.go b/internal/mutate/cache_deception.go new file mode 100644 index 0000000..d355ccc --- /dev/null +++ b/internal/mutate/cache_deception.go @@ -0,0 +1,348 @@ +package mutate + +import ( + "sort" + "strings" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// CacheDeception is the Web Cache Deception (WCD) access-control bypass +// mutator (Omer Gil, BlackHat 2017; refreshed coverage in the 2024-2026 +// BlackHat Cache-Confusion / CDN-Confusion research). It targets the very +// common deployment shape where a CDN / edge cache (CloudFront, Fastly, +// Akamai, Cloudflare, Varnish, nginx proxy_cache) is wired to cache responses +// whose URL "looks static" — typically by file-extension allowlist (.css, +// .js, .png, .jpg, .ico, .gif, .svg) or by literal-path rules +// (/static/...), and at most lightly varies by query string — while the +// upstream application router strips, ignores, or normalises away the +// "static" decoration and still routes the request to a dynamic handler +// that returns the caller's *personal* response. +// +// The bug being tested: the application returns alice's personalised content +// (her email, her balance, her API key) at a URL the CDN considers cacheable. +// The cache stores alice's personal response under that key. Any subsequent +// caller fetching the same URL — possibly unauthenticated — gets alice's +// cached response served straight from the edge. This is authorisation +// failure by transport-layer leak: the application did the right thing +// authorising alice, the cache cheerfully serves the result to bob (or to +// the anonymous internet) because the gateway and the handler disagree on +// what the URL means. The technique recurs continuously in real bug-bounty +// programs — including the original PayPal disclosure that named the class — +// because the gateway/handler split is structural rather than a coding bug. +// +// Where SwapIdentity attacks *who* the caller is, SwapObject attacks *which* +// object is referenced, ForbiddenBypass attacks *how* the access-control +// layer routes the request (to slip past a deny rule), and HostHeader +// attacks *which* host the gate believes the request targets, CacheDeception +// attacks *which storage tier* sees the response — a fronting cache stores a +// personalised, owner-shaped response under a URL the cache rule considers +// public/static, exposing it to every later caller. The technique is +// disjoint from ForbiddenBypass (which mutates the path to defeat a deny +// match) and from HostHeader (which spoofs the host the gate evaluates): the +// caller here is *already permitted* to fetch their own personal endpoint; +// the probe asks whether decorating that fetch with cacheable-looking URL +// shape causes the upstream to *still* serve their personal data while the +// cache decides to store it. +// +// Every variant keeps the caller's own credentials (Identity == nil): this +// is emphatically NOT an identity swap. The bug being tested is "the same +// permitted caller receives their own personal response under a cacheable +// URL shape" — possession's comparative ladder fires when that response +// looks shaped like the caller's own baseline (owner-2xx, owner-reflected +// markers, similar body to the un-decorated baseline). Confirming the cache +// actually stored and served the response to *other* callers is a +// follow-up step a human operator does with a cold cache key; possession's +// job is to surface the candidate URL shapes that warrant that follow-up. +// This mirrors how the existing ForbiddenBypass mutator surfaces candidate +// bypass paths the operator confirms by hand. +// +// Four technique families, each emitted as a separate variant for +// attribution. Generation order is fixed (sorted-by-technique-name) so +// identical inputs yield an identical variant slice (--dry-run and the +// offline corpus cover it): +// +// - path-suffix: append a cacheable extension as a sibling segment after a +// slash, e.g. /api/me → /api/me/possession.css. Many frameworks +// (Express, Rails route-globbing, Spring path-variable greedy match, +// Django catch-alls) route the request to the original handler ignoring +// the trailing segment, while the cache sees a .css URL and stores the +// personal response. This is the original Omer-Gil shape; the +// intermediate path component prevents collision with a real static +// file at /api/me.css and is the form most consistently observed in +// write-ups. +// +// - path-extension: append the extension directly to the last path +// segment, e.g. /api/me → /api/me.css. Frameworks that strip a known +// extension before routing (Rails respond_to format, ASP.NET Core +// content-negotiation, some Spring config) still hit the personal +// handler, while the cache stores the .css URL. Disjoint from +// path-suffix because no intermediate "/" appears. +// +// - semicolon-suffix: append the cacheable extension after a `;` +// matrix-parameter segment, e.g. /api/me → /api/me;.css. Tomcat / +// Spring strip the matrix segment when matching the handler; many +// caches treat the literal URL (including the `;`) as the cache key and +// see a .css response. Variant of path-suffix that targets specifically +// the JEE/Spring matrix-parameter convention; emitted separately so a +// confirmed bypass attributes the exact mechanism. +// +// - encoded-suffix: append the cacheable extension after a +// percent-encoded path separator, e.g. /api/me → /api/me%2fpossession.css. +// A cache normalising the URL before key-construction collapses the +// %2f to /, sees a .css extension, and caches; an upstream router that +// does NOT normalise treats the whole thing as one segment and still +// routes to /api/me (or to a NotFound that some misconfigured handlers +// fall through to the original). This is the gateway/handler URL +// normalisation desync — the same class as ForbiddenBypass's encoded +// path tricks, but applied to *post-path* cache-shape decoration rather +// than *pre-path* deny-rule evasion. +// +// For each of those four shapes, one variant is emitted per cacheable +// extension. The extension set is kept small and high-signal: the file +// types every CDN / edge cache rule lists by default, sorted alphabetically. +// Cross-product is (4 shapes × len(cacheableExtensions)) variants — bounded +// and deterministic. The variant ID derived by the planner from the +// mutation Detail carries both the shape and the extension so the offline +// corpus tests pin every cross-product cell. +// +// Endpoints whose path already ends in a cacheable extension are skipped: +// the response is already at a cacheable URL by intent and a probe would +// produce a no-op (or a byte-identical response that the comparative ladder +// would mark inconclusive). This is the same no-op-skip pattern HostHeader +// uses for spoof == original-host. +// +// Detection rides the existing comparative ladder unchanged. The caller's +// own baseline against the original (un-decorated) path is the reference; a +// variant that returns an owner-shaped 2xx (or whose body remains highly +// similar to that baseline, or which reflects the owner's markers) under a +// cacheable URL shape is the candidate cache-deception finding. Findings +// are class "authz-bypass" (ASVS V8.3.x, severity HIGH) — the same class +// ForbiddenBypass, HostHeader, MethodOverride, CSRFHeader, CookieTamper, +// HeaderInjection, ParamPollution, OriginSpoof, and ContentTypeConfusion +// use. The mutation Detail carries the shape and the cacheable extension so +// the reporter (and any future repro-snippet generator) can quote both the +// original URL and the cacheable shape the operator should re-fetch from a +// cold cache to confirm the leak. +// +// CacheDeception is OFF by default (Enabled == false). The decorated +// variants reach the *target's own* personal endpoints by design (this is +// the whole point of the test) and therefore observably warm the upstream +// cache at the decorated URL on the caller's behalf; the operator must +// explicitly opt in via --cache-deception. This mirrors the off-by-default +// gating of XXE, MassAssign, EnumerateID, ForbiddenBypass, WSHijack, +// CSRFHeader, MethodOverride, HostHeader, CookieTamper, HeaderInjection, +// ParamPollution, OriginSpoof, and ContentTypeConfusion. +type CacheDeception struct { + Enabled bool +} + +func (CacheDeception) Name() string { return "cache-deception" } + +// cacheableExtensions is the cache-extension allowlist most consistently +// observed in CDN default rules and operator-written cache-control configs. +// Kept narrow and high-signal — the file types every edge cache stores by +// extension. Sorted alphabetically so generation order is deterministic +// (the order test asserts this directly); .ico is included because +// favicon.ico is a near-universal cache-by-extension hit and a common WCD +// payload in surveyed write-ups. +var cacheableExtensions = []string{ + "css", + "gif", + "ico", + "jpg", + "js", + "png", + "svg", +} + +// cacheableSegmentName is the intermediate segment for the path-suffix and +// encoded-suffix shapes. It is a deliberate, recognisably-attacker-supplied +// token (the project name) so the request is traceable in an upstream log +// and never collides with a real static asset name. Kept as a package +// constant rather than a generated value so the variant ID is stable across +// runs and across builds — the offline corpus depends on that stability. +const cacheableSegmentName = "possession" + +func (cd CacheDeception) Generate(base *model.CapturedRequest, _ *model.RoleMatrix) []model.Variant { + if !cd.Enabled || base == nil || base.URL == nil { + return nil + } + + origPath := base.URL.Path + if origPath == "" { + origPath = "/" + } + + // Skip endpoints whose path already ends in a cacheable extension: the + // response is already at a cacheable URL by intent and the probe would + // be a no-op (the decorated URL is identical or the upstream sees the + // same handler twice). This mirrors HostHeader's no-op-skip for + // spoof == original-host. + if pathHasCacheableExtension(origPath) { + return nil + } + + // Deterministic copies sorted alphabetically; the order test pins both. + exts := append([]string(nil), cacheableExtensions...) + sort.Strings(exts) + + // The four shape names, sorted so the cross-product emission order is + // deterministic regardless of insertion order below. Pinned by the + // order test. + shapes := []string{ + "encoded-suffix", + "path-extension", + "path-suffix", + "semicolon-suffix", + } + sort.Strings(shapes) + + var out []model.Variant + for _, shape := range shapes { + for _, ext := range exts { + decoded, escaped, ok := buildCacheDeceptionPath(shape, origPath, ext) + if !ok { + continue + } + // Guard against a transform that happened to produce the same + // wire path as the original (e.g. a degenerate input). The + // comparative ladder cannot distinguish a byte-identical + // variant from the baseline, so emitting one would produce a + // false inconclusive. + if escaped == origPath { + continue + } + req := CloneRequest(base) + cloneURL(req, base) + req.URL.Path = decoded + // RawPath is honoured by url.URL.String() only when it is a + // valid escaping of Path; the encoded-suffix shape relies on + // this to reach the wire with %2f un-double-encoded (the same + // invariant ForbiddenBypass's encoded path transforms rely on). + req.URL.RawPath = escaped + + out = append(out, model.Variant{ + Base: req, + Identity: nil, // credentials unchanged — same permitted caller + Mutation: model.Mutation{ + Type: "cache-deception", + Description: "decorate URL (" + shape + ":" + ext + + ") so a fronting cache stores the personal response at a cacheable key", + Detail: map[string]string{ + "cache-deception": shape + ":" + ext, + "technique": shape + ":" + ext, + "shape": shape, + "extension": ext, + "path_from": origPath, + "path_to": escaped, + }, + Class: "authz-bypass", + }, + }) + } + } + + return out +} + +// buildCacheDeceptionPath returns (decoded, escaped, ok) for a given shape + +// origPath + extension. The decoded form is what url.URL.Path holds; the +// escaped form is what goes on the wire via url.URL.RawPath. The two are +// identical for shapes that inject no percent-encoding; the encoded-suffix +// shape sets them deliberately so %2f survives un-double-encoded. +// +// The function is pure — no I/O, no time-dependent state — and is exported +// (lowercase, package-private) to the test file so individual shapes can be +// unit-tested without going through Generate. +func buildCacheDeceptionPath(shape, origPath, ext string) (decoded, escaped string, ok bool) { + if origPath == "" || ext == "" { + return "", "", false + } + switch shape { + case "path-suffix": + // /api/me -> /api/me/. + // Ensure exactly one "/" between origPath and the appended segment. + joiner := "/" + if strings.HasSuffix(origPath, "/") { + joiner = "" + } + p := origPath + joiner + cacheableSegmentName + "." + ext + return p, p, true + + case "path-extension": + // /api/me -> /api/me. + // A trailing slash makes the "last segment" empty; the technique + // targets a real terminal segment, so skip when the path ends in + // "/" (the path-suffix shape covers that case). + if strings.HasSuffix(origPath, "/") { + return "", "", false + } + p := origPath + "." + ext + return p, p, true + + case "semicolon-suffix": + // /api/me -> /api/me;. + // Tomcat/Spring strip the matrix-parameter segment; many caches + // keep it in the key. Skip on a trailing slash for the same reason + // path-extension does. + if strings.HasSuffix(origPath, "/") { + return "", "", false + } + p := origPath + ";." + ext + return p, p, true + + case "encoded-suffix": + // /api/me -> /api/me%2f. + // A cache that URL-normalises before key-construction collapses + // %2f→/ and sees a . extension; a router that does NOT + // normalise treats the whole tail as one path segment. If the + // origPath already ends in "/", strip it from the decoded form so + // the decoded view does not double-slash (the escaped form is + // what reaches the wire and is unambiguous regardless). + trimmed := strings.TrimRight(origPath, "/") + if trimmed == "" { + trimmed = "" + } + decodedPath := trimmed + "/" + cacheableSegmentName + "." + ext + escapedPath := origPath + "%2f" + cacheableSegmentName + "." + ext + return decodedPath, escapedPath, true + } + return "", "", false +} + +// pathHasCacheableExtension reports whether the path's final segment already +// ends in one of the cacheableExtensions (case-insensitive). Used to skip +// endpoints whose response is already at a cacheable URL by intent — a +// further extension-decoration probe would be a no-op or near-no-op the +// comparative ladder can't usefully classify. +func pathHasCacheableExtension(path string) bool { + // The final segment is everything after the last "/". + last := path + if i := strings.LastIndexByte(path, '/'); i >= 0 { + last = path[i+1:] + } + dot := strings.LastIndexByte(last, '.') + if dot < 0 || dot == len(last)-1 { + return false + } + suffix := strings.ToLower(last[dot+1:]) + for _, ext := range cacheableExtensions { + if suffix == ext { + return true + } + } + return false +} + +// cacheDeceptionTechnique is a small helper used in tests to assert a +// mutation's technique without depending on the Detail map layout from +// outside the package. Kept here so the technique-key contract lives next +// to its producer (the same pattern hostHeaderTechnique and +// originSpoofTechnique establish). +func cacheDeceptionTechnique(m model.Mutation) string { + if m.Detail == nil { + return "" + } + return strings.TrimSpace(m.Detail["technique"]) +} diff --git a/internal/mutate/cache_deception_test.go b/internal/mutate/cache_deception_test.go new file mode 100644 index 0000000..dbb4c56 --- /dev/null +++ b/internal/mutate/cache_deception_test.go @@ -0,0 +1,378 @@ +package mutate + +import ( + "net/http" + "net/url" + "sort" + "strings" + "testing" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// cdReq builds a captured GET request authenticated as alice against her +// personal endpoint. Used as the canonical input across the cache-deception +// tests. +func cdReq(t *testing.T) *model.CapturedRequest { + t.Helper() + u, _ := url.Parse("https://api.example.com/api/me") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + return &model.CapturedRequest{ + ID: "alice-me", + Method: "GET", + URL: u, + Headers: h, + } +} + +// cdTechniques indexes variants by their technique string for assertion +// lookup. Mirrors hhTechniques / originSpoofTechnique-style helpers from +// the sibling mutator tests. +func cdTechniques(vs []model.Variant) map[string]model.Variant { + out := make(map[string]model.Variant, len(vs)) + for _, v := range vs { + out[cacheDeceptionTechnique(v.Mutation)] = v + } + return out +} + +func TestCacheDeception_DisabledByDefault(t *testing.T) { + if vs := (CacheDeception{}).Generate(cdReq(t), nil); len(vs) != 0 { + t.Fatalf("cache-deception must be off by default; got %d variants", len(vs)) + } +} + +func TestCacheDeception_NilBaseSafe(t *testing.T) { + if vs := (CacheDeception{Enabled: true}).Generate(nil, nil); vs != nil { + t.Errorf("nil base must yield nil variants; got %v", vs) + } +} + +// A request whose URL carries no path is treated as "/" and still emits +// variants — there is nothing pathological about a root endpoint as a +// cache-deception target. +func TestCacheDeception_EmptyPathTreatedAsRoot(t *testing.T) { + req := cdReq(t) + req.URL = &url.URL{Host: "api.example.com"} // empty Path + vs := (CacheDeception{Enabled: true}).Generate(req, nil) + if len(vs) == 0 { + t.Fatal("empty path must be treated as / and still produce variants") + } + for _, v := range vs { + if !strings.HasPrefix(v.Base.URL.Path, "/") { + t.Errorf("path %q should start with /", v.Base.URL.Path) + } + } +} + +// Every variant must keep the caller's own credentials (Identity == nil): +// this is a same-caller cache-shape probe, NOT an identity swap. Mirrors +// the equivalent assertion in host_header_test.go and origin_spoof_test.go. +func TestCacheDeception_KeepsCallerCredentials(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + if len(vs) == 0 { + t.Fatal("expected variants for an enabled cache-deception mutator") + } + for _, v := range vs { + tech := cacheDeceptionTechnique(v.Mutation) + if v.Identity != nil { + t.Errorf("technique %q: Identity must be nil (same caller); got %v", tech, v.Identity) + } + if v.Base.Headers.Get("Authorization") != "Bearer alice-token" { + t.Errorf("technique %q: caller credentials altered; Authorization=%q", + tech, v.Base.Headers.Get("Authorization")) + } + if v.Mutation.Type != "cache-deception" { + t.Errorf("technique %q: Type = %q; want cache-deception", tech, v.Mutation.Type) + } + if v.Mutation.Class != "authz-bypass" { + t.Errorf("technique %q: Class = %q; want authz-bypass", tech, v.Mutation.Class) + } + } +} + +// The full cross-product is (4 shapes × 7 cacheable extensions) = 28 +// variants for a clean path with no trailing slash and no existing +// extension. Pins both the shape count and the extension set; either +// drifting silently is the failure mode this test is here to prevent. +func TestCacheDeception_FullCrossProduct(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + wantShapes := 4 + wantExts := len(cacheableExtensions) + want := wantShapes * wantExts + if len(vs) != want { + t.Errorf("variant count = %d; want %d (%d shapes × %d extensions)", + len(vs), want, wantShapes, wantExts) + } + // Each shape must appear exactly len(extensions) times. + shapeCounts := map[string]int{} + for _, v := range vs { + shapeCounts[v.Mutation.Detail["shape"]]++ + } + for _, s := range []string{"path-suffix", "path-extension", "semicolon-suffix", "encoded-suffix"} { + if shapeCounts[s] != wantExts { + t.Errorf("shape %q: %d variants; want %d (one per extension)", + s, shapeCounts[s], wantExts) + } + } + // Each extension must appear exactly wantShapes times. + extCounts := map[string]int{} + for _, v := range vs { + extCounts[v.Mutation.Detail["extension"]]++ + } + for _, e := range cacheableExtensions { + if extCounts[e] != wantShapes { + t.Errorf("extension %q: %d variants; want %d (one per shape)", + e, extCounts[e], wantShapes) + } + } +} + +// path-suffix decorates the URL as //possession.. The decoded +// and escaped forms are identical (no percent-encoding injected) and the +// wire path is what a CDN sees as a static asset. +func TestCacheDeception_PathSuffixShape(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + tech := cdTechniques(vs) + v, ok := tech["path-suffix:css"] + if !ok { + t.Fatal("missing path-suffix:css variant") + } + wantPath := "/api/me/possession.css" + if v.Base.URL.Path != wantPath { + t.Errorf("Path = %q; want %q", v.Base.URL.Path, wantPath) + } + if v.Base.URL.RawPath != wantPath { + t.Errorf("RawPath = %q; want %q (no encoding injected)", v.Base.URL.RawPath, wantPath) + } + if v.Mutation.Detail["path_from"] != "/api/me" { + t.Errorf("path_from = %q; want /api/me", v.Mutation.Detail["path_from"]) + } + if v.Mutation.Detail["path_to"] != wantPath { + t.Errorf("path_to = %q; want %q", v.Mutation.Detail["path_to"], wantPath) + } +} + +// path-extension decorates the URL as /.. Disjoint from +// path-suffix because no intermediate slash appears. +func TestCacheDeception_PathExtensionShape(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + tech := cdTechniques(vs) + v, ok := tech["path-extension:js"] + if !ok { + t.Fatal("missing path-extension:js variant") + } + wantPath := "/api/me.js" + if v.Base.URL.Path != wantPath { + t.Errorf("Path = %q; want %q", v.Base.URL.Path, wantPath) + } + if v.Base.URL.RawPath != wantPath { + t.Errorf("RawPath = %q; want %q", v.Base.URL.RawPath, wantPath) + } +} + +// semicolon-suffix decorates the URL as /;. — the Tomcat/Spring +// matrix-parameter form. The literal `;` MUST appear on the wire (the +// cache key relies on it). +func TestCacheDeception_SemicolonSuffixShape(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + tech := cdTechniques(vs) + v, ok := tech["semicolon-suffix:png"] + if !ok { + t.Fatal("missing semicolon-suffix:png variant") + } + wantPath := "/api/me;.png" + if v.Base.URL.Path != wantPath { + t.Errorf("Path = %q; want %q", v.Base.URL.Path, wantPath) + } + if !strings.Contains(v.Base.URL.RawPath, ";") { + t.Errorf("RawPath = %q; expected literal ';' to survive to the wire", v.Base.URL.RawPath) + } +} + +// encoded-suffix decorates the URL as /%2fpossession.. The Path +// field holds the decoded form (with a real "/"); the RawPath field holds +// the literal %2f escape so url.URL.String() emits it un-double-encoded — +// the same invariant the ForbiddenBypass encoded transforms rely on. +func TestCacheDeception_EncodedSuffixShape(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + tech := cdTechniques(vs) + v, ok := tech["encoded-suffix:ico"] + if !ok { + t.Fatal("missing encoded-suffix:ico variant") + } + wantDecoded := "/api/me/possession.ico" + wantEscaped := "/api/me%2fpossession.ico" + if v.Base.URL.Path != wantDecoded { + t.Errorf("Path = %q; want %q (decoded form)", v.Base.URL.Path, wantDecoded) + } + if v.Base.URL.RawPath != wantEscaped { + t.Errorf("RawPath = %q; want %q (literal %%2f survives)", v.Base.URL.RawPath, wantEscaped) + } + // Sanity: url.URL.String() must emit the escaped form on the wire. + if !strings.Contains(v.Base.URL.String(), "%2f") { + t.Errorf("URL.String() = %q; want literal %%2f on the wire", v.Base.URL.String()) + } +} + +// A path that already ends in one of the cacheable extensions is a no-op +// target — the response is already at a cacheable URL by intent. Skipping +// it prevents byte-identical or near-identical probes the comparative +// ladder cannot classify. +func TestCacheDeception_SkipsAlreadyCacheable(t *testing.T) { + req := cdReq(t) + for _, p := range []string{"/static/app.css", "/img/photo.JPG", "/icon.ico", "/scripts/lib.js"} { + u, _ := url.Parse("https://api.example.com" + p) + req.URL = u + if vs := (CacheDeception{Enabled: true}).Generate(req, nil); len(vs) != 0 { + t.Errorf("path %q already has cacheable extension; want 0 variants, got %d", p, len(vs)) + } + } +} + +// A path with a trailing slash collapses path-extension and semicolon-suffix +// (which can't meaningfully extend an empty terminal segment); only +// path-suffix and encoded-suffix fire. +func TestCacheDeception_TrailingSlashHandling(t *testing.T) { + req := cdReq(t) + u, _ := url.Parse("https://api.example.com/api/me/") + req.URL = u + vs := (CacheDeception{Enabled: true}).Generate(req, nil) + if len(vs) == 0 { + t.Fatal("trailing-slash path must still produce path-suffix and encoded-suffix variants") + } + gotShapes := map[string]bool{} + for _, v := range vs { + gotShapes[v.Mutation.Detail["shape"]] = true + } + for _, want := range []string{"path-suffix", "encoded-suffix"} { + if !gotShapes[want] { + t.Errorf("trailing-slash path: missing shape %q", want) + } + } + for _, notWant := range []string{"path-extension", "semicolon-suffix"} { + if gotShapes[notWant] { + t.Errorf("trailing-slash path: shape %q must not fire on an empty terminal segment", notWant) + } + } + // path-suffix on /api/me/ must not double-slash to /api/me//possession.css. + for _, v := range vs { + if strings.Contains(v.Base.URL.Path, "//") { + t.Errorf("variant Path = %q contains double slash", v.Base.URL.Path) + } + } +} + +// Generate must be deterministic: identical input yields an identical +// variant slice (same techniques in the same order). Pins the +// "sorted-by-name" emission contract. +func TestCacheDeception_Deterministic(t *testing.T) { + a := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + b := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + if len(a) != len(b) { + t.Fatalf("non-deterministic length: %d vs %d", len(a), len(b)) + } + for i := range a { + ta := cacheDeceptionTechnique(a[i].Mutation) + tb := cacheDeceptionTechnique(b[i].Mutation) + if ta != tb { + t.Errorf("pos %d: non-deterministic technique %q vs %q", i, ta, tb) + } + if a[i].Mutation.Description != b[i].Mutation.Description { + t.Errorf("pos %d: non-deterministic description %q vs %q", + i, a[i].Mutation.Description, b[i].Mutation.Description) + } + } +} + +// Shape names are emitted in sorted order (the outer loop of the +// cross-product) so generation order is stable across builds. Within a +// shape, extensions are emitted in sorted order; both invariants are +// pinned here. +func TestCacheDeception_SortedShapesAndExtensions(t *testing.T) { + vs := (CacheDeception{Enabled: true}).Generate(cdReq(t), nil) + var seenShapes []string + last := "" + for _, v := range vs { + s := v.Mutation.Detail["shape"] + if s != last { + seenShapes = append(seenShapes, s) + last = s + } + } + if !sort.StringsAreSorted(seenShapes) { + t.Errorf("shapes emitted out of order: %v", seenShapes) + } + // Within each shape, extensions must be sorted. + byShape := map[string][]string{} + for _, v := range vs { + byShape[v.Mutation.Detail["shape"]] = append(byShape[v.Mutation.Detail["shape"]], v.Mutation.Detail["extension"]) + } + for shape, exts := range byShape { + if !sort.StringsAreSorted(exts) { + t.Errorf("shape %q: extensions emitted out of order: %v", shape, exts) + } + } +} + +// The Name() string is the public identifier used by the registry, by the +// reporter, and by allowlist suppressions. It must be stable — changing it +// silently invalidates existing allowlist entries operators have written +// against the tool's output. Mirrors the equivalent assertion in +// origin_spoof_test.go and content_type_confusion_test.go. +func TestCacheDeception_Name(t *testing.T) { + if got := (CacheDeception{}).Name(); got != "cache-deception" { + t.Errorf("Name() = %q; want cache-deception", got) + } +} + +// cache-deception must NOT be in the DefaultRegistry — it is registered +// separately by buildRegistry as an opt-in mutator. Mirrors the equivalent +// assertion every other off-by-default mutator has against DefaultRegistry. +func TestCacheDeception_NotInDefaultRegistry(t *testing.T) { + for _, n := range DefaultRegistry().Names() { + if n == "cache-deception" { + t.Fatalf("cache-deception must NOT be in DefaultRegistry() (off-by-default mutator)") + } + } +} + +// buildCacheDeceptionPath returns ok=false for an unknown shape so the +// caller (Generate) skips emission silently rather than synthesising a +// degenerate variant. +func TestCacheDeception_UnknownShapeSkipped(t *testing.T) { + if _, _, ok := buildCacheDeceptionPath("nonsense-shape", "/api/me", "css"); ok { + t.Error("unknown shape must return ok=false") + } +} + +// An empty path or empty extension yields ok=false: defensive guards +// against degenerate inputs the caller could otherwise propagate to the +// wire. +func TestCacheDeception_DegenerateInputsSkipped(t *testing.T) { + if _, _, ok := buildCacheDeceptionPath("path-suffix", "", "css"); ok { + t.Error("empty origPath must return ok=false") + } + if _, _, ok := buildCacheDeceptionPath("path-suffix", "/api/me", ""); ok { + t.Error("empty extension must return ok=false") + } +} + +// pathHasCacheableExtension is case-insensitive and only checks the final +// path segment, so a directory-name fragment like /css-experiments/list +// does NOT trip the guard. +func TestCacheDeception_PathHasCacheableExtensionMatchesFinalSegmentOnly(t *testing.T) { + if !pathHasCacheableExtension("/static/app.CSS") { + t.Error("case-insensitive match: /static/app.CSS should be cacheable") + } + if pathHasCacheableExtension("/css-experiments/list") { + t.Error("non-final-segment dot must not trip the guard") + } + if pathHasCacheableExtension("/api/me") { + t.Error("no extension at all must not trip the guard") + } + if pathHasCacheableExtension("/file.") { + t.Error("trailing dot with empty suffix must not trip the guard") + } +}