Guard the Contents, Not the Container: Two-Sided Rebind Media
The endpoint guarded the target entity but never the source media ids. After a rebind, managing someone else's media became legitimate.
TL;DR
An editor rebinds another bidang's media into their own gallery because the endpoint guarded only the target, not the source. The fix resolves media modules in one parameterized batched query and rejects out-of-scope ids with a static 403 before any write. A similar unguarded path in announcements shows source objects need the same object-level checks on every write path.
A scoped editor rebinds an entity gallery and walks away with media that belongs to another bidang's entity. The endpoint guarded the target entity's module but never checked where the source media ids came from. Worse, once the rebind finished, PATCH and DELETE on those media passed the guard, because the media now sat under the editor's own entity.
The wrong guess: the module guard on the entity already covers its media. What turned out to be true: media identity is a separate object-level authorization surface. Guarding the container is not guarding the contents. The gap is exactly the shape of horizontal privilege escalation, one user reaching another user's resources at the same privilege level [3], and CWE-639 names the user-controlled key as the mechanism [4].
Resolving Source Media in One Query
The module a piece of media belongs to is not stored on the media row itself. It is derived: media attach to entities, entities belong to categories, and modules live on categories. That is why the fix in the entity admin service resolves every source media's module in a single batched query that walks media to entities to categories. The id list is built as a parameterized IN clause with sqlx.In, so nothing is concatenated into the query string, and the chained-query load disappears. Ids outside the requester's scope are rejected with a static 403 before any write happens. The static body matters as much as the status: an error that echoes the requested id or module name gives a probing client confirmation about what exists on the other side of the fence. Rejecting before the write also means no half-done state: the entity gallery stays intact, the media library does not move, and nothing needs manual cleanup.
Free media, where entity_id is NULL, stays admin-global only, mirroring the media admin service. The regression test TestService_RebindMediaSourceScope was written first and proven red against the old code, then passed once the guard ran. That red-before-green pattern is acceptance evidence that the hole is closed rather than assumed closed.
The Sibling Behind the Review
Reviewing the first fix found a similar gap in the announcement admin service. It accepted the requester's bidang but never called the scope guard at all [F-SEC-43.1]. The shape differs: announcement rebind works from the URL address, not a list of media ids, so there is no source id to resolve. The scope check still has to run before the mutation, because the entity id the client carries is equally untrusted. Requests that fail now get a 403, and the 400 description was corrected in the same pass: that code is for malformed requests, not for authorization refusals. The announcement fix shipped with TestService_RebindMediaScope, also red before the fix.
The sibling was registered as its own finding and fixed in a follow-up commit the same day, with its own regression test. Two consecutive fixes make one point: a guard is not a pattern until every write path calls it. A review that finds a sibling is the process working as intended.
The rebind had a second effect worth naming: rebinding is an ownership transfer in the eyes of the system. Once the media rows pointed at the editor's own entity, every later PATCH and DELETE compared the requester's scope against that new home and found a match. The first request did the harm; everything after looked ordinary in the audit sense. A guard that only inspects the target of the initial request never sees the chain it started.
Object-Level Authorization
IDOR needs three ingredients: an object, a user-controlled reference to it, and a missing object-level authorization check [2]. Complex or random identifiers would not have replaced the check [2]. Deny-by-default belongs in trusted server-side code, and the data model should enforce record ownership [1].
Source objects deserve authorization as strong as target objects, on every write path.
Sources
[1] Broken_Access_Control/index.html — OWASP Top 10:2021 A01 Broken Access Control
[2] OWASP IDOR Prevention Cheat Sheet
[3] OWASP Authorization Cheat Sheet
[4] CWE-639: Authorization Bypass Through User-Controlled Key