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
- Multi-user is the norm. Job tracking apps are used by couples, families, roommates, and small teams — exactly the scenario where IDOR becomes exploitable.
- Resumes contain the most sensitive PII. Full name, phone number, home address, email, employment history, salary expectations, and cover letter content.
- 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).
- 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
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)
getJobDetailsjob.actions.ts:~178{ id: jobId }— no userIdgetResumeByIdprofile.actions.ts:~83{ id: resumeId }— no userIdgetCompanyByIdcompany.actions.ts:~175{ id: companyId }— no createdByWrite Access IDOR (any authenticated user can modify other users' data)
updateJobjob.actions.ts:~366data.userId(spoofable), Prismawhere: { id }has no userIdeditResumeprofile.actions.ts:~290where: { id }— no userIdaddContactInfoprofile.actions.ts:~126resume.update where: { id: data.resumeId }— no userIdupdateContactInfoprofile.actions.ts:~164where: { id: data.id }— no userIdupdateResumeSummaryprofile.actions.ts:~452where: { id: data.id }— no userIdupdateExperienceprofile.actions.ts:~548where: { id: data.id }— no userIdupdateEducationprofile.actions.ts:~632where: { id: data.id }— no userIdupdateCoverLettercoverLetter.actions.ts:~124where: { id }— no userIddeleteCoverLetterByIdcoverLetter.actions.ts:~145where: { id }— no userIdAuthorization Logic Flaw
updateJobjob.actions.ts:~337!=(loose equality) instead of!==for user ID comparisonupdateCompanycompany.actions.ts:~162The
updateJobauth check is especially problematic:This checks the client-submitted
data.userIdagainst the session user — the attacker simply sends their ownuserIdin the payload. The subsequent Prismaupdatequeries byidalone, so the job being modified can belong to anyone.Proof of Concept
Read another user's resume (chain with AI match endpoint)
Modify another user's job
Delete another user's cover letter
Impact
getResumeById,editResume,addContactInfo,updateContactInfo,updateExperience,updateEducationgetJobDetails,updateJobupdateCoverLetter,deleteCoverLetterByIdgetCompanyByIdWhy This Matters for Self-Hosted Apps
/api/ai/resume/match) exposes bothresumeIdandjobIdin its API surface, making enumeration feasible.Severity
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L= 8.3 (High)Suggested Fix (Systematic)
Pattern: Add
userIdto every Prismawhereclause:Pattern: Never trust client-submitted userId:
Pattern: Use strict equality:
References