Client-side request forgery

Client-side request forgery through unvalidated request URLs

Description

Client-side request forgery can arise when browser code builds URLs for fetch, XHR, or axios directly from user input. An attacker may manipulate query strings, hashes, or form fields to send requests to unintended paths, such as through ../, or to unapproved hosts. If the target API accepts a state-changing request, it may have an effect even when the response cannot be read. Rendering a response through innerHTML can also cause XSS. Whether a request succeeds and what it affects depend on browser security policies, credential options, and the target API's authentication and authorization.

Potential impact

  • Path manipulation or arbitrary host selection may direct requests to internal APIs or administrative endpoints.
  • A signed-in user's browser may be induced to make unintended API calls that read or change data.
  • Rendering an untrusted response with innerHTML may execute scripts or alter content.
  • The browser may be abused to send unwanted requests to local or corporate network resources, such as 127.0.0.1 or internal hosts.

Remediation

  • Fix the URL host or select it only from a predefined allow-list.
  • Validate the entire input format and permitted range. Converting with Number or parseInt is not complete validation. Prevent path manipulation with inputs such as ../, //, or %2e%2e.
  • Use a fixed base URL and assemble only validated path segments. URL and encodeURIComponent alone do not ensure an authorized path; also check the final origin and path.
  • Render responses using textContent rather than innerHTML, or use a trusted sanitizer when HTML is necessary.
  • Limit fetch options such as credentials and method to what is needed, and apply server-side authentication, authorization, and CSRF defenses. CORS alone cannot prevent request transmission or state changes.

Examples

Before

javascript
async function showProfile() {
  const qs = new URLSearchParams(location.search);
  const next = qs.get("next"); // Example: https://evil.example/steal
  const userId = qs.get("id"); // Example: ../../admin/metrics

  // BAD 1: use an entire untrusted URL
  const r1 = await fetch(next);

  // BAD 2: concatenate input into the path (possible path traversal)
  const r2 = await fetch("/api/user/" + userId);
  const data = await r2.json();

  // BAD 3: insert an unvalidated response into innerHTML, risking XSS
  document.getElementById("content").innerHTML = data.html;
}

After

javascript
async function showProfileSafe() {
  // 1) Use only an approved host
  const BASE = "https://api.example.com";

  // 2) Constrain path parameters to a schema (numbers) or validate strictly
  const qs = new URLSearchParams(location.search);
  const rawId = qs.get("id");
  const id = Number(rawId);
  if (!Number.isInteger(id) || id < 0) {
    throw new Error("invalid id");
  }

  // 3) Keep the base fixed and assemble only validated segments
  const url = new URL(`/v1/user/${id}`, BASE);

  const res = await fetch(url.toString(), {
    method: "GET",
    credentials: "same-origin",
  });
  const data = await res.json();

  // 4) Display content with textContent instead of innerHTML
  const box = document.getElementById("content");
  box.textContent = data.name; // Use a trusted sanitizer if HTML is needed
}

Explanation:

  • Before:
    • Input (next, id) becomes the fetch URL directly, permitting arbitrary hosts or path traversal.
    • Inserting the response into innerHTML can cause XSS.
  • After:
    • A fixed host and nonnegative integer path parameter prevent manipulations such as ../. The server must still validate the permitted ID range and access permissions.
    • URL(base) constructs the URL without allowing input to replace the entire URL.
    • textContent displays text without executing it as HTML.

References