Client Side Path Traversal: When the Browser Sends Your Fetch Somewhere Else

Client Side Path Traversal: When the Browser Sends Your Fetch Somewhere Else

Written by

in

Most people learn path traversal as a server bug: you send ../../etc/passwd to a backend and it reads a file it should not. Client side path traversal moves that same trick into the browser. A front end builds an API path or a fetch URL out of input it does not control, and a few ../ sequences let an attacker point that request at a different endpoint than the developer intended. On its own it often looks harmless. The damage shows up when it chains.

What client side path traversal actually is

The setup is plain. A single page app takes a value, glues it onto a URL, and calls fetch. The value can be a path segment, an ID from the URL, or a field reflected from the server. If it contains ../, the browser normalizes the URL before the request leaves, and the final path is not the one the code wrote.

Here is the kind of code that does it:

// Front end builds the path from an id it does not validate
const id = getIdFromUrl();          // attacker controls this
fetch('/api/users/' + id + '/profile')
  .then(r => r.json())
  .then(render);

When id is a normal value like 42, the request goes to /api/users/42/profile. That is expected. Now set id to ../../admin/delete. The string the code builds is:

/api/users/../../admin/delete/profile

The browser does not send that literally. It resolves the ../ segments the same way it resolves any relative URL, walking up the path. The request that actually leaves the browser is:

GET /admin/delete/profile

The developer wrote a read of a user profile. The browser sent a call to an admin endpoint. Nothing looks unusual on the server, because the request arrives as a normal same origin call from the real app, with the real session cookie attached.

Why the browser turns it into a different path

This is not a quirk of fetch. It is how URL resolution works. A browser treats . and .. as path operations, not as text: . means the current directory, .. means go up one. When a URL contains those, the browser collapses them before the network call. The URL constructor does the same:

new URL('/api/users/../../admin/delete/profile', location.origin).pathname
// => "/admin/delete/profile"

So the bug is a mismatch. The code thinks it is pasting a value into a fixed slot. The browser reads a path full of navigation. The attacker controls the value, so the attacker controls where it lands.

How client side path traversal differs from the server side bug

The shapes rhyme but the location and the impact differ. With classic server side path traversal, the attacker reaches the file system through a backend that opens a path. If that is the bug you are chasing, start with what is path traversal, which covers the server case in full.

  • Where it runs. Server side path traversal happens in backend code that opens files or paths. Client side path traversal happens in the browser, in JavaScript that builds a request URL.
  • What it reaches. The server bug usually reaches files on disk. The client bug reaches other HTTP endpoints of the same app, using the victim’s own session.
  • Who carries the request. In the client case the victim’s browser sends the request, with cookies, same origin, so server side origin checks see a trusted caller.
  • Why it matters. A standalone redirected fetch may just return data the user could already see. The value is that it puts an attacker chosen endpoint inside a trusted request, which is the start of a chain.

The browser does exactly what it was told. It resolves .. in a path the same way every time. The flaw is that the developer never meant that string to be a path at all.

Why it is dangerous: the chaining angle

Client side path traversal is rarely the whole attack. It is the primitive that lets a second bug fire from a trusted spot. Three common chains:

Reaching a state changing endpoint (CSPT to CSRF)

Say the app skips CSRF protection on same origin calls because it assumes the front end only ever calls safe URLs. An attacker who controls a path segment can steer a fetch to a POST or DELETE route. A harmless GET that the app fires automatically becomes a request against /api/account/delete or /api/roles/add, sent by the victim, with the victim’s cookies. That is CSRF reached through a path the server trusted.

Turning a response into script (CSPT to XSS)

If the app takes the response of that fetch and writes it into the page, an attacker who can redirect the fetch to an endpoint that reflects input, or to an endpoint they control, can feed back markup or script. The front end then renders attacker chosen content. That is the bridge from a redirected request to DOM based XSS, where the sink is the app writing an untrusted response into the DOM.

Fetching attacker controlled data

If traversal lets the path escape into a route that proxies or echoes external data, the app ends up trusting bytes the attacker picked, and that response drives whatever the front end does next.

The pattern is the same across all three. The traversal does not break the server by itself. It quietly changes the target of a request the app already trusts, and the real payload rides the second bug.

A worked example

Picture a notes app called Acme Notes. The front end loads a note by ID from the URL fragment:

// URL: https://acme.example/#/notes/42
const noteId = location.hash.split('/').pop();   // "42"
fetch('/api/notes/' + noteId)
  .then(r => r.text())
  .then(html => { document.querySelector('#note').innerHTML = html; });

An attacker sends a victim a link with a crafted fragment:

https://acme.example/#/notes/..%2f..%2fsearch%3fq%3d<img src=x onerror=alert(1)>

The fragment decodes, the path collapses, and the fetch hits the search endpoint, which reflects the query back. The app writes that response straight into innerHTML. The redirected fetch supplied the wrong endpoint; the innerHTML sink supplied the XSS. Two small mistakes, one real bug.

Defenses that actually close it

The root cause is building a path out of raw input. Fix that and the chains lose their entry point.

  • Validate every path segment. If an ID should be a number, check that it is digits only before it touches a URL. Reject anything with ., /, or encoded forms like %2e and %2f.
  • Allowlist IDs where you can. If the value should be one of a known set, compare against that set instead of trusting the string.
  • Encode the segment. Run untrusted values through encodeURIComponent so a / becomes %2F and a . stays literal, which stops the browser from reading them as path operations.
  • Do not build paths from raw input. Prefer a safe URL builder or a fixed route with the value passed as a query parameter or in the body, not splice into the path. new URLSearchParams keeps values out of the path entirely.
  • Defend the server too. Keep CSRF protection on state changing routes and never assume a same origin request is safe. Encode any response before it reaches a DOM sink so a redirected fetch cannot become script.

Here is the same Acme Notes call, fixed:

const noteId = location.hash.split('/').pop();
if (!/^[0-9]+$/.test(noteId)) throw new Error('bad id');
fetch('/api/notes/' + encodeURIComponent(noteId))
  .then(r => r.text())
  .then(text => { document.querySelector('#note').textContent = text; });

The ID is checked, the value is encoded, and the response goes to textContent instead of innerHTML. The traversal cannot form, and even if a stray response slipped through, it would not run as script.

The assumption that breaks

Client side path traversal exists because a front end assumes the value it pastes into a URL is data, while the browser reads it as a path. That gap is invisible to a scanner looking for known payloads, because the bug only matters once you understand what the app meant the request to do and then ask what else that request could reach. This is exactly the kind of bug an autonomous researcher that tests assumptions is built to find. As an early, encouraging signal: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about where a request really goes, not matching strings, is the work. Read more on our about page.

Frequently asked questions

What is client side path traversal?

It is a browser bug where a front end builds an API path or fetch URL from input it does not control, and ../ sequences let an attacker redirect the request to a different endpoint than intended. For example, code that calls fetch('/api/users/' + id + '/profile') with id set to ../../admin/delete ends up requesting /admin/delete/profile, because the browser normalizes the path before sending it. It lives in JavaScript in the browser, not in backend file handling.

How is it different from server side path traversal?

Server side path traversal happens in backend code that opens a file or path, and it usually reaches files on disk. Client side path traversal happens in the browser, in code that builds a request URL, and it reaches other HTTP endpoints of the same app. The client version uses the victim’s own browser and session cookies, so the redirected request arrives looking like a trusted same origin call.

Why is client side path traversal dangerous if it is low impact alone?

On its own a redirected fetch may just return data the user could already see. It matters because it chains. It can reach a state changing endpoint and become CSRF, it can feed an attacker chosen response into a DOM sink and become DOM based XSS, or it can pull in attacker controlled data the app then trusts. The traversal supplies the wrong target, and the second bug supplies the payload.

How do you prevent client side path traversal?

Validate every path segment so an ID is digits only and reject ., /, and encoded forms like %2e and %2f. Allowlist IDs when the set is known. Run untrusted values through encodeURIComponent so a slash cannot act as a path separator. Avoid splicing raw input into a path at all; pass it as a query parameter or in the body. Keep CSRF protection on the server and encode responses before they reach a DOM sink.