Skip to content

Security: Path Traversal + IDOR in Resume Download API (CVE-22, CVE-639) #68

@rorar

Description

@rorar

Summary

The resume download API (/api/profile/resume) contains three compounding vulnerabilities that together allow any authenticated user to read arbitrary files from the server filesystem and download any other user's resumes without authorization.

Affected File

src/app/api/profile/resume/route.ts (GET handler)

Vulnerabilities

1. Path Traversal (CWE-22) — CVSS 8.6 (High)

The filePath query parameter is read from user input and passed directly to fs.readFileSync() without any path validation or sandboxing:

const filePath = searchParams.get("filePath");
// ...
const fullFilePath = path.join(filePath);  // ← NO-OP (see below)
if (!fs.existsSync(fullFilePath)) { ... }
// ...
const fileContent = fs.readFileSync(fullFilePath);  // ← arbitrary file read

2. path.join() No-Op (CWE-22)

The intended path sandboxing is broken:

const fullFilePath = path.join(filePath);

path.join() with a single argument is an identity function — it returns the input unchanged. The correct usage would be path.join(UPLOAD_DIR, filePath) with a known safe base directory, followed by a check that the resolved path is still within that directory.

3. Missing Ownership Check — IDOR (CWE-639)

The handler extracts userId from the session but never uses it:

const userId = session?.user?.id;  // extracted...
// ...but never referenced in any query or check

Any authenticated user can download any other user's resume by supplying its file path.

Proof of Concept

# Read server's /etc/passwd (any authenticated user)
curl -b "session_cookie=..." "http://<host>:3737/api/profile/resume?filePath=/etc/passwd"

# Read .env file (exposes ENCRYPTION_KEY, AUTH_SECRET, API keys)
curl -b "session_cookie=..." "http://<host>:3737/api/profile/resume?filePath=../../.env"

# Read the entire SQLite database
curl -b "session_cookie=..." "http://<host>:3737/api/profile/resume?filePath=../../prisma/dev.db"

# Read Node.js process environment variables
curl -b "session_cookie=..." "http://<host>:3737/api/profile/resume?filePath=/proc/self/environ"

Note: There is a file extension check that blocks non-PDF/DOC files with a 400, but the IDOR (downloading other users' .pdf/.docx resumes) works unrestricted.

Impact

Asset Exposed Via
.env file ENCRYPTION_KEY (decrypt all stored API keys), AUTH_SECRET (forge session tokens), database URL
SQLite database (prisma/dev.db) All user data: jobs, resumes, notes, hashed passwords, encrypted API keys
Other users' resumes IDOR: any authenticated user can download any resume without ownership check
/proc/self/environ All runtime environment variables including secrets
Server files Any file readable by the Node.js process UID

Chained impact: Reading .env exposes ENCRYPTION_KEY. Combined with the hardcoded PBKDF2 salt in encryption.ts, the attacker can decrypt every stored API key (OpenAI, DeepSeek, RapidAPI) — turning a file read into third-party API key theft.

Why This Matters Even for Self-Hosted Apps

  1. SQLite is a single file. Unlike PostgreSQL which requires separate network credentials, path traversal to prisma/dev.db yields the entire database in one request.
  2. Shared household deployments. Multiple family members tracking job searches — any user can read all resumes, salary data, and cover letters from every other user.
  3. Credential chaining. .envENCRYPTION_KEY → decrypt all API keys → financial damage from unauthorized API usage.
  4. Container environments. In Docker, /proc/1/environ exposes container orchestration secrets. Volume mounts may expose host filesystem paths.

Severity

  • CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N = 8.6 (High)
  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory
  • CWE-639: Authorization Bypass Through User-Controlled Key

Suggested Fix

import path from "path";
import { UPLOAD_DIR } from "@/lib/constants"; // define a safe base directory

// 1. Resolve and sandbox the path
const resolvedPath = path.resolve(UPLOAD_DIR, filePath);
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
  return NextResponse.json({ error: "Invalid file path" }, { status: 400 });
}

// 2. Add ownership check
const resume = await prisma.resume.findFirst({
  where: { filePath: filePath, profile: { userId: userId } },
});
if (!resume) {
  return NextResponse.json({ error: "File not found" }, { status: 404 });
}

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions