Skip to content

Security: Systematic IDOR — missing ownership checks in 10+ server actions (CWE-639) #71

Description

@rorar

Summary

Multiple server actions authenticate the calling user but then query or mutate resources using only the resource ID — without verifying the resource belongs to the authenticated user. This is a systematic Insecure Direct Object Reference (IDOR) pattern that allows any authenticated user to read, modify, or delete other users' data.

The pattern is inconsistent: some actions correctly filter by userId (e.g., deleteJobById, updateJobStatus, all automation and note actions), while others query by resource ID alone. This inconsistency suggests ownership checks were applied ad-hoc rather than architecturally enforced.

Affected Files and Actions

Read Access IDOR (any authenticated user can read other users' data)

Action File Prisma Where Impact
getJobDetails job.actions.ts:~178 { id: jobId } — no userId Leaks full job record (description, salary, company, notes)
getResumeById profile.actions.ts:~83 { id: resumeId } — no userId Leaks full resume (name, email, phone, address, work history, education)
getCompanyById company.actions.ts:~175 { id: companyId } — no createdBy Leaks company data tracked by other users

Write Access IDOR (any authenticated user can modify other users' data)

Action File Issue Impact
updateJob job.actions.ts:~366 Auth check uses client-submitted data.userId (spoofable), Prisma where: { id } has no userId Overwrite any job's title, company, salary, status
editResume profile.actions.ts:~290 where: { id } — no userId Overwrite any resume content
addContactInfo profile.actions.ts:~126 resume.update where: { id: data.resumeId } — no userId Add contact info to any resume
updateContactInfo profile.actions.ts:~164 where: { id: data.id } — no userId Modify any user's contact details
updateResumeSummary profile.actions.ts:~452 where: { id: data.id } — no userId Modify any resume summary
updateExperience profile.actions.ts:~548 where: { id: data.id } — no userId Modify any work experience entry
updateEducation profile.actions.ts:~632 where: { id: data.id } — no userId Modify any education entry
updateCoverLetter coverLetter.actions.ts:~124 where: { id } — no userId Overwrite any cover letter
deleteCoverLetterById coverLetter.actions.ts:~145 where: { id } — no userId Delete any cover letter

Authorization Logic Flaw

Action File Issue
updateJob job.actions.ts:~337 Uses != (loose equality) instead of !== for user ID comparison
updateCompany company.actions.ts:~162 Same loose equality issue

The updateJob auth check is especially problematic:

if (!data.id || user.id != data.userId) {
  throw new Error("Id is not provide or no user privilages");
}

This checks the client-submitted data.userId against the session user — the attacker simply sends their own userId in the payload. The subsequent Prisma update queries by id alone, so the job being modified can belong to anyone.

Proof of Concept

Read another user's resume (chain with AI match endpoint)

# Attacker (User A) calls the AI match endpoint with User B's resumeId
POST /api/ai/resume/match
{
  "resumeId": "victim_resume_uuid",
  "jobId": "any_valid_job_id",
  "selectedModel": { ... }
}

# Server calls getResumeById("victim_resume_uuid") → returns User B's full resume
# including name, email, phone, address, and complete work history

Modify another user's job

# Attacker (userId: "attacker-id") updates a job owned by victim
# Server action call with:
{
  "id": "victim_job_uuid",
  "userId": "attacker-id",  # attacker's own ID → passes the != check
  "title": "HACKED",
  "company": "pwned"
}

# Check passes: user.id ("attacker-id") != data.userId ("attacker-id") → false → no error
# Prisma: job.update({ where: { id: "victim_job_uuid" }, data: {...} })
# → Victim's job is overwritten

Delete another user's cover letter

# Attacker calls deleteCoverLetterById with victim's cover letter ID
# No ownership check → cover letter deleted

Impact

Data at Risk Actions
Resumes (full PII: name, email, phone, address, work history) getResumeById, editResume, addContactInfo, updateContactInfo, updateExperience, updateEducation
Job applications (companies, salaries, status, notes) getJobDetails, updateJob
Cover letters (personal, often contains salary expectations) updateCoverLetter, deleteCoverLetterById
Companies (tracked employers, application history) getCompanyById
Data integrity Any authenticated user can sabotage another's job search by modifying or deleting their data

Why This Matters for Self-Hosted Apps

  1. Multi-user is the norm. Job tracking apps are used by couples, families, roommates, and small teams — exactly the scenario where IDOR becomes exploitable.
  2. Resumes contain the most sensitive PII. Full name, phone number, home address, email, employment history, salary expectations, and cover letter content.
  3. Data sabotage. In competitive job markets, one user modifying another's resume, cover letter, or application data can have real-world consequences (wrong salary on an application, deleted cover letter before a deadline).
  4. IDs are predictable. CUIDs/UUIDs provide some obscurity, but the AI match endpoint (/api/ai/resume/match) exposes both resumeId and jobId in its API surface, making enumeration feasible.

Severity

  • CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L = 8.3 (High)
  • CWE-639: Authorization Bypass Through User-Controlled Key
  • CWE-697: Incorrect Comparison (loose equality in auth checks)

Suggested Fix (Systematic)

Pattern: Add userId to every Prisma where clause:

// READ: Always filter by ownership
const job = await prisma.job.findFirst({
  where: { id: jobId, userId: user.id },
});
if (!job) throw new Error("Job not found");

// WRITE: Always include userId in where
const updated = await prisma.job.update({
  where: { id, userId: user.id },
  data: { ... }
});

// SUB-RESOURCES: Traverse ownership chain
const resume = await prisma.resume.findFirst({
  where: { id: resumeId, profile: { userId: user.id } },
});

Pattern: Never trust client-submitted userId:

// WRONG: checks client-supplied data.userId
if (user.id != data.userId) { ... }

// RIGHT: use session user.id in the Prisma where clause directly
const job = await prisma.job.update({
  where: { id: data.id, userId: user.id },
  data: { ... }
});

Pattern: Use strict equality:

// WRONG
if (user.id != data.userId)
// RIGHT
if (user.id !== data.userId)

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