Add explicit CSPT detection skill, categorization, fixtures, and tests

Closes #776
This commit is contained in:
Afraa Shaikh 2026-07-17 01:07:56 +05:30
parent 7d5a67d234
commit 47c27aef2e
13 changed files with 948 additions and 0 deletions

View file

@ -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 <target>
agent-browser auth login <profile> # 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 "<target>/#/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 "<target>/#/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 "<target>"
```
## 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=<redacted>
→ 200 OK (own order)
Traversed (CSPT):
GET /api/orders/../../admin/keys/detail
→ browser normalizes to: GET /api/admin/keys
Cookie: session=<redacted>
→ 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.

View file

@ -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",

View file

@ -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 <target>
# 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 <target>
agent-browser auth login <profile> # 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 "<target>/#/orders/..%2f..%2fadmin%2fkeys"
# - query: open with a crafted param
agent-browser open "<target>/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/<id>/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.

View file

@ -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,
)

35
tests/fixtures/cspt/README.md vendored Normal file
View file

@ -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.

View file

@ -0,0 +1,29 @@
<!doctype html>
<!--
CSPT NEGATIVE — safe path construction (NOT CSPT)
Source: query parameter (?q=)
Sink: fetch()
The request path is a CONSTANT ("/api/search"). The client value is only ever
attached as a query parameter via URLSearchParams, never concatenated into the
path, so it cannot introduce path traversal. A tainted query value is not CSPT.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Search (safe)</title></head>
<body>
<h1>Search</h1>
<pre id="out">loading…</pre>
<script>
// SOURCE: query param, e.g. ?q=../../admin
const q = new URLSearchParams(location.search).get("q") || "";
// SAFE: constant path; the value is a query parameter, not a path segment.
const url = new URL("/api/search", location.origin);
url.searchParams.set("q", q);
fetch(url.toString(), { credentials: "include" })
.then((r) => r.text())
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
</script>
</body>
</html>

View file

@ -0,0 +1,32 @@
<!doctype html>
<!--
CSPT NEGATIVE — validated input (NOT CSPT)
Source: URL fragment (location.hash)
Sink: fetch()
The segment is validated against a strict allowlist pattern before use. Any
value containing "/", ".", or "%" fails the check, so no traversal can form
and the request is rejected before the sink. Safe.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Order detail (validated)</title></head>
<body>
<h1>Order detail</h1>
<pre id="out">loading…</pre>
<script>
// SOURCE: fragment, e.g. #/orders/abc123
const orderId = location.hash.replace(/^#\/orders\//, "");
// GUARD: strict allowlist — a single path segment of [a-z0-9-] only.
// Traversal characters (/, ., %) cannot pass, so no CSPT.
const VALID_ID = /^[a-z0-9-]{1,64}$/;
if (!VALID_ID.test(orderId)) {
document.getElementById("out").textContent = "invalid order id";
} else {
fetch("/api/orders/" + orderId + "/detail", { credentials: "include" })
.then((r) => r.text())
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
}
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<!doctype html>
<!--
CSPT POSITIVE — direct traversal
Source: URL fragment (location.hash)
Sink: fetch()
The fragment segment is concatenated straight into the request path with no
validation. A hash of `../../admin/keys` normalizes the outbound request to
/api/admin/keys, sent with the page's ambient session.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Order detail</title></head>
<body>
<h1>Order detail</h1>
<pre id="out">loading…</pre>
<script>
// SOURCE: attacker-controlled fragment, e.g. #/orders/../../admin/keys
const orderId = location.hash.replace(/^#\/orders\//, "");
// SINK: order id joined directly into the request path — CSPT.
fetch("/api/orders/" + orderId + "/detail", { credentials: "include" })
.then((r) => r.text())
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<!--
CSPT POSITIVE — URL-encoded traversal
Source: query parameter (?resource=)
Sink: XMLHttpRequest.open()
The value is URL-decoded once, then joined into the path. A payload of
%2e%2e%2f%2e%2e%2fadmin%2fconfig decodes to ../../admin/config and the browser
normalizes the outbound path to /api/admin/config.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Resource viewer</title></head>
<body>
<h1>Resource viewer</h1>
<pre id="out">loading…</pre>
<script>
// SOURCE: attacker-controlled query param, e.g. ?resource=%2e%2e%2fadmin%2fconfig
const raw = new URLSearchParams(location.search).get("resource") || "";
const resource = decodeURIComponent(raw);
// SINK: decoded value joined directly into the request path — CSPT.
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/resources/" + resource);
xhr.withCredentials = true;
xhr.onload = () => { document.getElementById("out").textContent = xhr.responseText; };
xhr.onerror = () => { document.getElementById("out").textContent = "error"; };
xhr.send();
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<!--
CSPT POSITIVE — normalization bypass
Source: route segment parsed from location.pathname
Sink: fetch()
A naive single-pass filter strips one "../" occurrence, but the self-
collapsing sequence "....//" folds back to "../" after that strip, so the
traversal survives and the browser normalizes the outbound path.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Profile</title></head>
<body>
<h1>Profile</h1>
<pre id="out">loading…</pre>
<script>
// SOURCE: route segment, e.g. /view/....//....//admin/settings
const segment = location.pathname.replace(/^\/view\//, "");
// Ineffective sanitizer: a single non-recursive strip. "....//" -> "../".
const cleaned = segment.replace(/\.\.\//g, "");
// SINK: still-tainted value joined into the request path — CSPT.
fetch("/api/profile/" + cleaned, { credentials: "include" })
.then((r) => r.text())
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
</script>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<!--
CSPT POSITIVE — postMessage source
Source: postMessage event.data (NO event.origin check)
Sink: fetch()
A message handler trusts any window's message and joins the supplied path
into the request. An attacker page/iframe posts {path: "../../admin/keys"}.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Widget host</title></head>
<body>
<h1>Widget host</h1>
<pre id="out">waiting for message…</pre>
<script>
// SOURCE: cross-window message, origin unchecked — attacker-controlled.
window.addEventListener("message", (event) => {
// MISSING: if (event.origin !== TRUSTED) return;
const path = event.data && event.data.path;
if (!path) return;
// SINK: message-supplied value joined directly into the request path — CSPT.
fetch("/api/widgets/" + path, { credentials: "include" })
.then((r) => r.text())
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
});
</script>
</body>
</html>

View file

@ -0,0 +1,33 @@
<!doctype html>
<!--
CSPT POSITIVE — browser storage source
Source: localStorage value (seeded by an earlier attacker-influenceable flow)
Sink: axios-style request wrapper
The stored "activeTenant" is joined into the API path with no validation, so a
poisoned storage value of "../../admin" steers the authenticated request.
-->
<html lang="en">
<head><meta charset="utf-8" /><title>Tenant dashboard</title></head>
<body>
<h1>Tenant dashboard</h1>
<pre id="out">loading…</pre>
<script>
// Minimal axios-like wrapper with a baseURL (stands in for a real axios client).
const api = {
baseURL: "/api",
get(path) {
return fetch(this.baseURL + path, { credentials: "include" }).then((r) => r.text());
},
};
// SOURCE: attacker-influenceable stored value, e.g. "../../admin".
const tenant = localStorage.getItem("activeTenant") || "self";
// SINK: stored value joined directly into the request path — CSPT.
api
.get("/tenants/" + tenant + "/summary")
.then((t) => { document.getElementById("out").textContent = t; })
.catch((e) => { document.getElementById("out").textContent = String(e); });
</script>
</body>
</html>

300
tests/test_cspt_skill.py Normal file
View file

@ -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("<!doctype html>")
@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"