From 47c27aef2ede6d144c29b87435c65924d34a388d Mon Sep 17 00:00:00 2001 From: Afraa Shaikh Date: Fri, 17 Jul 2026 01:07:56 +0530 Subject: [PATCH] Add explicit CSPT detection skill, categorization, fixtures, and tests Closes #776 --- docs/cspt-evidence-runbook.md | 127 ++++++++ strix/report/sarif.py | 11 + .../client_side_path_traversal.md | 234 ++++++++++++++ strix/tools/reporting/tool.py | 34 ++ tests/fixtures/cspt/README.md | 35 ++ .../cspt/negative_safe_construction.html | 29 ++ tests/fixtures/cspt/negative_validated.html | 32 ++ tests/fixtures/cspt/positive_direct.html | 26 ++ tests/fixtures/cspt/positive_encoded.html | 29 ++ tests/fixtures/cspt/positive_normalized.html | 29 ++ tests/fixtures/cspt/positive_postmessage.html | 29 ++ tests/fixtures/cspt/positive_storage.html | 33 ++ tests/test_cspt_skill.py | 300 ++++++++++++++++++ 13 files changed, 948 insertions(+) create mode 100644 docs/cspt-evidence-runbook.md create mode 100644 strix/skills/vulnerabilities/client_side_path_traversal.md create mode 100644 tests/fixtures/cspt/README.md create mode 100644 tests/fixtures/cspt/negative_safe_construction.html create mode 100644 tests/fixtures/cspt/negative_validated.html create mode 100644 tests/fixtures/cspt/positive_direct.html create mode 100644 tests/fixtures/cspt/positive_encoded.html create mode 100644 tests/fixtures/cspt/positive_normalized.html create mode 100644 tests/fixtures/cspt/positive_postmessage.html create mode 100644 tests/fixtures/cspt/positive_storage.html create mode 100644 tests/test_cspt_skill.py diff --git a/docs/cspt-evidence-runbook.md b/docs/cspt-evidence-runbook.md new file mode 100644 index 00000000..86e86524 --- /dev/null +++ b/docs/cspt-evidence-runbook.md @@ -0,0 +1,127 @@ +# CSPT evidence runbook + +How to reproduce and capture request-path evidence for a Client-Side Path +Traversal (CSPT) finding, so a confirmed report carries the proof the maintainer +asked for. This is the manual counterpart to the automated tests in +`tests/test_cspt_skill.py` — the tests guard the skill/fixtures/categorization in +CI; this runbook produces the live browser + proxy evidence a real finding needs +(which CI can't run). + +The vulnerability and its confirmation both happen **in the browser**: the win +condition is the victim's browser sending a request to an *unintended* same-origin +path, carrying the victim's session. So the evidence is the **outbound request +path on the wire**, read off the Caido proxy / a HAR capture — not the response +body alone. + +## Prerequisites + +- `agent-browser` CLI (pre-installed in the Strix sandbox), which auto-routes + through the Caido proxy via `http_proxy` / `https_proxy`. **Do not pass + `--proxy`** — see the `agent_browser` skill. +- An authenticated session on the target (so requests carry ambient credentials), + or one of the fixtures under `tests/fixtures/cspt/` served locally for a + self-contained demo. + +## Procedure + +```bash +# 1. Load the page. Authenticate so subsequent requests carry a session. +agent-browser open +agent-browser auth login # or: state load ./auth.json + +# 2. Start capturing traffic BEFORE triggering the sink. +agent-browser network har start + +# 3a. BENIGN CONTROL — drive the source with a normal value first. +# This establishes the intended request path for the side-by-side. +agent-browser open "/#/orders/12345" +agent-browser wait --load networkidle +agent-browser network requests # note the intended path, e.g. /api/orders/12345/detail + +# 3b. TRAVERSAL — drive the same source with a crafted value. +agent-browser open "/#/orders/..%2f..%2fadmin%2fkeys" +agent-browser wait --load networkidle +agent-browser network requests # note the traversed path, e.g. /api/admin/keys + +# 4. Stop the capture and keep the HAR as the primary artifact. +agent-browser network har stop /workspace/.agent-browser-screenshots/cspt.har + +# 5. Optional visual proof if the traversed response changes the page. +agent-browser screenshot +``` + +For a `postMessage` source, replace step 3b with an eval that posts the crafted +message into the vulnerable listener: + +```bash +cat <<'EOF' | agent-browser eval --stdin +window.postMessage({ path: "../../admin/keys" }, "*"); +EOF +``` + +For a storage source, seed the value first, then reload: + +```bash +agent-browser eval "localStorage.setItem('activeTenant', '../../admin')" +agent-browser open "" +``` + +## Confirmation criteria + +A finding is a real CSPT only when **all** of these hold, evidenced from the HAR: + +1. **Traversed path on the wire** — the outbound request path is the traversed + target (e.g. `/api/admin/keys`), not the intended one + (`/api/orders/12345/detail`). +2. **Ambient credentials** — that request carried the victim's session + (`Cookie` / `Authorization` header present; read it from the HAR entry). +3. **Benign control** — the same flow with a normal value hit the intended path. + Show both, side by side. +4. **Reach** — the traversed endpoint returned data or performed an action the + source value should not have been able to reach. + +Do not exfiltrate real secrets — a status / length / shape delta against a +low-sensitivity sibling endpoint is enough to prove reach. + +## Turning it into a report + +File with `create_vulnerability_report`: + +- `finding_class="client_side_path_traversal"` (**required** — separates it from + server-side path traversal / LFI / RFI, which share CWE-22). +- `cwe="CWE-22"`, `title` prefixed `Client-Side Path Traversal`. +- `evidence` — the intended vs. traversed request paths from the HAR, with the + credential header shown, plus the benign control, in fenced code blocks. + +### Evidence block template + +``` +Intended (benign control): + GET /api/orders/12345/detail + Cookie: session= + → 200 OK (own order) + +Traversed (CSPT): + GET /api/orders/../../admin/keys/detail + → browser normalizes to: GET /api/admin/keys + Cookie: session= + → 200 OK (admin key material reachable via a low-priv user's fragment) +``` + +## Demoing against the bundled fixtures + +The fixtures in `tests/fixtures/cspt/` are self-contained static pages. Serve the +directory and drive each one to see the positive/negative behavior without a real +target: + +```bash +python -m http.server 8000 --directory tests/fixtures/cspt +agent-browser network har start +agent-browser open "http://localhost:8000/positive_direct.html#/orders/../../admin/keys" +agent-browser wait --load networkidle +agent-browser network requests # observe the normalized /api/admin/keys request +``` + +The positive fixtures emit a traversed `/api/...` request; the negative fixtures +(`negative_validated.html`, `negative_safe_construction.html`) either reject the +input before the sink or keep the value out of the path entirely. diff --git a/strix/report/sarif.py b/strix/report/sarif.py index 6d5db177..481772bb 100644 --- a/strix/report/sarif.py +++ b/strix/report/sarif.py @@ -531,6 +531,16 @@ def _result_properties( if value not in (None, ""): strix[key] = value + # Surface a non-default finding sub-class (e.g. client_side_path_traversal) + # so consumers can machine-separate it from other findings that share the + # same CWE rule — CSPT and server-side path traversal / LFI / RFI all key + # on CWE-22, so ``ruleId`` alone cannot tell them apart. The default + # ``dynamic`` carries no signal and is omitted to avoid noise on every + # result. + finding_class = _string_value(report.get("finding_class")) + if finding_class and finding_class != "dynamic": + strix["finding_class"] = finding_class + # SARIF is written for external upload (code-scanning / ASPM), so it must # NOT carry the weaponized exploit payload — that stays a local run # artifact (vulnerabilities.json / the finding MD). We surface the PoC @@ -773,6 +783,7 @@ _VULN_CLASS_KEYWORDS = ( "default password", "session fixation", "open redirect", + "client-side path traversal", "path traversal", "directory traversal", "command injection", diff --git a/strix/skills/vulnerabilities/client_side_path_traversal.md b/strix/skills/vulnerabilities/client_side_path_traversal.md new file mode 100644 index 00000000..2dffc539 --- /dev/null +++ b/strix/skills/vulnerabilities/client_side_path_traversal.md @@ -0,0 +1,234 @@ +--- +name: client-side-path-traversal +description: Client-Side Path Traversal (CSPT) testing — attacker-controlled client input steering the victim's own authenticated browser requests to unintended same-origin endpoints +--- + +# Client-Side Path Traversal (CSPT) + +Client-Side Path Traversal happens **in the victim's browser**, not on the +server. Front-end JavaScript takes an attacker-influenceable client-side value +and concatenates it into the path of a request the browser then sends with the +victim's ambient credentials (cookies, `Authorization` headers set by the app, +session). By injecting `../` (or encoded / normalizing variants) into that value, +an attacker redirects the request to a **different same-origin endpoint** than the +developer intended — turning the victim's own browser into the request forger. + +The bug is a **path join in client code**, and the impact is realized entirely by +the browser's URL normalization (`new URL()`, the fetch/XHR URL parser) collapsing +`../` before the request leaves the page. Treat every client-readable input that +flows into a request URL as untrusted; validate the *resulting path*, not the raw +segment. + +CSPT is frequently a **gadget**, not a standalone bug: it lets an attacker point +an authenticated request at an endpoint whose JSON/HTML response is then fed into +a sink. Chained with a reflected/DOM XSS sink, a permissive CORS reader, or a +state-changing endpoint that trusts a weak/absent CSRF defense, a "read-only" +CSPT becomes account takeover or a stored-XSS delivery path. + +## Not CSPT (route these elsewhere) + +Keep this class clean. The following are **server-side** file/path issues covered +by the `path-traversal-lfi-rfi` skill — report them as `dynamic` findings, never +as `client_side_path_traversal`: + +- **Server-side path traversal** — server code joins a request parameter into a + filesystem path and reads/returns a file (`/download?file=../../etc/passwd`). + The traversal is resolved by the *server's* filesystem, not the browser. +- **LFI / RFI** — server includes/executes a file whose name/URL is user-controlled + (`php://filter`, remote include). Server-side execution. +- **Zip Slip / archive extraction** — server writes files outside a target dir. +- **SSRF** — server (not the browser) fetches an attacker-controlled URL. +- **Open redirect** — the browser is navigated (Location / `window.location`) to + an attacker origin. CSPT stays *same-origin* and targets a request **path**, not + a top-level navigation. If the sink is a navigation, it's `open-redirect`. + +The discriminator: **who resolves the `../` and who sends the final request?** +If it's the victim's browser building a same-origin `fetch`/XHR path → CSPT. If +it's the server touching its filesystem or making an outbound request → +server-side traversal / LFI / RFI / SSRF. + +## Sources (attacker-influenceable client input) + +Any of these reaching a request-URL sink is a candidate taint source: + +- **URL query params** — `URLSearchParams`, `location.search`, router query + (`?next=`, `?id=`, `?path=`, `?tab=`, `?redirect=`). +- **URL path segments** — SPA route params parsed from `location.pathname` + (`/orders/:id`, `react-router` / `vue-router` params). +- **Fragment / hash** — `location.hash`, hash-router segments. Never sent to the + server, so server-side WAFs don't see it — a favorite CSPT source. +- **`document.referrer`** — attacker controls it by hosting the referring page. +- **`postMessage` data** — `event.data` from another window/iframe without an + `event.origin` check. +- **Browser storage** — `localStorage`, `sessionStorage`, `IndexedDB`, cookies — + when an earlier, attacker-influenceable flow seeded the value. +- **`window.name`**, **BroadcastChannel**, **WebSocket message payloads** — cross + -context channels that survive navigation. + +## Sinks (client request builders whose path is tainted) + +The tainted value must land in the **path** portion (not just a query value) of: + +- `fetch(url)` / `fetch(new Request(url))` +- `XMLHttpRequest.open(method, url)` +- `axios.get/post/... (url)`, an axios instance with a `baseURL` +- jQuery `$.ajax({url})`, `$.get`, `$.getJSON`, `$.load` +- `navigator.sendBeacon(url)` +- `new EventSource(url)`, `new WebSocket(url)` +- framework data layers that build a request path: Angular `HttpClient`, + `HttpClient.get(\`/api/${seg}\`)`, RTK Query / SWR / react-query key→URL builders, + GraphQL client `uri`. + +The dangerous pattern is **template/`+` concatenation of a source into a path**: + +```js +fetch("/api/users/" + userId + "/avatar") // userId from location.hash +fetch(`/api/${tab}/settings`) // tab from ?tab= +axios.get("/api/orders/" + params.id) // id from route param +xhr.open("GET", baseApi + "/" + segment) // segment from postMessage +``` + +If `userId` = `../../admin/keys#`, the browser normalizes +`/api/users/../../admin/keys` → `/api/admin/keys`, sent with the victim's session. + +## Reconnaissance + +### Surface map + +- Grep client bundles for the sinks above joined with a source. In `agent_browser`: + ```bash + agent-browser open + # dump all script URLs, then fetch + grep them + agent-browser eval "Array.from(document.scripts).map(s=>s.src).filter(Boolean)" + ``` + Then pull each bundle and search for `fetch(`, `.open(`, `axios`, backtick URL + templates, and `location.hash` / `URLSearchParams` / `postMessage` / referrer. +- Map every endpoint the SPA calls (record with the proxy, see Validation). Note + which build their path from a client value vs. a constant. +- Identify **high-value sibling endpoints** reachable by traversal from a normal + path (admin, other users' resources, token/key endpoints, state-changing POSTs). + +### Capability probes + +Inject into each candidate source and watch the **outbound request path** (via the +proxy), not the response body alone — the win condition is the browser sending a +path you didn't intend. + +- **Direct**: `../`, `../../`, `..%2f` un-decoded, leading `/` (absolute-path + override: `id=/admin/keys` → `/api//admin/keys` or an absolute join). +- **Encoded**: `%2e%2e%2f`, `%2e%2e/`, `..%2f`, double-encoded `%252e%252e%252f` + when an app decodes before joining. The browser decodes `%2e`/`%2f` in the path + during normalization — test both raw and encoded because framework routers may + decode a layer first. +- **Normalized**: `....//` (folds to `../` after one collapse), `..././`, + `.%2e/`, mixed `..\` on apps that rewrite backslashes, redundant `//` and `/./` + segments that shift the base. +- **Base-relative vs root-relative**: confirm whether the app uses a relative + base (`api/x`) or rooted (`/api/x`) — it changes how many `../` are needed and + whether a leading `/` fully replaces the path. + +## Validation (browser + Caido proxy) + +Detection is dynamic: drive a real headless browser and read the **final outbound +request path** off the proxy. The `agent_browser` CLI auto-routes through the +Caido HTTP/HTTPS proxy (do **not** pass `--proxy`; it's wired via +`http_proxy`/`https_proxy`). See the `agent_browser` skill for the full CLI. + +Runbook: + +```bash +# 1. Load the page and (if needed) authenticate so requests carry a session. +agent-browser open +agent-browser auth login # or state load — see agent_browser skill + +# 2. Start capturing traffic BEFORE triggering the sink. +agent-browser network har start + +# 3. Drive the tainted source. Examples per source type: +# - fragment: navigate with a crafted hash +agent-browser open "/#/orders/..%2f..%2fadmin%2fkeys" +# - query: open with a crafted param +agent-browser open "/dashboard?tab=../../admin/config" +# - postMessage: eval a crafted message into the vulnerable listener +cat <<'EOF' | agent-browser eval --stdin +window.postMessage({ path: "../../admin/keys" }, "*"); +EOF + +# 4. Let the request fire, then read what actually left the browser. +agent-browser wait --load networkidle +agent-browser network requests # inspect fired request URLs +agent-browser network har stop /workspace/.agent-browser-screenshots/cspt.har + +# 5. Capture visual proof if the traversed response changes the page. +agent-browser screenshot +``` + +**Confirmation criteria** — you have a real CSPT when *all* hold: + +1. The **outbound request path in the proxy/HAR** is the traversed target + (`/api/admin/keys`), not the intended one (`/api/users//avatar`). +2. That request carried the **victim's credentials** (session cookie / + `Authorization` header present on the request — read it from the HAR). +3. There is a **benign same-endpoint control**: the same flow with a normal + value hits the intended path. Show both paths side by side. +4. The traversed endpoint returned data (or performed an action) the source + value should not have been able to reach. + +Record the request/response pair (method, full path, relevant headers, status) +from the HAR as evidence. Prefer reading a low-sensitivity sibling endpoint to +prove reach; do not exfiltrate real secrets — a status/length/shape delta is +enough proof. + +## Reporting + +File confirmed CSPT with `create_vulnerability_report` and: + +- `finding_class="client_side_path_traversal"` — **required** so the finding is + separated from server-side path traversal / LFI / RFI (which share the CWE but + are a different class). Do not leave it `dynamic`. +- `cwe="CWE-22"` (Improper Limitation of a Pathname to a Restricted Directory). +- `title` prefixed **`Client-Side Path Traversal`** (e.g. + `"Client-Side Path Traversal in order-detail fetch via URL fragment"`), so the + class is obvious in the report/SARIF even to a human skimming titles. +- `evidence` — the two request paths (intended vs. traversed) from the proxy/HAR, + with the credential header present, plus the benign control. Fenced code blocks. +- `poc_description` — steps to reproduce (navigate/craft source → observe path). +- `poc_script_code` — the crafted URL / `postMessage` / storage-seeding snippet. +- `impact` — name the concrete sibling endpoint reached and what it exposes or + changes; if chained (XSS/CSRF/CORS), state the chain. +- CVSS — usually `AV:N`, `UI:R` (victim opens a link), `PR:N`; scope/impact + depends on the reached endpoint. + +## False positives + +Not CSPT — do not report: + +- The source is **validated to the resulting path**: an allowlist of endpoints, + a strict `^[a-z0-9-]+$` segment check, or `encodeURIComponent` on a value used + as a **single segment** *and* the app rejects/encodes `/` and `.` so traversal + can't form. (`encodeURIComponent` alone is only safe when the value is one + segment and `%2f`/`%2e` aren't decoded downstream — verify the outbound path.) +- **Safe path construction**: the endpoint path is a constant and the client + value only ever becomes a **query parameter or request body field**, never part + of the path. A tainted query value is not CSPT (it may be another bug). +- The browser **does not normalize** the value into a different path (confirm on + the wire — no traversal actually occurred in the outbound request). +- The traversed request is **not** sent with ambient credentials (no session on + that request), so there's no privilege to abuse — note it, don't over-rate it. +- Server rejects the traversed path (404/403) with no differential — no reach, no + finding. + +## Pro tips + +1. Always judge on the **outbound request path in the proxy**, not the response — + a 404 on the traversed path still proves the browser was steered, but a + *successful* differential response is what makes it impactful. +2. The fragment (`location.hash`) is the strongest source: invisible to server + WAFs/logs and fully attacker-controlled via a link. +3. Test both raw (`../`) and encoded (`%2e%2e%2f`) — SPA routers often decode one + layer, so the effective payload differs by framework. +4. `....//` and other self-collapsing sequences bypass naive `replace("../","")` + filters — a single non-recursive strip leaves a working `../`. +5. CSPT shines when chained. After proving reach, look for a reader sink (CORS, + response rendered into DOM) or a state-changing target to escalate read-only + traversal into real impact. diff --git a/strix/tools/reporting/tool.py b/strix/tools/reporting/tool.py index b6f284ca..dcaef337 100644 --- a/strix/tools/reporting/tool.py +++ b/strix/tools/reporting/tool.py @@ -154,6 +154,15 @@ _REQUIRED_FIELDS = { _VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"}) +# Dynamically-proven finding sub-classes that a reporting agent may set on +# ``create_vulnerability_report``. ``dynamic`` is the default catch-all. +# ``client_side_path_traversal`` (CSPT) is called out explicitly so the +# finding is machine-separable from server-side path traversal / LFI / RFI, +# which share CWE-22 but are a distinct vulnerability class (see the +# ``client-side-path-traversal`` skill). ``dependency_cve`` is reserved for +# ``create_dependency_report`` and is not selectable here. +_VALID_FINDING_CLASS = frozenset({"dynamic", "client_side_path_traversal"}) + async def _do_create( # noqa: PLR0912 *, @@ -175,6 +184,7 @@ async def _do_create( # noqa: PLR0912 cwe: str | None, code_locations: list[dict[str, Any]] | None, fix_pr_body: str | None = None, + finding_class: str | None = None, agent_id: str | None = None, agent_name: str | None = None, ) -> dict[str, Any]: @@ -201,6 +211,13 @@ async def _do_create( # noqa: PLR0912 f"Invalid fix_effort: {fix_effort!r}. Must be one of: {sorted(_VALID_FIX_EFFORT)}" ) + finding_class = (finding_class or "dynamic").strip().lower() + if finding_class not in _VALID_FINDING_CLASS: + errors.append( + f"Invalid finding_class: {finding_class!r}. Must be one of: " + f"{sorted(_VALID_FINDING_CLASS)}" + ) + if not isinstance(cvss_breakdown, dict) or not cvss_breakdown: errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics") cvss_breakdown = {} @@ -295,6 +312,7 @@ async def _do_create( # noqa: PLR0912 cwe=cwe, code_locations=parsed_locations, fix_pr_body=fix_pr_body, + finding_class=finding_class, agent_id=agent_id if isinstance(agent_id, str) else None, agent_name=agent_name if isinstance(agent_name, str) else None, ) @@ -339,6 +357,7 @@ async def create_vulnerability_report( cwe: str | None = None, code_locations: list[dict[str, Any]] | None = None, fix_pr_body: str | None = None, + finding_class: str | None = None, ) -> str: """File a vulnerability report — one report per fully-verified finding. @@ -543,6 +562,20 @@ async def create_vulnerability_report( fix (summary + rationale). Prose/markdown only — the code change itself belongs in ``code_locations``. Omit for black-box findings. + finding_class: Optional finding sub-class for machine-readable + categorization. Defaults to ``"dynamic"``. Set to + ``"client_side_path_traversal"`` for a confirmed CSPT finding + — attacker-controlled client-side input (URL params, path + segments, fragment, ``document.referrer``, ``postMessage`` + data, browser storage) reaching a browser request sink + (``fetch`` / ``XMLHttpRequest`` / ``axios`` / ``$.ajax`` / + ``EventSource`` / ``WebSocket``) so the victim's own + authenticated browser is steered to an unintended same-origin + endpoint. CSPT shares ``CWE-22`` with server-side path + traversal / LFI / RFI but is a distinct class; setting this + keeps it separated in the report artifacts and SARIF. Do NOT + use it for server-side traversal / LFI / RFI (leave those + ``dynamic``). See the ``client-side-path-traversal`` skill. Example (abbreviated — mirror this structure):: @@ -610,6 +643,7 @@ async def create_vulnerability_report( cwe=cwe, code_locations=code_locations, fix_pr_body=fix_pr_body, + finding_class=finding_class, agent_id=agent_id, agent_name=agent_name, ) diff --git a/tests/fixtures/cspt/README.md b/tests/fixtures/cspt/README.md new file mode 100644 index 00000000..4f5d548b --- /dev/null +++ b/tests/fixtures/cspt/README.md @@ -0,0 +1,35 @@ +# CSPT test fixtures + +Self-contained HTML+JS apps exercising Client-Side Path Traversal (CSPT) +detection. Each file is a single static page with no server dependency — a +CSPT specialist can drive them with the `agent_browser` CLI (routed through the +Caido proxy) exactly as it would a real target, and the automated tests in +`tests/test_cspt_skill.py` assert their structural properties so they cannot +silently rot. + +Every app builds a same-origin request URL by joining a **client-side source** +into a request **path** and sending it via a browser request sink (`fetch` / +`XMLHttpRequest` / `axios`-style). The distinction between positive and negative +is whether the resulting path is validated before it reaches the sink. + +## Positive fixtures (a real CSPT — source flows unvalidated into the path) + +| File | Source | Sink | Traversal style | +|------|--------|------|-----------------| +| `positive_direct.html` | URL fragment (`location.hash`) | `fetch` | direct `../` | +| `positive_encoded.html` | query param (`?resource=`) | `XMLHttpRequest` | URL-encoded `%2e%2e%2f` | +| `positive_normalized.html` | route segment (`location.pathname`) | `fetch` | self-collapsing `....//` | +| `positive_postmessage.html` | `postMessage` `event.data` (no origin check) | `fetch` | direct `../` | +| `positive_storage.html` | `localStorage` value | `axios`-style wrapper | direct `../` | + +## Negative fixtures (safe — not CSPT) + +| File | Why it's safe | +|------|---------------| +| `negative_validated.html` | Segment matched against a strict allowlist / `^[a-z0-9-]+$` before use; traversal rejected. | +| `negative_safe_construction.html` | Endpoint path is a constant; the client value only becomes a **query parameter**, never part of the path. | + +## How to drive one manually (evidence runbook) + +See `docs/cspt-evidence-runbook.md` for the full browser + Caido proxy procedure +that produces the request-path evidence a confirmed finding requires. diff --git a/tests/fixtures/cspt/negative_safe_construction.html b/tests/fixtures/cspt/negative_safe_construction.html new file mode 100644 index 00000000..b5e52a32 --- /dev/null +++ b/tests/fixtures/cspt/negative_safe_construction.html @@ -0,0 +1,29 @@ + + + +Search (safe) + +

Search

+
loading…
+ + + diff --git a/tests/fixtures/cspt/negative_validated.html b/tests/fixtures/cspt/negative_validated.html new file mode 100644 index 00000000..486727e2 --- /dev/null +++ b/tests/fixtures/cspt/negative_validated.html @@ -0,0 +1,32 @@ + + + +Order detail (validated) + +

Order detail

+
loading…
+ + + diff --git a/tests/fixtures/cspt/positive_direct.html b/tests/fixtures/cspt/positive_direct.html new file mode 100644 index 00000000..6fdee5c4 --- /dev/null +++ b/tests/fixtures/cspt/positive_direct.html @@ -0,0 +1,26 @@ + + + +Order detail + +

Order detail

+
loading…
+ + + diff --git a/tests/fixtures/cspt/positive_encoded.html b/tests/fixtures/cspt/positive_encoded.html new file mode 100644 index 00000000..5ea76d03 --- /dev/null +++ b/tests/fixtures/cspt/positive_encoded.html @@ -0,0 +1,29 @@ + + + +Resource viewer + +

Resource viewer

+
loading…
+ + + diff --git a/tests/fixtures/cspt/positive_normalized.html b/tests/fixtures/cspt/positive_normalized.html new file mode 100644 index 00000000..c0a3e20d --- /dev/null +++ b/tests/fixtures/cspt/positive_normalized.html @@ -0,0 +1,29 @@ + + + +Profile + +

Profile

+
loading…
+ + + diff --git a/tests/fixtures/cspt/positive_postmessage.html b/tests/fixtures/cspt/positive_postmessage.html new file mode 100644 index 00000000..275b7069 --- /dev/null +++ b/tests/fixtures/cspt/positive_postmessage.html @@ -0,0 +1,29 @@ + + + +Widget host + +

Widget host

+
waiting for message…
+ + + diff --git a/tests/fixtures/cspt/positive_storage.html b/tests/fixtures/cspt/positive_storage.html new file mode 100644 index 00000000..a5d60730 --- /dev/null +++ b/tests/fixtures/cspt/positive_storage.html @@ -0,0 +1,33 @@ + + + +Tenant dashboard + +

Tenant dashboard

+
loading…
+ + + diff --git a/tests/test_cspt_skill.py b/tests/test_cspt_skill.py new file mode 100644 index 00000000..f103590e --- /dev/null +++ b/tests/test_cspt_skill.py @@ -0,0 +1,300 @@ +"""Regression tests for Client-Side Path Traversal (CSPT) detection support. + +Covers the three in-repo pillars of issue #776 that can run in CI without a live +browser/proxy: + +1. The ``client-side-path-traversal`` skill exists, loads, and documents the + source/sink taxonomy plus the "Not CSPT" boundary the agent relies on. +2. The HTML fixtures encode a real source->sink flow (positives) or a validation + guard / safe construction (negatives), asserted structurally so they can't + silently rot. +3. A CSPT finding is categorized separately from server-side path traversal / + LFI / RFI: the ``finding_class`` param round-trips through the reporting tool + and report state, and surfaces distinctly in SARIF (both as a property and as + a distinct vulnerability-class fingerprint) even though both share CWE-22. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from strix.report.sarif import write_sarif +from strix.report.state import ReportState, set_global_report_state +from strix.skills import get_all_skill_names, get_available_skills, load_skills +from strix.tools.reporting.tool import _do_create + + +FIXTURES = Path(__file__).parent / "fixtures" / "cspt" + +POSITIVE_FIXTURES = [ + "positive_direct.html", + "positive_encoded.html", + "positive_normalized.html", + "positive_postmessage.html", + "positive_storage.html", +] +NEGATIVE_FIXTURES = [ + "negative_validated.html", + "negative_safe_construction.html", +] + +# Browser request sinks whose *path* being tainted constitutes CSPT. +_SINK_MARKERS = ("fetch(", "XMLHttpRequest", ".open(", "axios", "sendBeacon", "EventSource") +# Client-side taint sources. +_SOURCE_MARKERS = ( + "location.hash", + "location.search", + "location.pathname", + "URLSearchParams", + "postMessage", + "localStorage", + "sessionStorage", + "document.referrer", +) + + +# --------------------------------------------------------------------------- # +# Skill +# --------------------------------------------------------------------------- # + + +def test_cspt_skill_is_discoverable() -> None: + assert "client_side_path_traversal" in get_all_skill_names() + assert "client_side_path_traversal" in get_available_skills().get("vulnerabilities", []) + + +def test_cspt_skill_loads_with_frontmatter_stripped() -> None: + loaded = load_skills(["client_side_path_traversal"]) + body = loaded.get("client_side_path_traversal") + assert body, "CSPT skill failed to load" + # Frontmatter must be stripped by the loader. + assert not body.lstrip().startswith("---") + assert body.lstrip().startswith("# Client-Side Path Traversal") + + +def test_cspt_skill_documents_sources_sinks_and_boundary() -> None: + body = load_skills(["client_side_path_traversal"])["client_side_path_traversal"].lower() + + # It must teach the sources named in the issue. + for source in ("location.hash", "postmessage", "referrer", "localstorage"): + assert source in body, f"CSPT skill missing source: {source}" + # ...and the request sinks. + for sink in ("fetch", "xmlhttprequest", "axios"): + assert sink in body, f"CSPT skill missing sink: {sink}" + # ...and the explicit boundary vs. server-side classes so findings don't get + # miscategorized. + assert "not cspt" in body + for other in ("lfi", "rfi", "server-side"): + assert other in body, f"CSPT skill missing boundary reference: {other}" + + +def test_cspt_skill_prescribes_finding_class_and_cwe() -> None: + body = load_skills(["client_side_path_traversal"])["client_side_path_traversal"] + # The skill must tell the agent to tag the finding for separation. + assert "client_side_path_traversal" in body + assert "CWE-22" in body + + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("name", POSITIVE_FIXTURES + NEGATIVE_FIXTURES) +def test_fixture_exists_and_is_html(name: str) -> None: + path = FIXTURES / name + assert path.is_file(), f"missing fixture {name}" + text = path.read_text(encoding="utf-8") + assert text.lstrip().lower().startswith("") + + +@pytest.mark.parametrize("name", POSITIVE_FIXTURES) +def test_positive_fixture_has_source_flowing_into_a_request_sink(name: str) -> None: + text = (FIXTURES / name).read_text(encoding="utf-8") + assert any(s in text for s in _SOURCE_MARKERS), f"{name}: no client-side source" + assert any(s in text for s in _SINK_MARKERS), f"{name}: no request sink" + # A positive must join a value into the request *path* (concatenation or a + # template literal), not merely attach a query parameter. + joins_into_path = '" + ' in text or "+ '" in text or "${" in text or "this.baseURL + " in text + assert joins_into_path, f"{name}: no value concatenated into the request path" + + +@pytest.mark.parametrize("name", NEGATIVE_FIXTURES) +def test_negative_fixture_is_guarded_or_safe(name: str) -> None: + text = (FIXTURES / name).read_text(encoding="utf-8") + # Still a real client source + sink (so it's a meaningful negative, not a + # blank page), but neutralized. + assert any(s in text for s in _SOURCE_MARKERS), f"{name}: no client-side source" + assert any(s in text for s in _SINK_MARKERS), f"{name}: no request sink" + + if name == "negative_validated.html": + # A strict allowlist guard on the segment before it reaches the sink. + assert "^[a-z0-9-]" in text + assert ".test(" in text + elif name == "negative_safe_construction.html": + # Value used as a query parameter against a constant path, never joined + # into the path. + assert "searchParams.set" in text + assert '"/api/orders/" +' not in text + + +def test_positive_and_negative_fixtures_are_distinct() -> None: + # Guard against a copy-paste that would make a "negative" actually vulnerable. + negatives = {(FIXTURES / n).read_text(encoding="utf-8") for n in NEGATIVE_FIXTURES} + positives = {(FIXTURES / n).read_text(encoding="utf-8") for n in POSITIVE_FIXTURES} + assert negatives.isdisjoint(positives) + + +# --------------------------------------------------------------------------- # +# Categorization separation +# --------------------------------------------------------------------------- # + + +def _cvss() -> dict[str, str]: + return { + "attack_vector": "N", + "attack_complexity": "L", + "privileges_required": "N", + "user_interaction": "R", + "scope": "U", + "confidentiality": "H", + "integrity": "N", + "availability": "N", + } + + +def _create_kwargs(**overrides: Any) -> dict[str, Any]: + base: dict[str, Any] = { + "title": "Client-Side Path Traversal in order-detail fetch", + "description": "Fragment segment reaches a fetch path unvalidated.", + "impact": "Authenticated request steered to a sibling admin endpoint.", + "target": "https://app.example.com", + "technical_analysis": "location.hash is concatenated into the fetch path.", + "poc_description": "Open the app with a crafted fragment.", + "poc_script_code": "location.hash = '#/orders/../../admin/keys'", + "remediation_steps": "Validate the segment against an allowlist.", + "evidence": "Outbound request path was /api/admin/keys with the session cookie.", + "assumptions": "Assumes the victim opens a crafted link while authenticated.", + "fix_effort": "low", + "cvss_breakdown": _cvss(), + "cwe": "CWE-22", + "endpoint": None, + "method": None, + "cve": None, + "code_locations": None, + } + base.update(overrides) + return base + + +def test_do_create_accepts_cspt_finding_class() -> None: + state = ReportState(run_name="cspt-test") + set_global_report_state(state) + try: + result = asyncio.run( + _do_create(finding_class="client_side_path_traversal", **_create_kwargs()) + ) + finally: + set_global_report_state(None) # type: ignore[arg-type] + + assert result["success"] is True, result + assert len(state.vulnerability_reports) == 1 + assert state.vulnerability_reports[0]["finding_class"] == "client_side_path_traversal" + + +def test_do_create_defaults_finding_class_to_dynamic() -> None: + state = ReportState(run_name="cspt-default") + set_global_report_state(state) + try: + result = asyncio.run(_do_create(**_create_kwargs())) + finally: + set_global_report_state(None) # type: ignore[arg-type] + + assert result["success"] is True, result + assert state.vulnerability_reports[0]["finding_class"] == "dynamic" + + +def test_do_create_rejects_unknown_finding_class() -> None: + result = asyncio.run(_do_create(finding_class="not_a_real_class", **_create_kwargs())) + assert result["success"] is False + assert any("finding_class" in e for e in result["errors"]) + + +def test_sarif_surfaces_cspt_finding_class(tmp_path: Path) -> None: + write_sarif( + tmp_path, + [ + { + "id": "vuln-0001", + "title": "Client-Side Path Traversal in order-detail fetch", + "severity": "high", + "cwe": "CWE-22", + "finding_class": "client_side_path_traversal", + "timestamp": "2026-07-16 10:00:00 UTC", + "code_locations": [{"file": "app.js", "start_line": 12}], + } + ], + ) + doc = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8")) + strix = doc["runs"][0]["results"][0]["properties"]["strix"] + assert strix["finding_class"] == "client_side_path_traversal" + + +def test_sarif_omits_default_finding_class(tmp_path: Path) -> None: + write_sarif( + tmp_path, + [ + { + "id": "vuln-0001", + "title": "Path Traversal in download handler", + "severity": "high", + "cwe": "CWE-22", + "finding_class": "dynamic", + "timestamp": "2026-07-16 10:00:00 UTC", + "code_locations": [{"file": "app.py", "start_line": 4}], + } + ], + ) + doc = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8")) + strix = doc["runs"][0]["results"][0]["properties"]["strix"] + assert "finding_class" not in strix + + +def test_sarif_separates_cspt_from_server_side_traversal_class(tmp_path: Path) -> None: + # Both share CWE-22 (same ruleId), but the class fingerprint must differ so + # cross-run dismissal tooling doesn't collapse a CSPT alert into a + # server-side path traversal alert. + write_sarif( + tmp_path, + [ + { + "id": "vuln-0001", + "title": "Client-Side Path Traversal in order-detail fetch", + "severity": "high", + "cwe": "CWE-22", + "finding_class": "client_side_path_traversal", + "timestamp": "2026-07-16 10:00:00 UTC", + "code_locations": [{"file": "app.js", "start_line": 12}], + }, + { + "id": "vuln-0002", + "title": "Path Traversal in download file parameter", + "severity": "high", + "cwe": "CWE-22", + "finding_class": "dynamic", + "timestamp": "2026-07-16 10:00:00 UTC", + "code_locations": [{"file": "server.py", "start_line": 30}], + }, + ], + ) + results = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))["runs"][0][ + "results" + ] + hashes = [r["properties"].get("strix_vuln_class_hash") for r in results] + assert all(hashes), "expected a class fingerprint on both findings" + assert hashes[0] != hashes[1], "CSPT and server-side traversal share a class fingerprint"