Client-Side Path Traversal (CSPT): When JavaScript Constructs the Wrong API Path

Client-Side Path Traversal is a vulnerability class where attacker-controlled input is incorporated into a URL path in client-side JavaScript, redirecting fetch() or XHR requests to unintended API endpoints on the same origin. It bypasses CSRF protections and can chain into data exfiltration, account takeover, and SSRF-like impact. Here's how it works and how to prevent it.

Path traversal is a well-understood server-side vulnerability: user input containing ../ sequences reads files outside the intended directory. Client-Side Path Traversal (CSPT) is a distinct and underappreciated variant where the traversal happens in JavaScript in the browser, not on the server — and the result is not file reads but misdirected API calls.

CSPT remains underdetected in security reviews because most path traversal tooling and mental models focus on the server. But in modern single-page applications, where client-side code constructs API request paths dynamically, CSPT can redirect authenticated API calls to unintended endpoints, bypass CSRF controls, and chain into meaningful impact.

How CSPT Works

The core pattern: user-controlled input is incorporated into a URL path that JavaScript uses to make an API request.

// Vulnerable pattern: route parameter incorporated directly into API path
const userId = new URLSearchParams(window.location.search).get('id');
fetch(`/api/users/${userId}/profile`);

If the application does not sanitise userId, an attacker can supply:

?id=../admin/settings

The fetch request becomes:

GET /api/users/../admin/settings/profile

Which most web servers normalise to:

GET /api/admin/settings/profile

The browser sends the request with the user’s cookies, session tokens, and other credentials. The server receives a fully authenticated request to an endpoint the application logic never intended to expose through this code path.

Why This Bypasses CSRF Defences

CSRF protection typically relies on verifying a token (synchroniser token pattern), checking the Origin or Referer header, or using SameSite=Strict cookies.

CSPT bypasses the Origin/Referer check because the request comes from the same origin — the attacker’s payload is on the victim’s own application, not a cross-site page. The browser sends the correct Origin header. SameSite cookies are sent. Anti-CSRF tokens embedded in the page are also sent.

This is why the attack is sometimes called OSRF (On-Site Request Forgery) or CSPT2CSRF: it achieves CSRF-like outcomes without being blocked by CSRF mitigations, because the misdirected request is same-origin.

Concrete Example: Hash-Based Routing

React Router and Vue Router applications often construct API paths from route parameters:

// React component -- vulnerable
import { useParams } from 'react-router-dom';

function UserProfile() {
  const { userId } = useParams();
  
  useEffect(() => {
    fetch(`/api/users/${userId}/data`)
      .then(r => r.json())
      .then(setUserData);
  }, [userId]);
  
  // ...
}

Route: /users/:userId

Attack URL: /users/..%2Fadmin%2Fdelete?userId=123

The URL-decoded path /api/users/../admin/delete/data normalises to /api/admin/delete/data. If that endpoint exists and accepts GET requests without additional authorisation checks beyond the session cookie, the attacker has triggered an authenticated administrative action by getting the victim to visit a crafted URL.

In this attack scenario, the attacker doesn’t need to know the anti-CSRF token. The application’s own code constructs and sends the request.

Concrete Example: Fragment/Hash Parameters

Applications that read from window.location.hash to determine API endpoints are vulnerable in the same way:

// Vulnerable: reading API path component from the hash
const section = window.location.hash.replace('#', '');
fetch(`/api/documents/${section}`)
  .then(r => r.json())
  .then(renderDocument);

Hash: #../../sensitive-data

Fetch becomes: GET /api/documents/../../sensitive-data Normalises to: GET /api/sensitive-data

Chaining CSPT for Higher Impact

CSPT becomes more severe when chained with other vulnerabilities.

CSPT + Open Redirect: If the manipulated API path can be redirected to an external URL, the attacker gains a cross-origin request capability. Responses that include sensitive data in the body or in redirect Location headers can be exposed.

CSPT + CDN Cache Poisoning: If the misdirected request is cached (and the cache key doesn’t include sufficient path normalisation), subsequent legitimate users may receive the attacker’s poisoned response.

CSPT + Stored XSS: If the API endpoint reached via CSPT returns JSON that is then rendered unsafely, CSPT becomes an alternative XSS delivery path.

CSPT for POST Actions: If the application makes fetch POST requests where the URL path is partially user-controlled, CSPT can trigger state-changing operations. Example: editing a user profile makes a POST to /api/users/${id}/profile. With CSPT, that becomes a POST to /api/admin/create-account.

Secure Patterns

Path Component Validation

The primary fix is validating that user-controlled input going into URL paths does not contain traversal sequences:

function sanitisePath(segment: string): string {
  // Remove URL-encoded dots and forward slashes before validation
  const decoded = decodeURIComponent(segment);
  
  // Reject if the decoded value contains path separators or traversal sequences
  if (decoded.includes('/') || decoded.includes('\\') || decoded.includes('..')) {
    throw new Error('Invalid path segment');
  }
  
  return encodeURIComponent(decoded);
}

// Usage
const safeUserId = sanitisePath(rawUserId);
fetch(`/api/users/${safeUserId}/profile`);

Allow-List Validation

Better than denying specific characters is ensuring the value matches the expected format entirely:

function validateUserId(id: string): string {
  // User IDs are UUIDs
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  
  if (!UUID_PATTERN.test(id)) {
    throw new Error(`Invalid user ID format: ${id}`);
  }
  
  return id;
}

For numeric IDs:

function validateNumericId(id: string): number {
  const parsed = parseInt(id, 10);
  
  if (isNaN(parsed) || parsed.toString() !== id || parsed <= 0) {
    throw new Error('Invalid numeric ID');
  }
  
  return parsed;
}

URL Object Construction (Preferred)

Using the browser’s URL object with a base URL prevents traversal manipulation because the URL parser handles path normalisation explicitly:

function buildUserApiUrl(userId: string): URL {
  // Validate first
  const safeId = validateUserId(userId);
  
  // Build URL with the base -- the URL constructor normalises the path
  const url = new URL(`/api/users/${safeId}/profile`, window.location.origin);
  
  // Check that the resulting pathname is what we expect
  if (!url.pathname.startsWith('/api/users/')) {
    throw new Error('Path manipulation detected');
  }
  
  return url;
}

// Usage
const apiUrl = buildUserApiUrl(rawUserId);
fetch(apiUrl.toString());

Path Normalisation Check

For cases where you cannot validate the input format up front, normalise the constructed path and verify it starts with the expected prefix:

function safeFetch(baseEndpoint: string, userSegment: string): Promise<Response> {
  const rawPath = `${baseEndpoint}/${userSegment}`;
  
  // Normalise: resolve any .. sequences
  const normalised = new URL(rawPath, window.location.origin).pathname;
  
  // Verify the normalised path still starts with the expected base
  if (!normalised.startsWith(baseEndpoint)) {
    throw new Error('Path traversal detected');
  }
  
  return fetch(normalised);
}

Testing for CSPT

Manual testing approach:

  1. Map all places where user-controlled values appear in fetch/XHR request URLs
  2. For each: test input with ../, %2e%2e%2f, ..%2f, %2e%2e/ and double-encoded variants
  3. Observe which endpoint the request actually reaches (check the Network tab in DevTools)
  4. Identify whether the reached endpoint responds differently than the intended endpoint

Automated: Tools that spider SPAs and trace data flow from URL parameters/hash/localStorage to fetch calls will surface this. The CSPT attack class is less well-supported by traditional scanners than server-side traversal, so manual review of client-side routing logic is often necessary.

Key search patterns in JavaScript code:

// Patterns to review in code search
fetch(`.../${someParam}...`)
fetch('...' + someParam + '...')
axios.get(`/api/${pathSegment}`)
$.ajax({ url: '/api/' + segment })

Framework-Specific Notes

React Router: useParams() values are URL-decoded but not otherwise sanitised. Validate all parameters before use in API paths.

Vue Router: $route.params values carry the same caveat. Vue Router’s RouterLink component does not sanitise dynamic segments.

Angular: HttpClient does not sanitise path parameters. Angular’s Router resolves routes before components run, but params in routes can still contain traversal sequences if the route pattern allows them.

Next.js: useRouter().query returns decoded string values. API routes using req.query to construct paths on the server are additionally vulnerable to server-side path traversal — the client-side issue is present in the same way as React.

Summary

CSPT is a path traversal vulnerability that most teams miss because it lives in client-side code and the impact model (API misdirection, not file reads) doesn’t match the mental model most developers have for path traversal. It bypasses CSRF defences because the request is same-origin. It chains into meaningful impact through open redirects, cache poisoning, and unsafe JSON rendering.

The fix is always the same: validate path segments against an allow-list before incorporating them into URL paths, and check that the resulting URL pathname is what the code intended.

References