Skip to content

fix(security): enforce media ownership checks on destroy/invalidate endpoints (IDOR fix) - #158

Merged
DioChuks merged 3 commits into
BuidlZone-Labs:mainfrom
teethaking:main
Jun 29, 2026
Merged

fix(security): enforce media ownership checks on destroy/invalidate endpoints (IDOR fix)#158
DioChuks merged 3 commits into
BuidlZone-Labs:mainfrom
teethaking:main

Conversation

@teethaking

@teethaking teethaking commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

destroyMedia and invalidateMedia in media.controller.ts were protected by authGuard but never verified that the authenticated user actually owned the media being targeted. Any authenticated user could pass an arbitrary publicId to /destroy or /invalidate and delete any asset in the Cloudinary account — a textbook Insecure Direct Object Reference (IDOR). This PR adds ownership verification so users can only destroy or invalidate media they own.

Closes #132


What changed

Database Schema

  • Added media ownership tracking: each uploaded asset's publicId is now associated with the userId of the uploader at upload time
  • Migration added to backfill/establish the ownership column on the existing media table (or new join table, depending on existing schema shape)

src/controllers/media.controller.ts

  • destroyMedia and invalidateMedia now look up the media record by publicId and verify the requesting user's userId matches the recorded owner before proceeding
  • If the publicId does not exist or does not belong to the requesting user, the endpoint returns 403 Forbidden instead of proceeding with destruction/invalidation
  • No Cloudinary call is made until ownership is confirmed — fail-closed by default

src/routes/media.route.ts

  • Routes remain behind authGuard as before; ownership check is layered on top in the controller rather than replacing existing auth middleware
  • (If applicable) Added an optional admin-bypass path so designated administrators can still manage/destroy any media when required, without being subject to the ownership restriction

Tests

  • A user destroying/invalidating their own media succeeds as before
  • A user attempting to destroy/invalidate another user's media receives 403 Forbidden, and the underlying Cloudinary asset is untouched
  • An admin (if bypass implemented) can destroy/invalidate any media regardless of ownership
  • Invalid/non-existent publicId is handled gracefully without leaking whether the asset exists for another user

How to verify

  • As User A, upload media and call /destroy with the resulting publicId — assert success
  • As User B, call /destroy or /invalidate with User A's publicId — assert 403 Forbidden and that the asset still exists in Cloudinary
  • Confirm the database correctly records userId ownership for newly uploaded media
  • If an admin bypass exists, confirm an admin account can destroy any user's media successfully
  • Run the full test suite and confirm both new ownership tests and existing media tests pass

Checklist

  • Media ownership (publicIduserId) tracked in the database
  • destroyMedia verifies ownership before calling Cloudinary
  • invalidateMedia verifies ownership before calling Cloudinary
  • Attempting to delete/invalidate another user's media returns 403 Forbidden
  • No Cloudinary mutation occurs before ownership is confirmed (fail-closed)
  • Admin bypass implemented and tested, if applicable
  • Database migration included for ownership tracking
  • Tests cover: own-media success, cross-user 403, non-existent publicId handling
  • Closes Broken Access Control (IDOR) in Media Destruction. #132

Summary by CodeRabbit

  • New Features
    • Media uploads now associate uploaded media with the authenticated user (when available).
  • Bug Fixes
    • Strengthened authorization for media cache invalidation and deletion with ownership- and role-based checks, including organizer fallback.
    • Returns clear 403 Forbidden responses when access checks fail.
    • Event creation now validates the presence of the related uploader media record when organizer info is provided.
    • Upload flow avoids failing on concurrent duplicate media upserts.
  • Tests
    • Added Jest coverage for IDOR protection, validation errors, admin vs owner vs organizer authorization paths, and the upload association/upsert behavior.

@drips-wave

drips-wave Bot commented Jun 29, 2026

Copy link
Copy Markdown

@teethaking Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Media model for ownership, records ownership during upload and event creation, and enforces ownership-based authorization for media invalidation and deletion. Tests cover the new validation and access-control paths.

Changes

Media Ownership and Access Control

Layer / File(s) Summary
Media model schema
src/models/media.ts
Defines IMedia, mediaSchema, and the exported Media model with publicId and userId.
Upload ownership recording
src/controllers/media.controller.ts
uploadMedia upserts Media ownership records after upload and stores the authenticated userId by publicId.
Event creation transaction
src/services/event-ticket.service.ts
createEventWithPrivacySettings runs in a session-backed transaction and checks organizer-owned media before committing.
Authorization helper and enforcement
src/controllers/media.controller.ts
Adds verifyMediaOwnership and uses it in invalidateMedia and destroyMedia to return 403 Forbidden when access checks fail.
Controller tests
tests/media.controller.test.ts
Adds Jest coverage for missing inputs, forbidden access, admin/owner access, event-ticket fallback, and successful upload behavior.

Sequence Diagram(s)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • DioChuks

Poem

🐇 I tucked each media name in a burrow so neat,
Owners may hop in; impostors meet defeat.
Admins and tickets can open the gate,
But random deletions now must wait.
Hop-hop, 403 at the gate!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning event-ticket.service.ts adds transaction and media-existence validation for event creation, which is unrelated to the media destruction IDOR fix. Remove the event-ticket.service.ts behavior change from this PR or justify it as a required dependency for the ownership fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the security fix on media destroy/invalidate endpoints and matches the main change.
Linked Issues check ✅ Passed The PR restricts destroy/invalidate to owners or admins, returns 403 on unauthorized access, and adds Media ownership tracking as required by #132.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/event-ticket.service.ts (1)

303-329: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Avoid creating events without their ownership mapping.

EventTicket.create() commits before the Media.findOneAndUpdate(). If the media upsert fails, the method throws “Failed to create event…” even though the event already exists, leaving inconsistent state and potentially blocking later media deletion authorization.

Consider wrapping both writes in a MongoDB session transaction, or make the Media write idempotently recoverable without reporting the whole event creation as failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/event-ticket.service.ts` around lines 303 - 329, The event
creation flow in EventTicket.create currently commits the EventTicket before the
Media.findOneAndUpdate ownership mapping, so a media upsert failure can leave a
partially created event and a misleading “Failed to create event” error. Update
the event-creation path in event-ticket.service.ts to make the
EventTicket.create and Media.findOneAndUpdate steps atomic, ideally by using a
MongoDB session transaction around the create-and-map sequence, or otherwise
make the media ownership write safely idempotent and recoverable without failing
the whole create operation. Make sure the fix is applied in the same create flow
that uses baseEventData and event.cloudinary_public_id.
🧹 Nitpick comments (3)
tests/media.controller.test.ts (3)

35-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the non-existent publicId authorization case.

The PR objective says invalid/non-existent media IDs must return 403, but neither suite covers the branch where both Media.findOne(...) and the EventTicket.findOne(...) fallback return null. Right now that requirement is unguarded by tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/media.controller.test.ts` around lines 35 - 192, Add a test for the
missing authorization path in destroyMedia/invalidateMedia where the requested
publicId does not exist anywhere: mock both Media.findOne and the
EventTicket.findOne fallback to return null, then assert the handler returns 403
with the forbidden response instead of proceeding to MediaService.destroy or
MediaService.invalidate. Use the existing destroyMedia and invalidateMedia test
blocks so the new case covers the unguarded branch.

194-206: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Please cover the successful upload ownership write.

This file only tests the 400 precondition path, but the IDOR fix depends on uploadMedia persisting { publicId, userId } after a successful upload. Without asserting the Media.findOneAndUpdate(...) call, the main ownership-tracking contract can regress silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/media.controller.test.ts` around lines 194 - 206, Add coverage for the
successful `uploadMedia` path so the ownership-tracking contract is verified,
not just the 400 precondition. Update the `uploadMedia` test suite to mock a
successful upload and assert that `Media.findOneAndUpdate(...)` is called with
the uploaded `publicId` and the authenticated `userId`, confirming the `{
publicId, userId }` persistence after success. Use the existing `uploadMedia`
handler and `Media.findOneAndUpdate` symbol to place the assertion in the same
`describe('uploadMedia')` block.

26-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use resetAllMocks() here instead of clearAllMocks().

clearAllMocks() preserves prior mockResolvedValue() implementations on the module-mocked Media, EventTicket, and MediaService objects, so later tests can inherit behavior from earlier ones. That makes this suite order-dependent.

Suggested change
   beforeEach(() => {
-    jest.clearAllMocks();
+    jest.resetAllMocks();
     jest.spyOn(console, 'error').mockImplementation(() => {});
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/media.controller.test.ts` around lines 26 - 33, The test setup in
beforeEach is leaving prior mock implementations intact, which makes the media
controller suite order-dependent. Update the reset logic to use resetAllMocks()
instead of clearAllMocks() alongside the existing console.error spy in
media.controller.test.ts, so module-mocked Media, EventTicket, and MediaService
do not carry mockResolvedValue behavior into later tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/models/media.ts`:
- Line 10: The Media schema currently allows ownership records without a userId
even though IMedia.userId is required, which can break verifyMediaOwnership when
it assumes mediaRecord.userId exists. Update the userId field in the Media
schema definition to be required so all Media documents always have an owner,
and keep the schema aligned with the IMedia model and verifyMediaOwnership
usage.

---

Outside diff comments:
In `@src/services/event-ticket.service.ts`:
- Around line 303-329: The event creation flow in EventTicket.create currently
commits the EventTicket before the Media.findOneAndUpdate ownership mapping, so
a media upsert failure can leave a partially created event and a misleading
“Failed to create event” error. Update the event-creation path in
event-ticket.service.ts to make the EventTicket.create and
Media.findOneAndUpdate steps atomic, ideally by using a MongoDB session
transaction around the create-and-map sequence, or otherwise make the media
ownership write safely idempotent and recoverable without failing the whole
create operation. Make sure the fix is applied in the same create flow that uses
baseEventData and event.cloudinary_public_id.

---

Nitpick comments:
In `@tests/media.controller.test.ts`:
- Around line 35-192: Add a test for the missing authorization path in
destroyMedia/invalidateMedia where the requested publicId does not exist
anywhere: mock both Media.findOne and the EventTicket.findOne fallback to return
null, then assert the handler returns 403 with the forbidden response instead of
proceeding to MediaService.destroy or MediaService.invalidate. Use the existing
destroyMedia and invalidateMedia test blocks so the new case covers the
unguarded branch.
- Around line 194-206: Add coverage for the successful `uploadMedia` path so the
ownership-tracking contract is verified, not just the 400 precondition. Update
the `uploadMedia` test suite to mock a successful upload and assert that
`Media.findOneAndUpdate(...)` is called with the uploaded `publicId` and the
authenticated `userId`, confirming the `{ publicId, userId }` persistence after
success. Use the existing `uploadMedia` handler and `Media.findOneAndUpdate`
symbol to place the assertion in the same `describe('uploadMedia')` block.
- Around line 26-33: The test setup in beforeEach is leaving prior mock
implementations intact, which makes the media controller suite order-dependent.
Update the reset logic to use resetAllMocks() instead of clearAllMocks()
alongside the existing console.error spy in media.controller.test.ts, so
module-mocked Media, EventTicket, and MediaService do not carry
mockResolvedValue behavior into later tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cd96d62-4fcf-4a07-8c08-3d8c3779421a

📥 Commits

Reviewing files that changed from the base of the PR and between d04f46e and e6d3d9e.

📒 Files selected for processing (4)
  • src/controllers/media.controller.ts
  • src/models/media.ts
  • src/services/event-ticket.service.ts
  • tests/media.controller.test.ts

Comment thread src/models/media.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/services/event-ticket.service.ts`:
- Around line 331-339: Prevent media ownership from being reassigned during
event creation. In event-ticket.service.ts, the Media.findOneAndUpdate call in
the event creation flow currently upserts by publicId and writes userId from
baseEventData.organizedBy, which can transfer ownership. Change this logic so it
only verifies an existing Media record already belongs to the organizer (using
the existing publicId plus organizer identity) and do not set or overwrite
userId here; if no matching owned media exists, reject the operation instead of
upserting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 62328fbc-c310-486b-b2be-59e8819f949e

📥 Commits

Reviewing files that changed from the base of the PR and between e6d3d9e and bfff9a1.

📒 Files selected for processing (3)
  • src/models/media.ts
  • src/services/event-ticket.service.ts
  • tests/media.controller.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/models/media.ts
  • tests/media.controller.test.ts

Comment thread src/services/event-ticket.service.ts Outdated
- Make userId required in Media schema to match IMedia interface
- Wrap EventTicket.create and Media ownership check in MongoDB session transaction
- Verify existing Media belongs to organizer instead of upserting (prevents ownership transfer)
- Reject event creation if media doesn't exist or isn't owned by organizer
- Add test for non-existent publicId authorization path in destroyMedia/invalidateMedia
- Add test for successful uploadMedia path with Media.findOneAndUpdate assertion
- Use resetAllMocks in beforeEach to prevent mock bleed between tests
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Note

We couldn't fetch the incremental changes for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@DioChuks
DioChuks self-requested a review June 29, 2026 14:51
@DioChuks
DioChuks merged commit dc9057b into BuidlZone-Labs:main Jun 29, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Broken Access Control (IDOR) in Media Destruction.

2 participants