mirror of
https://github.com/usestrix/strix.git
synced 2026-10-15 04:18:10 +00:00
Merge 15397a68f7 into e21f9ce4cc
This commit is contained in:
commit
bebaeff53f
19 changed files with 1984 additions and 17 deletions
|
|
@ -48,6 +48,7 @@ Core vulnerability classes with deep exploitation techniques.
|
|||
| `business_logic` | Logic flaws, state manipulation, race conditions |
|
||||
| `race_conditions` | TOCTOU, parallel request attacks |
|
||||
| `path_traversal_lfi_rfi` | File inclusion, path traversal |
|
||||
| `client_side_path_traversal` | Client-side path traversal (CSPT), browser request steering |
|
||||
| `open_redirect` | Redirect bypasses, URL parsing tricks |
|
||||
| `mass_assignment` | Attribute injection, hidden parameter pollution |
|
||||
| `insecure_file_uploads` | Upload bypasses, extension tricks |
|
||||
|
|
|
|||
160
docs/cspt-evidence-runbook.md
Normal file
160
docs/cspt-evidence-runbook.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# 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.
|
||||
# Use RAW ../ with literal slashes: the browser's URL parser only collapses
|
||||
# dot-segments delimited by real "/". A percent-encoded ..%2f..%2f stays a
|
||||
# single literal segment and does NOT traverse unless the app decodes it
|
||||
# before joining (see the skill's encoding notes).
|
||||
agent-browser open "<target>/#/orders/../../admin/keys"
|
||||
agent-browser wait --load networkidle
|
||||
agent-browser network requests # note the traversed path, e.g. /admin/keys/detail
|
||||
|
||||
# 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
|
||||
|
||||
Confirm at **two independent levels** — don't require impact to accept the
|
||||
primitive.
|
||||
|
||||
**Level 1 — Confirmed CSPT primitive** (the bug exists), from the HAR:
|
||||
|
||||
1. **Traversed path on the wire** — the outbound request path is the traversed
|
||||
target (e.g. `/admin/keys/detail`), not the intended one
|
||||
(`/api/orders/12345/detail`), because of attacker-controlled client input.
|
||||
2. **Benign control** — the same flow with a normal value hit the intended path.
|
||||
Show both, side by side.
|
||||
|
||||
This alone establishes path traversal. A traversed request that is
|
||||
unauthenticated or returns 404 is still a confirmed primitive.
|
||||
|
||||
**Level 2 — Confirmed exploit impact** (severity), one or more of:
|
||||
|
||||
- **Ambient credentials** — the traversed request carried the victim's session
|
||||
(`Cookie` / `Authorization` header present; read it from the HAR entry).
|
||||
- **Reach** — the traversed endpoint returned data or performed an action the
|
||||
source value should not have been able to reach.
|
||||
- **Chain** — the response feeds an XSS/CORS sink, or the reached endpoint is
|
||||
state-changing with weak/absent CSRF defense.
|
||||
|
||||
Credentials and reach set severity; they don't decide whether traversal
|
||||
occurred. 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 /admin/keys/detail
|
||||
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. Every
|
||||
positive drives its source from the **fragment, query string, `postMessage`, or
|
||||
`localStorage`** — never from a server-side route — so they all serve correctly
|
||||
under a plain static server (no SPA fallback needed). Serve the directory and
|
||||
drive one to see the 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 /admin/keys/detail request
|
||||
```
|
||||
|
||||
The normalization-bypass fixture is driven the same way, via the fragment:
|
||||
|
||||
```bash
|
||||
agent-browser open "http://localhost:8000/positive_normalized.html#/view/....//....//admin/settings"
|
||||
```
|
||||
|
||||
For an automated, reproducible version of this — used as the CI regression that
|
||||
proves the source-to-sink flow steers the browser's outbound path — see
|
||||
`tests/test_cspt_browser_capture.py`, which serves each fixture from a local
|
||||
request-capture server, drives it with headless Chromium (Playwright), and
|
||||
asserts the exact traversed path the server observed (and that negatives emit
|
||||
none). `tests/test_cspt_dataflow.py` adds a deterministic taint-flow check that
|
||||
fails if a fixture's source and sink are present but not connected.
|
||||
|
||||
The positive fixtures emit a traversed request whose path escapes the intended
|
||||
`/api/...` prefix (e.g. `/admin/keys/detail`); 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.
|
||||
|
|
@ -67,6 +67,12 @@ dev = [
|
|||
"pyinstaller>=6.17.0; python_version >= '3.12' and python_version < '3.15'",
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
# Drives a real headless Chromium for the CSPT browser/outbound-request
|
||||
# assertions in tests/test_cspt_browser_capture.py. CI must also run
|
||||
# `playwright install chromium` once; the tests importorskip when the
|
||||
# runtime or browser build is unavailable so a browserless environment
|
||||
# still runs the rest of the suite.
|
||||
"playwright>=1.40",
|
||||
"types-requests>=2.32",
|
||||
]
|
||||
|
||||
|
|
@ -320,6 +326,11 @@ ignore = [
|
|||
"strix/interface/cli.py" = ["BLE001", "PLC0415"]
|
||||
"strix/interface/scan_setup.py" = ["PLC0415"]
|
||||
"strix/interface/main.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
|
||||
"strix/interface/tui/renderers/agent_message_renderer.py" = ["PLC0415"]
|
||||
# Browser integration harness: broad except around Playwright/Chromium startup
|
||||
# is an intentional skip-guard so a browserless CI environment degrades to a
|
||||
# clean skip rather than an error.
|
||||
"tests/test_cspt_browser_capture.py" = ["BLE001"]
|
||||
"strix/interface/cli_args.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
|
||||
"strix/interface/environment.py" = ["BLE001", "PLC0415", "PLR0912", "PLR0915"]
|
||||
# The Go TUI runtime and backend controller import interface modules lazily so
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ CRITICAL DEDUPLICATION RULES:
|
|||
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
|
||||
- Different severity levels due to different impact
|
||||
- One is authenticated, other is unauthenticated
|
||||
- Different finding_class even under the same CWE (e.g., client_side_path_traversal
|
||||
vs a server-side/dynamic path-traversal finding both mapped to CWE-22): these are
|
||||
distinct classes fixed by different code, so they are NOT duplicates
|
||||
|
||||
3. ARE DUPLICATES even if:
|
||||
- Titles are worded differently
|
||||
|
|
@ -103,6 +106,8 @@ COMPARISON GUIDELINES:
|
|||
FIELDS TO ANALYZE:
|
||||
- title, description: General vulnerability info
|
||||
- target, endpoint, method: Exact location of vulnerability
|
||||
- finding_class: Machine-readable vulnerability class; a different finding_class means a
|
||||
different class of bug even under a shared CWE — not a duplicate
|
||||
- technical_analysis: Root cause details
|
||||
- poc_description: How it's exploited
|
||||
- impact: What damage it can cause
|
||||
|
|
@ -145,6 +150,7 @@ def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
|
|||
"endpoint",
|
||||
"method",
|
||||
"cve",
|
||||
"finding_class",
|
||||
"dependency_metadata",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -556,6 +556,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
|
||||
|
||||
dependency_metadata = report.get("dependency_metadata")
|
||||
if isinstance(dependency_metadata, dict) and dependency_metadata:
|
||||
strix["dependency_metadata"] = dependency_metadata
|
||||
|
|
@ -911,6 +921,7 @@ _VULN_CLASS_KEYWORDS = (
|
|||
"default password",
|
||||
"session fixation",
|
||||
"open redirect",
|
||||
"client-side path traversal",
|
||||
"path traversal",
|
||||
"directory traversal",
|
||||
"command injection",
|
||||
|
|
@ -1017,12 +1028,19 @@ def _primary_fingerprint(
|
|||
parts.append(f"route:{route}")
|
||||
|
||||
if is_synthetic:
|
||||
# Augment with class keyword so locationless findings of
|
||||
# Augment with the class token so locationless findings of
|
||||
# different vuln classes don't collide. rule_id is already in
|
||||
# the composite so we don't also need to stamp the CWE.
|
||||
title = _string_value(report.get("title")) or ""
|
||||
if title:
|
||||
parts.append(f"synth_class:{_class_keyword(title)}")
|
||||
# Prefer the structured finding_class over a title keyword: two
|
||||
# CWE-22 findings (a CSPT with finding_class
|
||||
# "client_side_path_traversal" and a server-side traversal with
|
||||
# finding_class "dynamic") can share a generic "path traversal"
|
||||
# title keyword, so keying on the title alone collapses them onto
|
||||
# the same primary fingerprint. _class_token resolves to the
|
||||
# discriminating structured field when present.
|
||||
token = _class_token(report)
|
||||
if token:
|
||||
parts.append(f"synth_class:{token}")
|
||||
|
||||
composite = "|".join(parts)
|
||||
return hashlib.sha256(composite.encode("utf-8")).hexdigest()
|
||||
|
|
@ -1045,23 +1063,41 @@ def _class_fingerprint(rule_id: str, report: dict[str, Any]) -> str | None:
|
|||
primary fingerprint (typical case: file rename, or a fix that moves
|
||||
the vulnerable code to a new module).
|
||||
|
||||
Composite of (rule_id, vuln-class keyword extracted from title).
|
||||
Title is LLM-authored so it's stochastic at the prose level, but
|
||||
the class keyword extraction picks up the discrete vulnerability
|
||||
category, which is much more stable than the full title.
|
||||
|
||||
Falls back to the first 5 lowercased words of the title when no
|
||||
curated keyword matches. Acceptable as a fallback because the
|
||||
class fingerprint is a tiebreaker, not a primary reconciliation key.
|
||||
Composite of (rule_id, vuln-class token). The token comes from
|
||||
``_class_token``, which prefers the structured ``finding_class`` and
|
||||
falls back to a title keyword — see that helper for why the structured
|
||||
field must win (CSPT vs. server-side traversal both key on CWE-22).
|
||||
"""
|
||||
title = _string_value(report.get("title")) or ""
|
||||
keyword = _class_keyword(title) if title else ""
|
||||
if not keyword:
|
||||
token = _class_token(report)
|
||||
if not token:
|
||||
return None
|
||||
composite = f"rule:{rule_id}|class:{keyword}"
|
||||
composite = f"rule:{rule_id}|class:{token}"
|
||||
return hashlib.sha256(composite.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _class_token(report: dict[str, Any]) -> str:
|
||||
"""Resolve a finding's vulnerability-class token.
|
||||
|
||||
Prefers the structured ``finding_class`` when it carries signal (i.e.
|
||||
present and not the default ``dynamic``); otherwise falls back to a
|
||||
keyword extracted from the (LLM-authored) title. Returns empty string
|
||||
when neither yields a token.
|
||||
|
||||
Shared by ``_class_fingerprint`` (file-rename dismissal carryover) and
|
||||
the synthetic branch of ``_primary_fingerprint`` (locationless
|
||||
distinguisher) so both fingerprints agree on what counts as "the same
|
||||
class". Keeping them aligned is what stops a CSPT finding whose title
|
||||
contains the generic ``path traversal`` keyword from collapsing onto a
|
||||
server-side path-traversal finding's primary fingerprint: the
|
||||
structured field distinguishes them and both hashes consume it.
|
||||
"""
|
||||
finding_class = _string_value(report.get("finding_class"))
|
||||
if finding_class and finding_class != "dynamic":
|
||||
return finding_class
|
||||
title = _string_value(report.get("title")) or ""
|
||||
return _class_keyword(title) if title else ""
|
||||
|
||||
|
||||
def _class_keyword(title: str) -> str:
|
||||
"""Pick the first matching curated keyword in ``title``, or fall
|
||||
back to the first 5 lowercased alpha-numeric words.
|
||||
|
|
|
|||
289
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
289
strix/skills/vulnerabilities/client_side_path_traversal.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
---
|
||||
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 trailing `#` starts a fragment, dropping
|
||||
the appended `/avatar`), the browser normalizes
|
||||
`/api/users/../../admin/keys` → `/admin/keys` (the two `../` segments pop `users`
|
||||
then `api`), 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.
|
||||
|
||||
Know exactly what the **browser's** URL parser normalizes vs. what stays literal —
|
||||
this is where most CSPT reports go wrong:
|
||||
|
||||
- **Browser-normalized dot-segments** (WHATWG URL Standard): the parser collapses a
|
||||
path segment that equals `..`, `%2e.`, `.%2e`, or `%2e%2e` **only when it is
|
||||
delimited by literal `/`**. So `../`, `%2e%2e/`, `.%2e/`, `%2e%2e%2e%2e//`-style
|
||||
self-collapsing sequences all traverse once real slashes separate them.
|
||||
- **Encoded separators stay encoded**: the browser does **not** turn `%2f` into a
|
||||
path delimiter during normalization. `..%2f..%2fadmin` is a single literal
|
||||
segment `..%2f..%2fadmin` — it does **not** become `../../admin`. Payloads like
|
||||
`..%2f` or `%2e%2e%2f` only traverse if the **application** decodes them before
|
||||
concatenating into the URL (see below). Do not assume `%2f` traversal without
|
||||
confirming decode-then-join in the app code or on the wire.
|
||||
- **Application/framework decoding before URL construction**: if app code runs
|
||||
`decodeURIComponent`, reads `URLSearchParams.get()` (which decodes), or a router
|
||||
decodes a route param, an encoded payload becomes raw `../` *before* it reaches
|
||||
the sink — then the literal-slash rule above applies. Double-encoding
|
||||
(`%252e%252e%252f`) matters only when the app decodes twice.
|
||||
- **Direct** (no app decode needed): `../`, `../../`, leading `/` (absolute-path
|
||||
override: `id=/admin/keys` → `/api//admin/keys` or an absolute join).
|
||||
|
||||
Two more families to try, both using **literal** `/` separators so the browser
|
||||
parser acts on them:
|
||||
|
||||
- **Self-collapsing / normalization**: `....//` (folds to `../` after a naive
|
||||
single strip), `..././`, `.%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.
|
||||
|
||||
Test both raw and encoded because framework routers may decode a layer first — but
|
||||
attribute any `%2f`-based traversal to an **app-layer decode**, never to the browser
|
||||
parser.
|
||||
|
||||
## 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 (RAW ../ — literal slashes; the
|
||||
# browser only collapses dot-segments split by real "/")
|
||||
agent-browser open "<target>/#/orders/../../admin/keys"
|
||||
# - 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 has **two independent levels**. Keep them separate — conflating
|
||||
them causes false negatives (a valid primitive dismissed because impact wasn't
|
||||
demonstrated *yet*).
|
||||
|
||||
**Level 1 — Confirmed CSPT primitive** (the bug exists). Both must hold:
|
||||
|
||||
1. The **outbound request path in the proxy/HAR** differs from the intended one
|
||||
because of attacker-controlled client input — the traversed target
|
||||
(e.g. `/admin/keys`) appears on the wire, not the intended
|
||||
`/api/users/<id>/avatar`.
|
||||
2. There is a **benign same-endpoint control**: the same flow with a normal
|
||||
value hits the intended path. Show both paths side by side.
|
||||
|
||||
That's enough to establish path traversal occurred. Credentials and a successful
|
||||
response are **not** required for the primitive to be real — a traversed request
|
||||
that is unauthenticated or returns 404 still proves attacker input steered the
|
||||
browser's request path, and may become exploitable when chained.
|
||||
|
||||
**Level 2 — Confirmed exploit impact** (what it currently demonstrates). One or
|
||||
more of:
|
||||
|
||||
- The traversed request carried the **victim's credentials** (session cookie /
|
||||
`Authorization` header on the request — read it from the HAR), and
|
||||
- The traversed endpoint **returned data or performed an action** the source
|
||||
value should not have reached, or
|
||||
- The primitive **participates in a demonstrated chain**: response rendered into
|
||||
a sink (XSS), a permissive CORS reader, a state-changing endpoint with weak/no
|
||||
CSRF defense, or application-added authorization on the reached path.
|
||||
|
||||
Credentials, reach, and chaining drive **reportability and severity** — they do
|
||||
not decide *whether* traversal happened. Report a confirmed primitive; let the
|
||||
impact level set the CVSS and how loudly you rate it.
|
||||
|
||||
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 at all** — the primitive doesn't exist, 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). This is the
|
||||
real negative: if the outbound path is unchanged, there is no primitive.
|
||||
|
||||
**Primitive exists but low/unproven impact** — this is *still CSPT*; report it,
|
||||
but rate it honestly and say what's missing (do NOT silently drop it):
|
||||
|
||||
- The traversed request carries **no ambient credentials** (no session on that
|
||||
request). The path was still steered — report the primitive at reduced severity
|
||||
and note the missing privilege, or look for a chain that supplies impact.
|
||||
- Server **rejects** the traversed path (404/403) with no differential. Reach
|
||||
isn't demonstrated *yet*, but the primitive is confirmed. Report it as a
|
||||
primitive and note that impact depends on a reachable sibling endpoint or a
|
||||
chain (response rendering, CORS reader, state-changing target).
|
||||
|
||||
## 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.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
name: path-traversal-lfi-rfi
|
||||
name: path_traversal_lfi_rfi
|
||||
description: Path traversal and file inclusion testing for local/remote file access and code execution
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -270,7 +270,17 @@ def _with_warning(result: dict[str, Any], warning: str | None) -> dict[str, Any]
|
|||
result["warning"] = warning
|
||||
return result
|
||||
|
||||
# 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, PLR0915
|
||||
def _validate_cvss_breakdown(breakdown: Any) -> list[str]:
|
||||
"""Check the 8 CVSS metrics are all present with legal values."""
|
||||
if not isinstance(breakdown, dict) or not breakdown:
|
||||
|
|
@ -803,6 +813,7 @@ async def _do_create(
|
|||
confidence_rationale: str | None = None,
|
||||
fix_verification: str | None = 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]:
|
||||
|
|
@ -837,6 +848,37 @@ async def _do_create(
|
|||
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)}"
|
||||
)
|
||||
|
||||
# CSPT findings are typically locationless (the bug lives in a JS bundle,
|
||||
# not a server route), so they anchor to SECURITY.md in SARIF. Without a
|
||||
# discriminator, multiple distinct CSPT findings collapse onto the same
|
||||
# synthetic fingerprint. Require the traversed endpoint/method or a
|
||||
# physical code location so each one stays a separate alert.
|
||||
if finding_class == "client_side_path_traversal":
|
||||
has_endpoint = bool(str(endpoint or "").strip())
|
||||
has_location = bool(_normalize_code_locations(code_locations))
|
||||
if not (has_endpoint or has_location):
|
||||
errors.append(
|
||||
"finding_class 'client_side_path_traversal' requires a discriminator: "
|
||||
"set endpoint (the traversed target path, e.g. '/admin/keys') and method, "
|
||||
"or a code_location for the vulnerable client sink. Locationless CSPT "
|
||||
"findings otherwise collapse onto one SARIF fingerprint."
|
||||
)
|
||||
|
||||
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 = {}
|
||||
else:
|
||||
for name, valid in _CVSS_VALID.items():
|
||||
value = cvss_breakdown.get(name)
|
||||
if value not in valid:
|
||||
errors.append(f"Invalid {name}: {value}. Must be one of: {valid}")
|
||||
errors.extend(_validate_cvss_breakdown(cvss_breakdown))
|
||||
|
||||
parsed_locations = _normalize_code_locations(code_locations)
|
||||
|
|
@ -883,6 +925,7 @@ async def _do_create(
|
|||
"poc_script_code": poc_script_code,
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"finding_class": finding_class,
|
||||
}
|
||||
report_fields: dict[str, Any] = {
|
||||
"title": title,
|
||||
|
|
@ -933,6 +976,27 @@ async def _do_create(
|
|||
}
|
||||
|
||||
report_id = report_state.add_vulnerability_report(
|
||||
title=title,
|
||||
description=description,
|
||||
severity=severity,
|
||||
impact=impact,
|
||||
target=target,
|
||||
technical_analysis=technical_analysis,
|
||||
poc_description=poc_description,
|
||||
poc_script_code=poc_script_code,
|
||||
remediation_steps=remediation_steps,
|
||||
evidence=evidence,
|
||||
assumptions=assumptions,
|
||||
fix_effort=fix_effort,
|
||||
cvss=cvss_score,
|
||||
cvss_breakdown=cvss_breakdown,
|
||||
endpoint=endpoint,
|
||||
method=method,
|
||||
cve=cve,
|
||||
cwe=cwe,
|
||||
code_locations=parsed_locations,
|
||||
fix_pr_body=fix_pr_body,
|
||||
finding_class=finding_class,
|
||||
**report_fields,
|
||||
agent_id=agent_id if isinstance(agent_id, str) else None,
|
||||
agent_name=agent_name if isinstance(agent_name, str) else None,
|
||||
|
|
@ -1005,6 +1069,7 @@ async def create_vulnerability_report(
|
|||
confidence_rationale: str | None = None,
|
||||
fix_verification: str | None = None,
|
||||
fix_pr_body: str | None = None,
|
||||
finding_class: str | None = None,
|
||||
) -> str:
|
||||
"""File a vulnerability report — one report per fully-verified finding.
|
||||
|
||||
|
|
@ -1410,6 +1475,57 @@ 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)::
|
||||
|
||||
title: "Reflected XSS in /search q parameter"
|
||||
description:
|
||||
The **`q`** parameter of `/search` reflects user input into
|
||||
the HTML response without encoding, allowing script
|
||||
injection.
|
||||
technical_analysis:
|
||||
The handler interpolates `q` directly into the page body:
|
||||
|
||||
```python title=views.py startLineNumber=42 endLineNumber=44
|
||||
html = f"<h2>Results for {q}</h2>"
|
||||
return HttpResponse(html)
|
||||
```
|
||||
|
||||
No output encoding is applied, so `<script>` executes.
|
||||
poc_description:
|
||||
1. Navigate to `/search?q=<payload>`.
|
||||
2. Observe the payload executes in the victim's browser.
|
||||
poc_script_code:
|
||||
```
|
||||
GET /search?q=<script>alert(document.domain)</script>
|
||||
```
|
||||
evidence:
|
||||
Response echoes the payload verbatim:
|
||||
|
||||
```html
|
||||
<h2>Results for <script>alert(document.domain)</script></h2>
|
||||
```
|
||||
assumptions:
|
||||
Assumes a victim can be induced to open a crafted link.
|
||||
remediation_steps:
|
||||
Context-encode all user input rendered into HTML; prefer the
|
||||
template engine's auto-escaping over string interpolation.
|
||||
fix_effort: "low"
|
||||
|
||||
"""
|
||||
(
|
||||
http_exchange_ids,
|
||||
|
|
@ -1454,6 +1570,7 @@ async def create_vulnerability_report(
|
|||
http_exchange_ids=http_exchange_ids,
|
||||
fix_verification=fix_verification,
|
||||
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
35
tests/fixtures/cspt/README.md
vendored
Normal 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.
|
||||
29
tests/fixtures/cspt/negative_safe_construction.html
vendored
Normal file
29
tests/fixtures/cspt/negative_safe_construction.html
vendored
Normal 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>
|
||||
32
tests/fixtures/cspt/negative_validated.html
vendored
Normal file
32
tests/fixtures/cspt/negative_validated.html
vendored
Normal 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>
|
||||
29
tests/fixtures/cspt/positive_direct.html
vendored
Normal file
29
tests/fixtures/cspt/positive_direct.html
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!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. With a hash of `#/orders/../../admin/keys`, orderId becomes
|
||||
`../../admin/keys` and the request URL is built as
|
||||
`/api/orders/../../admin/keys/detail`. The browser normalizes that outbound
|
||||
path to /admin/keys/detail (the two `../` segments pop `orders` then `api`),
|
||||
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>
|
||||
31
tests/fixtures/cspt/positive_encoded.html
vendored
Normal file
31
tests/fixtures/cspt/positive_encoded.html
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!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, the request
|
||||
URL is built as `/api/resources/../../admin/config`, and the browser
|
||||
normalizes the outbound path to /admin/config (the two `../` segments pop
|
||||
`resources` then `api`).
|
||||
-->
|
||||
<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>
|
||||
35
tests/fixtures/cspt/positive_normalized.html
vendored
Normal file
35
tests/fixtures/cspt/positive_normalized.html
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
CSPT POSITIVE — normalization bypass
|
||||
Source: route segment parsed from the URL fragment (hash router)
|
||||
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.
|
||||
|
||||
Served statically: the segment comes from location.hash (a hash router), so
|
||||
the page loads at its own URL under `python -m http.server` and the crafted
|
||||
route travels in the fragment — e.g.
|
||||
positive_normalized.html#/view/....//....//admin/settings
|
||||
No server-side route (/view/...) is required.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>Profile</title></head>
|
||||
<body>
|
||||
<h1>Profile</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
<script>
|
||||
// SOURCE: hash-router route segment, e.g. #/view/....//....//admin/settings
|
||||
const segment = location.hash.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>
|
||||
29
tests/fixtures/cspt/positive_postmessage.html
vendored
Normal file
29
tests/fixtures/cspt/positive_postmessage.html
vendored
Normal 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>
|
||||
33
tests/fixtures/cspt/positive_storage.html
vendored
Normal file
33
tests/fixtures/cspt/positive_storage.html
vendored
Normal 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>
|
||||
286
tests/test_cspt_browser_capture.py
Normal file
286
tests/test_cspt_browser_capture.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"""Browser-driven outbound-request assertions for the CSPT fixtures.
|
||||
|
||||
This is the runtime counterpart to the structural checks in
|
||||
``test_cspt_skill.py``. It answers the acceptance-criterion the structural
|
||||
tests cannot: *does the tainted source actually flow into the request sink and
|
||||
steer the browser's outbound path?*
|
||||
|
||||
For each fixture we:
|
||||
|
||||
1. Serve the fixture directory from a local HTTP server that also **captures**
|
||||
every request whose path escapes the intended ``/api/...`` prefix (the
|
||||
traversed target) and every ``/api/...`` request (the benign/intended one).
|
||||
2. Drive a real headless Chromium at the fixture with a crafted source value.
|
||||
3. Read what the *server* actually received off the wire and assert the exact
|
||||
outbound path.
|
||||
|
||||
Positives must emit a traversed request to the exact escaped path; negatives
|
||||
must emit **no** traversed request (the value is validated away or kept out of
|
||||
the path). A fixture whose source and sink exist but are not connected would
|
||||
never produce the traversed request, so this fails closed — unlike a purely
|
||||
textual "contains a source and a sink" check.
|
||||
|
||||
Skips cleanly when Playwright or its Chromium build is unavailable, so CI
|
||||
without a browser stays green; the structural suite still runs there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
playwright_sync = pytest.importorskip("playwright.sync_api")
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "cspt"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Capture:
|
||||
"""Records the request paths the server observed, by category."""
|
||||
|
||||
intended: list[str] = field(default_factory=list) # /api/... (benign target)
|
||||
traversed: list[str] = field(default_factory=list) # escaped /api/... prefix
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def record(self, path: str) -> None:
|
||||
with self.lock:
|
||||
if path.startswith("/api/"):
|
||||
self.intended.append(path)
|
||||
else:
|
||||
self.traversed.append(path)
|
||||
|
||||
|
||||
def _make_handler(capture: _Capture, root: Path) -> type[BaseHTTPRequestHandler]:
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args: object) -> None: # silence stderr spam
|
||||
return
|
||||
|
||||
def _serve(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path
|
||||
|
||||
# A fixture file request: serve it so the page (and its script) load.
|
||||
if path == "/" or path.endswith(".html"):
|
||||
name = "index.html" if path == "/" else Path(path).name
|
||||
target = (root / name).resolve()
|
||||
if target.is_file() and root.resolve() in target.parents:
|
||||
body = target.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
# Any other path is a request the page's sink emitted. Record which
|
||||
# bucket it fell into, then answer 200 so the fetch/XHR resolves.
|
||||
capture.record(path)
|
||||
body = b"ok"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._serve()
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Server:
|
||||
base_url: str
|
||||
capture: _Capture
|
||||
_httpd: ThreadingHTTPServer
|
||||
_thread: threading.Thread
|
||||
|
||||
def stop(self) -> None:
|
||||
self._httpd.shutdown()
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
|
||||
def _start_server() -> _Server:
|
||||
capture = _Capture()
|
||||
handler = _make_handler(capture, FIXTURES)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
host, port = httpd.server_address[0], httpd.server_address[1]
|
||||
return _Server(f"http://{host}:{port}", capture, httpd, thread)
|
||||
|
||||
|
||||
# Each case: fixture, how to drive the source, and the exact traversed path the
|
||||
# server must observe on the wire. ``drive`` returns the URL to open (fragment /
|
||||
# query sources) or ``None`` when the page must be seeded via JS (postMessage /
|
||||
# storage), handled explicitly in the test body.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def browser(): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
pw = playwright_sync.sync_playwright().start()
|
||||
except Exception as exc: # pragma: no cover - env without playwright runtime
|
||||
pytest.skip(f"playwright runtime unavailable: {exc}")
|
||||
try:
|
||||
try:
|
||||
browser = pw.chromium.launch()
|
||||
except Exception as exc: # pragma: no cover - chromium not installed
|
||||
pytest.skip(f"chromium not installed for playwright: {exc}")
|
||||
yield browser
|
||||
browser.close()
|
||||
finally:
|
||||
pw.stop()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(): # type: ignore[no-untyped-def]
|
||||
srv = _start_server()
|
||||
yield srv
|
||||
srv.stop()
|
||||
|
||||
|
||||
def _drive(browser, url: str, *, before_load_js: str | None = None): # type: ignore[no-untyped-def]
|
||||
"""Open ``url`` in a fresh page, optionally running JS first, and wait for
|
||||
network to settle so the sink's request has left the browser."""
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
if before_load_js:
|
||||
page.add_init_script(before_load_js)
|
||||
page.goto(url, wait_until="networkidle")
|
||||
page.wait_for_timeout(150)
|
||||
context.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Positives — a traversed request must appear on the wire at the exact path.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Payloads use RAW ../ with literal slashes (browser-normalized dot-segments).
|
||||
# The encoded fixture decodes at the app layer (decodeURIComponent), so it is
|
||||
# fed a %2e%2e%2f payload that the page decodes before joining.
|
||||
|
||||
|
||||
def test_positive_direct_traverses(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# #/orders/../../admin/keys -> orderId="../../admin/keys"
|
||||
# -> fetch("/api/orders/../../admin/keys/detail") -> /admin/keys/detail
|
||||
_drive(browser, f"{server.base_url}/positive_direct.html#/orders/../../admin/keys")
|
||||
assert server.capture.traversed == ["/admin/keys/detail"], server.capture
|
||||
|
||||
|
||||
def test_positive_encoded_traverses(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# ?resource=%2e%2e%2f%2e%2e%2fadmin%2fconfig -> decoded to ../../admin/config
|
||||
# -> fetch("/api/resources/../../admin/config") -> /admin/config
|
||||
payload = "%2e%2e%2f%2e%2e%2fadmin%2fconfig"
|
||||
_drive(browser, f"{server.base_url}/positive_encoded.html?resource={payload}")
|
||||
assert server.capture.traversed == ["/admin/config"], server.capture
|
||||
|
||||
|
||||
def test_positive_normalized_traverses(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# #/view/....//....//admin/settings survives the single-pass strip and
|
||||
# traverses. Assert the exact observed path rather than hand-computing it.
|
||||
_drive(
|
||||
browser,
|
||||
f"{server.base_url}/positive_normalized.html#/view/....//....//admin/settings",
|
||||
)
|
||||
assert len(server.capture.traversed) == 1, server.capture
|
||||
observed = server.capture.traversed[0]
|
||||
assert not observed.startswith("/api/"), observed
|
||||
assert "admin/settings" in observed, observed
|
||||
|
||||
|
||||
def test_positive_postmessage_traverses(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# Post a crafted message after load; listener joins it into the path.
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto(f"{server.base_url}/positive_postmessage.html", wait_until="networkidle")
|
||||
page.evaluate("window.postMessage({ path: '../../admin/keys' }, '*')")
|
||||
page.wait_for_timeout(200)
|
||||
context.close()
|
||||
# fetch("/api/widgets/../../admin/keys") -> /admin/keys
|
||||
assert server.capture.traversed == ["/admin/keys"], server.capture
|
||||
|
||||
|
||||
def test_positive_storage_traverses(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# Seed localStorage before the page script runs, then load.
|
||||
_drive(
|
||||
browser,
|
||||
f"{server.base_url}/positive_storage.html",
|
||||
before_load_js="localStorage.setItem('activeTenant', '../../admin')",
|
||||
)
|
||||
# api.get("/tenants/../../admin/summary") over baseURL "/api"
|
||||
# -> fetch("/api/tenants/../../admin/summary") -> /admin/summary
|
||||
assert server.capture.traversed == ["/admin/summary"], server.capture
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Negatives — no traversed request may leave the browser.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_negative_validated_emits_no_traversal(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# The allowlist rejects a traversal payload before the sink fires.
|
||||
_drive(browser, f"{server.base_url}/negative_validated.html#/orders/../../admin/keys")
|
||||
assert server.capture.traversed == [], server.capture
|
||||
assert server.capture.intended == [], server.capture # guard failed -> no request at all
|
||||
|
||||
|
||||
def test_negative_safe_construction_stays_on_constant_path(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# The value is a query param on a constant path; nothing escapes /api/.
|
||||
_drive(browser, f"{server.base_url}/negative_safe_construction.html?q=../../admin")
|
||||
assert server.capture.traversed == [], server.capture
|
||||
assert server.capture.intended, "expected the benign /api/search request to fire"
|
||||
assert all(p.startswith("/api/search") for p in server.capture.intended), server.capture
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Browser URL-normalization ground truth (WHATWG). These pin the exact claim
|
||||
# the review corrected: the browser collapses raw ``..`` dot-segments delimited
|
||||
# by literal ``/``, but does NOT decode ``%2f`` into a separator, so an
|
||||
# un-decoded ``..%2f..%2f`` stays a single literal segment and does not traverse.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _fetch_from_page(browser, server, url_to_fetch: str) -> list[str]: # type: ignore[no-untyped-def]
|
||||
"""Load a served origin, issue a raw ``fetch`` of ``url_to_fetch`` from the
|
||||
page (no application decoding in between), and return the path(s) the server
|
||||
observed off the wire."""
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto(f"{server.base_url}/positive_direct.html#noop", wait_until="networkidle")
|
||||
server.capture.intended.clear()
|
||||
server.capture.traversed.clear()
|
||||
page.evaluate("(u) => fetch(u).catch(() => {})", url_to_fetch)
|
||||
page.wait_for_timeout(200)
|
||||
context.close()
|
||||
return server.capture.traversed + server.capture.intended
|
||||
|
||||
|
||||
def test_browser_collapses_raw_dotdot_with_literal_slashes(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# Raw ../ delimited by literal / IS collapsed by the URL parser.
|
||||
observed = _fetch_from_page(browser, server, "/api/orders/../../admin/keys")
|
||||
assert observed == ["/admin/keys"], observed
|
||||
assert not observed[0].startswith("/api/"), observed
|
||||
|
||||
|
||||
def test_browser_does_not_decode_percent_2f_as_separator(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# ..%2f..%2f is a single literal segment: NOT decoded to ../../, so the path
|
||||
# does NOT escape the /api/ prefix. This is the exact misconception the
|
||||
# review flagged.
|
||||
observed = _fetch_from_page(browser, server, "/api/orders/..%2f..%2fadmin%2fkeys")
|
||||
assert len(observed) == 1, observed
|
||||
assert observed[0].startswith("/api/orders/"), observed
|
||||
# It did not collapse to /admin/keys.
|
||||
assert observed[0] != "/admin/keys", observed
|
||||
|
||||
|
||||
def test_browser_collapses_encoded_dot_segments_with_literal_slashes(browser, server) -> None: # type: ignore[no-untyped-def]
|
||||
# %2e%2e are recognized as dot-segments when delimited by literal / .
|
||||
observed = _fetch_from_page(browser, server, "/api/orders/%2e%2e/%2e%2e/admin/keys")
|
||||
assert observed == ["/admin/keys"], observed
|
||||
282
tests/test_cspt_dataflow.py
Normal file
282
tests/test_cspt_dataflow.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""Deterministic source-to-sink data-flow analysis over the CSPT fixtures.
|
||||
|
||||
The structural check in ``test_cspt_skill.py`` only asserts that a source string
|
||||
and a sink string both appear in a fixture; a page with an *unrelated* source
|
||||
and sink would pass it. This module closes that gap with a small, deterministic
|
||||
taint tracker that verifies the source value actually **flows into** the request
|
||||
sink's path argument.
|
||||
|
||||
It is intentionally not a general JS engine — it models the assignment- and
|
||||
concatenation-based flow the fixtures use (the same shape real CSPT bugs take):
|
||||
|
||||
* seed taint at ``const X = <source-expression>`` and inside a ``postMessage``
|
||||
handler's ``event.data`` access;
|
||||
* propagate taint across ``const Y = <expr referencing a tainted identifier>``
|
||||
(covers ``.replace(...)``, ``decodeURIComponent(...)``, wrapper vars);
|
||||
* mark a flow **broken** when a tainted value is gated by an allowlist guard
|
||||
(``/.../.test(id)``) before any sink, or is only ever passed to
|
||||
``searchParams.set`` / used as a query value on a constant path;
|
||||
* report a **hit** when a tainted identifier appears in the path argument of a
|
||||
request sink (``fetch(...)``, ``xhr.open(...)``, an ``axios``-style
|
||||
``baseURL + path`` join).
|
||||
|
||||
Because it requires a *connected* path, it fails closed: break the flow in a
|
||||
fixture (rename the tainted var at the sink, drop the concatenation, add a real
|
||||
guard) and the corresponding assertion flips. That is the regression the issue
|
||||
asks for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
# Expressions that introduce attacker-controlled taint.
|
||||
_SOURCE_EXPR = re.compile(
|
||||
r"""
|
||||
location\.hash
|
||||
| location\.search
|
||||
| location\.pathname
|
||||
| document\.referrer
|
||||
| new\s+URLSearchParams\b
|
||||
| localStorage\.getItem
|
||||
| sessionStorage\.getItem
|
||||
| event\.data
|
||||
| window\.name
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# A request sink call. Each alternative captures the sink name; the balancer
|
||||
# starts at the ``(`` that immediately follows the match. ``.open`` is the XHR
|
||||
# method-then-URL form, handled by picking the 2nd argument below.
|
||||
_SINK_CALL = re.compile(
|
||||
r"""
|
||||
(?P<open>\.open)\s*\(
|
||||
| (?P<fetch>\bfetch)\s*\(
|
||||
| (?P<beacon>sendBeacon)\s*\(
|
||||
| (?P<es>new\s+EventSource)\s*\(
|
||||
| (?P<ws>new\s+WebSocket)\s*\(
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
_JS_IDENT = re.compile(r"[A-Za-z_$][\w$]*")
|
||||
|
||||
# A same-endpoint allowlist guard: an anchored character-class regex literal used
|
||||
# anywhere, plus a ``.test(<ident>)`` gate. The literal and the call need not be
|
||||
# adjacent (the fixture stores the regex in a const, then calls VALID_ID.test).
|
||||
_ALLOWLIST_REGEX_LITERAL = re.compile(r"/\^\[[^\]]+\][^/]*\$?/")
|
||||
_TEST_GUARD = re.compile(r"\.test\s*\(\s*([A-Za-z_$][\w$]*)\s*\)")
|
||||
|
||||
|
||||
def _script_body(html: str) -> str:
|
||||
return "\n".join(re.findall(r"<script>(.*?)</script>", html, re.DOTALL))
|
||||
|
||||
|
||||
def _balanced_args(src: str, open_paren_idx: int) -> str:
|
||||
"""Return the full argument-list text between an opening paren and its
|
||||
matching close paren (exclusive)."""
|
||||
depth = 0
|
||||
out: list[str] = []
|
||||
i = open_paren_idx
|
||||
while i < len(src):
|
||||
ch = src[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
i += 1
|
||||
continue
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _split_top_level_args(arglist: str) -> list[str]:
|
||||
"""Split an argument-list string on top-level commas (ignoring commas nested
|
||||
in (), [], {}, or string/template literals)."""
|
||||
args: list[str] = []
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
buf: list[str] = []
|
||||
for ch in arglist:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in "\"'`":
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
continue
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
args.append("".join(buf))
|
||||
buf = []
|
||||
continue
|
||||
buf.append(ch)
|
||||
if buf:
|
||||
args.append("".join(buf))
|
||||
return args
|
||||
|
||||
|
||||
def _tainted_vars(script: str) -> set[str]:
|
||||
"""Fixed-point propagation of taint through ``const/let/var X = <expr>``.
|
||||
|
||||
Seeds on source expressions, then repeatedly taints any assignment whose
|
||||
right-hand side references an already-tainted identifier. ``event.data``
|
||||
accesses inside a message handler seed the handler's derived var too.
|
||||
"""
|
||||
assign_re = re.compile(
|
||||
r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?);",
|
||||
re.DOTALL,
|
||||
)
|
||||
assignments = [(m.group(1), m.group(2)) for m in assign_re.finditer(script)]
|
||||
|
||||
tainted: set[str] = set()
|
||||
# Seed: direct source expression on the RHS.
|
||||
for name, rhs in assignments:
|
||||
if _SOURCE_EXPR.search(rhs):
|
||||
tainted.add(name)
|
||||
|
||||
# Propagate to a fixed point: RHS references a tainted identifier.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, rhs in assignments:
|
||||
if name in tainted:
|
||||
continue
|
||||
idents = set(_JS_IDENT.findall(rhs))
|
||||
if idents & tainted:
|
||||
tainted.add(name)
|
||||
changed = True
|
||||
return tainted
|
||||
|
||||
|
||||
def _guarded_idents(script: str) -> set[str]:
|
||||
"""Identifiers gated by an allowlist ``.test(ident)`` guard before a sink."""
|
||||
guarded: set[str] = set()
|
||||
if _ALLOWLIST_REGEX_LITERAL.search(script):
|
||||
for m in _TEST_GUARD.finditer(script):
|
||||
guarded.add(m.group(1))
|
||||
return guarded
|
||||
|
||||
|
||||
def _sink_path_args(script: str) -> list[str]:
|
||||
"""The URL/path argument text of every request sink call in the script.
|
||||
|
||||
The URL is the 1st argument for ``fetch``/``sendBeacon``/``EventSource``/
|
||||
``WebSocket`` and the 2nd for ``xhr.open(method, url)``.
|
||||
"""
|
||||
args: list[str] = []
|
||||
for m in _SINK_CALL.finditer(script):
|
||||
open_idx = script.index("(", m.end() - 1)
|
||||
arglist = _balanced_args(script, open_idx)
|
||||
parts = _split_top_level_args(arglist)
|
||||
if not parts:
|
||||
continue
|
||||
url_arg = parts[1] if m.group("open") and len(parts) > 1 else parts[0]
|
||||
args.append(url_arg)
|
||||
return args
|
||||
|
||||
|
||||
def _tainted_flows_into_sink_path(script: str) -> bool:
|
||||
"""True iff a tainted identifier is concatenated into a sink's path arg.
|
||||
|
||||
Requires an actual join into the path (``+`` concatenation or a template
|
||||
literal) so that a tainted value used *only* as a query parameter (e.g.
|
||||
``searchParams.set(k, tainted)`` on a constant-path URL) does not count.
|
||||
"""
|
||||
tainted = _tainted_vars(script)
|
||||
guarded = _guarded_idents(script)
|
||||
live = tainted - guarded
|
||||
if not live:
|
||||
return False
|
||||
|
||||
# An axios-style wrapper joins ``baseURL + path``; the tainted value reaches
|
||||
# the sink transitively through the wrapper argument. Treat a call to such a
|
||||
# wrapper method (``.get("/x/" + tainted)``) as a path-join sink too.
|
||||
wrapper_join = re.compile(r"\.get\s*\(")
|
||||
|
||||
candidate_args = list(_sink_path_args(script))
|
||||
for m in wrapper_join.finditer(script):
|
||||
open_idx = script.find("(", m.end() - 1)
|
||||
if open_idx != -1:
|
||||
parts = _split_top_level_args(_balanced_args(script, open_idx))
|
||||
if parts:
|
||||
candidate_args.append(parts[0])
|
||||
|
||||
for arg in candidate_args:
|
||||
joins_path = "+" in arg or "${" in arg or "`" in arg
|
||||
if not joins_path:
|
||||
continue
|
||||
if set(_JS_IDENT.findall(arg)) & live:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", POSITIVE_FIXTURES)
|
||||
def test_positive_fixture_source_flows_into_sink_path(name: str) -> None:
|
||||
script = _script_body((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
assert _tainted_flows_into_sink_path(script), (
|
||||
f"{name}: no data flow from a client source into a request sink's path — "
|
||||
"source and sink present but not connected"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", NEGATIVE_FIXTURES)
|
||||
def test_negative_fixture_flow_is_broken(name: str) -> None:
|
||||
script = _script_body((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
assert not _tainted_flows_into_sink_path(script), (
|
||||
f"{name}: a tainted value reaches a request sink's path — negative fixture "
|
||||
"is not actually neutralized"
|
||||
)
|
||||
|
||||
|
||||
def test_analyzer_detects_a_disconnected_source_and_sink() -> None:
|
||||
# Meta-test: the analyzer must NOT flag a page where the source and sink are
|
||||
# both present but unrelated (the exact gap the structural check misses).
|
||||
disconnected = """
|
||||
const tainted = location.hash.slice(1);
|
||||
console.log(tainted); // taint goes nowhere
|
||||
fetch("/api/orders/12345/detail", { credentials: "include" });
|
||||
"""
|
||||
assert not _tainted_flows_into_sink_path(disconnected)
|
||||
|
||||
|
||||
def test_analyzer_flags_a_connected_source_and_sink() -> None:
|
||||
# ...and it MUST flag the connected version, so the negative above is real.
|
||||
connected = """
|
||||
const tainted = location.hash.slice(1);
|
||||
fetch("/api/orders/" + tainted + "/detail", { credentials: "include" });
|
||||
"""
|
||||
assert _tainted_flows_into_sink_path(connected)
|
||||
526
tests/test_cspt_skill.py
Normal file
526
tests/test_cspt_skill.py
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
"""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.dedupe import _prepare_report_for_comparison
|
||||
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 /admin/keys/detail 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:
|
||||
# CSPT requires a discriminator (endpoint or code location); supply the
|
||||
# traversed endpoint.
|
||||
kwargs = _create_kwargs(endpoint="/admin/keys", method="GET")
|
||||
result = asyncio.run(_do_create(finding_class="client_side_path_traversal", **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_do_create_cspt_requires_endpoint_or_location() -> None:
|
||||
# A CSPT finding with no discriminator (no endpoint, no code_locations) is
|
||||
# rejected so multiple locationless CSPT findings can't collapse onto one
|
||||
# SARIF fingerprint.
|
||||
kwargs = _create_kwargs(endpoint=None, method=None, code_locations=None)
|
||||
result = asyncio.run(_do_create(finding_class="client_side_path_traversal", **kwargs))
|
||||
assert result["success"] is False
|
||||
assert any("discriminator" in e for e in result["errors"]), result
|
||||
|
||||
|
||||
def test_do_create_cspt_accepts_with_endpoint() -> None:
|
||||
state = ReportState(run_name="cspt-endpoint")
|
||||
set_global_report_state(state)
|
||||
try:
|
||||
kwargs = _create_kwargs(endpoint="/admin/keys", method="GET", code_locations=None)
|
||||
result = asyncio.run(_do_create(finding_class="client_side_path_traversal", **kwargs))
|
||||
finally:
|
||||
set_global_report_state(None) # type: ignore[arg-type]
|
||||
assert result["success"] is True, result
|
||||
|
||||
|
||||
def test_dedupe_comparison_payload_includes_finding_class() -> None:
|
||||
# The structured discriminator must reach the dedup judge; without it a CSPT
|
||||
# and a server-side traversal on the same endpoint look identical.
|
||||
cleaned = _prepare_report_for_comparison(
|
||||
{
|
||||
"id": "vuln-1",
|
||||
"title": "Path Traversal",
|
||||
"endpoint": "/admin/keys",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
}
|
||||
)
|
||||
assert cleaned.get("finding_class") == "client_side_path_traversal"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_sarif_class_fingerprint_uses_finding_class_over_colliding_title(tmp_path: Path) -> None:
|
||||
# Regression: a CSPT finding whose *title* contains the generic "path
|
||||
# traversal" keyword must still be separated from a genuine server-side
|
||||
# path-traversal finding. Before the fix the class fingerprint was derived
|
||||
# from the title keyword alone, so both collapsed onto the same hash even
|
||||
# though the structured finding_class distinguished them. The structured
|
||||
# field must win.
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Path Traversal via fragment", # collides on the generic keyword
|
||||
"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 finding with a 'path traversal' title collapsed onto the "
|
||||
"server-side traversal fingerprint; finding_class must take precedence"
|
||||
)
|
||||
|
||||
|
||||
def test_sarif_primary_fingerprint_separates_locationless_cspt(tmp_path: Path) -> None:
|
||||
# Regression for the *primary* fingerprint (partialFingerprints
|
||||
# .primaryLocationLineHash), not just the custom class property. Both
|
||||
# findings are locationless (no code_locations, no endpoint/method) so they
|
||||
# anchor to SECURITY.md and share the CWE-22 ruleId. Only the structured
|
||||
# finding_class distinguishes them. Before the fix the synthetic branch
|
||||
# derived its class token from the title keyword alone, so a CSPT titled
|
||||
# "Path Traversal via fragment" collapsed onto the server-side traversal's
|
||||
# primary fingerprint — hiding a real, separate alert from code-scanning
|
||||
# reconciliation. The primary fingerprints must differ.
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0001",
|
||||
"title": "Path Traversal via fragment", # generic keyword collision
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"code_locations": None,
|
||||
"endpoint": None,
|
||||
"method": None,
|
||||
},
|
||||
{
|
||||
"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": None,
|
||||
"endpoint": None,
|
||||
"method": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
results = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))["runs"][0][
|
||||
"results"
|
||||
]
|
||||
primary = [r.get("partialFingerprints", {}).get("primaryLocationLineHash") for r in results]
|
||||
assert all(primary), "expected a primary fingerprint on both locationless findings"
|
||||
assert primary[0] != primary[1], (
|
||||
"locationless CSPT and server-side traversal share a primary fingerprint; "
|
||||
"the synthetic-location class token must prefer finding_class"
|
||||
)
|
||||
|
||||
|
||||
def test_sarif_primary_fingerprint_separates_cspt_by_endpoint(tmp_path: Path) -> None:
|
||||
# Two locationless CSPT findings that differ only by endpoint must stay
|
||||
# distinct, so multiple CSPT findings don't collapse onto one alert.
|
||||
write_sarif(
|
||||
tmp_path,
|
||||
[
|
||||
{
|
||||
"id": "vuln-0003",
|
||||
"title": "Client-Side Path Traversal",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"endpoint": "/admin/keys",
|
||||
"method": "GET",
|
||||
},
|
||||
{
|
||||
"id": "vuln-0004",
|
||||
"title": "Client-Side Path Traversal",
|
||||
"severity": "high",
|
||||
"cwe": "CWE-22",
|
||||
"finding_class": "client_side_path_traversal",
|
||||
"timestamp": "2026-07-16 10:00:00 UTC",
|
||||
"endpoint": "/admin/config",
|
||||
"method": "GET",
|
||||
},
|
||||
],
|
||||
)
|
||||
results = json.loads((tmp_path / "findings.sarif").read_text(encoding="utf-8"))["runs"][0][
|
||||
"results"
|
||||
]
|
||||
primary = [r.get("partialFingerprints", {}).get("primaryLocationLineHash") for r in results]
|
||||
assert all(primary)
|
||||
assert primary[0] != primary[1], (
|
||||
"two CSPT findings on different traversed endpoints collapsed onto one primary fingerprint"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixture path normalization (documented expected paths must be correct)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _normalize_url_path(path: str) -> str:
|
||||
"""Resolve ``.`` / ``..`` segments the way a browser normalizes a URL path.
|
||||
|
||||
Leading ``..`` that would escape the root are clamped (dropped), matching
|
||||
the URL parser rather than a filesystem join.
|
||||
"""
|
||||
parts = path.split("/")
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
if part == "..":
|
||||
if out and out[-1] not in ("", ".."):
|
||||
out.pop()
|
||||
elif part != ".":
|
||||
out.append(part)
|
||||
return "/".join(out)
|
||||
|
||||
|
||||
def test_normalize_helper_matches_documented_examples() -> None:
|
||||
# Guard the helper itself so the assertions below mean something.
|
||||
assert _normalize_url_path("/api/orders/../../admin/keys/detail") == "/admin/keys/detail"
|
||||
assert _normalize_url_path("/api/resources/../../admin/config") == "/admin/config"
|
||||
assert _normalize_url_path("/api/users/../../admin/keys") == "/admin/keys"
|
||||
|
||||
|
||||
def test_positive_direct_fixture_documents_correct_normalized_path() -> None:
|
||||
text = (FIXTURES / "positive_direct.html").read_text(encoding="utf-8")
|
||||
# The fixture builds this request URL from orderId="../../admin/keys":
|
||||
assert _normalize_url_path("/api/orders/../../admin/keys/detail") == "/admin/keys/detail"
|
||||
# ...and the comment must document that correct result, not the wrong
|
||||
# /api/admin/keys.
|
||||
assert "/admin/keys/detail" in text
|
||||
assert "/api/admin/keys" not in text
|
||||
|
||||
|
||||
def test_positive_encoded_fixture_documents_correct_normalized_path() -> None:
|
||||
text = (FIXTURES / "positive_encoded.html").read_text(encoding="utf-8")
|
||||
assert _normalize_url_path("/api/resources/../../admin/config") == "/admin/config"
|
||||
assert "/admin/config" in text
|
||||
assert "/api/admin/config" not in text
|
||||
|
||||
|
||||
def test_no_fixture_or_doc_claims_the_wrong_api_admin_path() -> None:
|
||||
# Belt-and-suspenders: the incorrect "/api/admin/..." normalized paths must
|
||||
# not reappear in any fixture, the skill, or the runbook.
|
||||
repo = Path(__file__).parent.parent
|
||||
targets = [
|
||||
FIXTURES / "positive_direct.html",
|
||||
FIXTURES / "positive_encoded.html",
|
||||
repo / "docs" / "cspt-evidence-runbook.md",
|
||||
repo / "strix" / "skills" / "vulnerabilities" / "client_side_path_traversal.md",
|
||||
]
|
||||
for path in targets:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "/api/admin/keys" not in text, f"{path.name} claims wrong path /api/admin/keys"
|
||||
assert "/api/admin/config" not in text, f"{path.name} claims wrong path /api/admin/config"
|
||||
Loading…
Add table
Reference in a new issue