hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Web Security Fundamentals with @hectoday/http

The Attacker's Mindset

  • Thinking Like an Attacker
  • Project Setup

Injection Attacks

  • SQL Injection
  • SQL Injection: Beyond the Basics
  • Command Injection
  • Header Injection

Cross-Site Scripting (XSS)

  • What Is XSS?
  • Output Encoding
  • Content Security Policy in Practice

Broken Access and Redirects

  • Insecure Direct Object References (IDOR)
  • Open Redirects
  • Server-Side Request Forgery (SSRF)

File and Data Handling

  • Path Traversal
  • Mass Assignment
  • Denial of Service via Input

Putting It All Together

  • Security Testing
  • The OWASP Top 10
  • Capstone: Hardened Notes API

Path Traversal

Escaping the sandbox

In the SSRF lesson, the attacker tricked your server into making HTTP requests to internal resources. Path traversal is similar, but the resource is your file system. The attacker manipulates a file path to read or write files outside the intended directory.

The vulnerable code

Imagine a route that serves file attachments for notes:

import path from "path";
import { readFile } from "fs/promises";

const UPLOADS_DIR = path.resolve("./uploads");

route.get("/files/:filename", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const filename = c.params.filename;
    const filePath = path.join(UPLOADS_DIR, filename);

    // DELIBERATELY VULNERABLE — does not check if filePath stays in UPLOADS_DIR
    try {
      const content = await readFile(filePath);
      return new Response(content);
    } catch {
      return Response.json({ error: "File not found" }, { status: 404 });
    }
  },
});

The filename comes from the URL. If the user requests /files/report.txt, the server reads ./uploads/report.txt. That is the intended behavior.

The attack

The attacker requests: /files/../../etc/passwd

path.join("./uploads", "../../etc/passwd") resolves to /etc/passwd. The server reads and returns the contents of the system’s password file.

curl -b cookies.txt http://localhost:3000/files/..%2F..%2Fetc%2Fpasswd

(%2F is a URL-encoded /)

The attacker can read any file the server process has permission to read: configuration files, environment variables, source code, database files, private keys.

Why it works

.. means “parent directory” in file paths. Each ../ goes up one level. By chaining enough ../ sequences, the attacker escapes the uploads directory and reaches any file on the system.

path.join does not validate anything. It combines path segments, including .. segments, into a final path. It does not check whether the result stays within a specific directory.

The fix: resolve and verify

Use path.resolve to compute the final absolute path, then check that it starts with the allowed directory:

route.get("/files/:filename", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const filename = c.params.filename;
    const filePath = path.resolve(UPLOADS_DIR, filename);

    // Check that the resolved path is inside the uploads directory
    if (!filePath.startsWith(UPLOADS_DIR + path.sep)) {
      return Response.json({ error: "Invalid filename" }, { status: 400 });
    }

    try {
      const content = await readFile(filePath);
      return new Response(content);
    } catch {
      return Response.json({ error: "File not found" }, { status: 404 });
    }
  },
});

path.resolve computes the absolute path, resolving all .. segments. Then we check that the result starts with UPLOADS_DIR + path.sep (the separator prevents a partial match like /uploads-other/).

If the attacker sends ../../etc/passwd, path.resolve produces /etc/passwd, which does not start with /path/to/uploads/, so the request is rejected.

[!NOTE] We append path.sep (which is / on Linux/Mac and \ on Windows) to prevent a sneaky bypass. Without it, a file at /uploads-malicious/evil.txt would pass the startsWith("/uploads") check because the string /uploads-malicious starts with /uploads. Adding the separator ensures the path is actually inside the directory, not just a sibling with a similar name.

Where path traversal hides

Any route that reads or writes files based on user input: file downloads, file uploads (the filename), static file serving, template rendering (the template name), log file viewers, and report generators that read from disk.

Exercises

Exercise 1: Create a ./uploads directory with a test file. Add the vulnerable route. Try accessing ../../package.json. Do you see your package.json contents?

Exercise 2: Apply the path.resolve + startsWith fix. Try the same attack. It should return 400.

Exercise 3: Try ....//....//etc/passwd (double dots with extra slashes). Does path.resolve handle this correctly? (Yes — it normalizes the path before producing the absolute result.)

Why is path.join insufficient to prevent path traversal?

← Server-Side Request Forgery (SSRF) Mass Assignment →

© 2026 hectoday. All rights reserved.