DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

retrieve said 403. history_detail said 200. (Objects API, CVE-2026-82411)

TL;DR

  • What: Open Object (called the Objects API before 4.0) stores object registrations and gives each API token read or write access per object type. One action, the record-version detail endpoint GET /api/v2/objects/{uuid}/{index} (history_detail), returned the record without running that per-type check.
  • Impact: A token with a read grant on one object type could read the full versioned data of a record of any other object type, given the object's UUID (record indexes start at 1). Read-only. Rated Moderate (CVSS 6.5, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N), CWE-639.
  • Affected: 2.4.3 and later, up to the fixed releases. Fixed in 2.5.1, 3.6.2, 4.0.2 and 4.1.1 (all 2026-07-16). Advisory GHSA-55w6-rqp5-j5wx, CVE-2026-82411. The advisory credits two reporters: me and @raysabee.

Why you should care

Open Object stores "objects" for other applications. The docs' examples include a parking permit, a tree and a complaint. It was commissioned by the Municipality of Utrecht and is designed in line with the Common Ground model, and the docs name municipalities as the organizations that want to store these objects.

The access model for objects is per object type: clients "have explicit access to objects based on their object types." A token can hold a read grant on trees and no grant on parking permits. This bug went around that limit. Authentication was not the problem, because the token was real. What went missing was the next question: does this token have a grant on this record's object type?

What the data holds depends on the object type. The CVE record says it can include citizen or case records and sensitive identifiers in government deployments, and the project's own Vordering (debt claim) example schema has a required Burgerservicenummer field, the Dutch citizen service number (BSN). I have not looked at any real deployment. My run used synthetic data.

The setup

Open Object is a Django and Django REST Framework (DRF) app. In DRF, the conventional place for object-level authorization is self.get_object(). It fetches the record and calls check_object_permissions(), which runs each permission class's has_object_permission(). Here that class is ObjectTypeBasedPermission, and its object-level check asks whether the token has a grant on this record's object type.

Two other places matter:

  • The view-level gate, has_permission(), runs before the record is loaded, so it can't know which object type you're about to touch.
  • The queryset can be scoped to the token's grants with the filter_for_token() queryset method. list and search get that.

So a read is authorized one of two ways. retrieve and history go through self.get_object(). list and search go through the queryset filter. An action that does neither gets no object-type check.

The bug

history_detail fetches the record with get_object_or_404 and returns it. The code in this section is from tag 4.1.0, the version I tested.

# src/objects/api/v2/views.py, tag 4.1.0, lines 493-498 (history_detail)
    def history_detail(self, request, uuid=None, index=None):
        """Retrieve a RECORD of an OBJECT."""
        queryset = self.get_queryset()
        record = get_object_or_404(queryset, object__uuid=uuid, index=index)
        serializer = self.get_serializer(record)
        return Response(serializer.data)
Enter fullscreen mode Exit fullscreen mode

The view-level gate lets any authenticated token through for every action except create:

# src/objects/token/permissions.py, tag 4.1.0, lines 9-22 (ObjectTypeBasedPermission)
    def has_permission(self, request, view):
        if bypass_permissions(request):
            return True

        # request should be token authenticated
        if not request.auth:
            return False

        if request.auth.is_superuser:
            return True

        # detail actions are processed in has_object_permission method
        if view.action != "create":
            return True
        ...
Enter fullscreen mode Exit fullscreen mode

That comment is the assumption: a detail action gets checked later, in has_object_permission. Nothing in history_detail makes that happen.

The token filter applies only to list and search:

# src/objects/api/v2/views.py, tag 4.1.0, lines 340-345 (end of get_queryset)
        if self.action not in ("list", "search"):
            return base

        # show only allowed objects
        base = base.filter_for_token(token_auth)
        return base
Enter fullscreen mode Exit fullscreen mode

So for history_detail, any valid token passes the view-level gate, the queryset is not filtered, and check_object_permissions() is never called, so has_object_permission() never runs. HistoryRecordSerializer is a plain ModelSerializer whose fields include data, so the record's whole payload goes back.

The history action directly above it in the same ViewSet does get the check, because it fetches with self.get_object():

# src/objects/api/v2/views.py, tag 4.1.0, line 459 (history)
        records = self.get_object().object.records.order_by("id")

# src/objects/api/v2/views.py, tag 4.1.0, lines 495-496 (history_detail)
        queryset = self.get_queryset()
        record = get_object_or_404(queryset, object__uuid=uuid, index=index)
Enter fullscreen mode Exit fullscreen mode

Token B gets 403 from retrieve, then 200 with an ObjectType 2 record from history_detail. In 4.1.1 history_detail runs the check.

The "aha"

retrieve and history reach the record through self.get_object(), which runs the object-level check. list and search are scoped by filter_for_token. history_detail did neither, so nothing compared the token's grants with the record's object type.

Proof of concept (benign)

I ran this locally in Docker against the shipped maykinmedia/open-object:4.1.0 image (Postgres and Redis), with synthetic data only. No hosted system was touched. I created two non-superuser tokens on the same instance, each with a read grant on a different object type:

  • Token A: read on ObjectType 2
  • Token B: read on ObjectType 1 only

Then I seeded one object of ObjectType 2 with a marker payload: {"secret":"CROSS-TYPE-LEAK-CANARY","bsn":"123456789",...}. The BSN is made up.

This is my recorded summary of the run, not a raw log, and the response body is abridged where it shows ...:

# Token B, control: retrieve is denied
GET /api/v2/objects/{uuid}
-> HTTP 403  permission_denied

# Token B, the bug: history_detail on the same object
GET /api/v2/objects/{uuid}/1
-> HTTP 200
{"index":1,"typeVersion":1,"data":{"bsn":"123456789","note":"OType2 citizen record","secret":"CROSS-TYPE-LEAK-CANARY"},...}

# Token A, control: it has the grant, so 200 is expected
GET /api/v2/objects/{uuid}/1
-> HTTP 200

# Token B, sanity check: the token is valid
GET /api/v2/objects
-> HTTP 200
Enter fullscreen mode Exit fullscreen mode

Same token, same object: 403 through retrieve, 200 through history_detail. That split is the finding.

Honest boundary: the run reads one seeded canary record. There is no write, no code execution and no privilege change. It's a confidentiality bug (C:H, I:N, A:N). I tested one pair of object types. I already had the UUID because I seeded the object. Object UUIDs default to random uuid4 values, list and search are filtered by token, and I did not test any way of discovering another type's UUID. I also did not request history as Token B. That it enforces the grant comes from the source and from the project's test_history_no_object_permissions, which expects 403.

The advisory rates it Moderate with CVSS 6.5, and the CVE record carries the same score and vector as MEDIUM.

The fix

Shipped on 2026-07-16 in four release lines: 2.5.1, 3.6.2, 4.0.2 and 4.1.1. The 4.1.x commit makes two changes. The first keeps the same fetch and adds one direct call on the fetched record:

# src/objects/api/v2/views.py, commit ce6b182 (history_detail)
         queryset = self.get_queryset()
         record = get_object_or_404(queryset, object__uuid=uuid, index=index)
+        self.check_object_permissions(self.request, record)
         serializer = self.get_serializer(record)
         return Response(serializer.data)
Enter fullscreen mode Exit fullscreen mode

It uses neither self.get_object() nor filter_for_token, the two options my report suggested. A token with no grant now gets 403: the commit adds test_history_detail_no_object_permissions, which asserts that. I did not re-run my PoC against the fixed releases.

The second change extends an existing rule. A read-only token using field-based permissions (the docs' "Use fields" option) was already refused history. Now it is refused history_detail too, even with a grant on that object type:

# src/objects/token/permissions.py, commit ce6b182 (has_object_permission)
         # forbid showing history if there is field based auth
         if (
-            view.action == "history"
+            view.action in ["history", "history_detail"]
             and object_permission.mode == PermissionModes.read_only
             and object_permission.use_fields
         ):
Enter fullscreen mode Exit fullscreen mode

My run did not test field-restricted tokens. Both changes are also present at tags 2.5.1, 3.6.2 and 4.0.2.

Takeaways

  1. In DRF, self.get_object() is what runs check_object_permissions(). A custom action that fetches a record another way, with get_object_or_404 or a direct .get(), has to call check_object_permissions() itself. The shipped fix here is that one line.
  2. A view-level gate that defers to the object-level check depends on something calling it. has_permission() here returns True because has_object_permission() is meant to handle detail actions. DRF runs that from check_object_permissions(), so an action that skips the call skips the check, with no error.
  3. Test the denial on every action. At 4.1.0, test_auth.py had test_history_no_object_permissions and nothing for history_detail. The fix adds it.

Disclosure timeline

  • 2026-06-19: Found by source review and reproduced locally on the shipped open-object:4.1.0 image with a synthetic canary.
  • 2026-06-23: Reported privately through GitHub's private vulnerability reporting on maykinmedia/open-object.
  • 2026-07-16: Advisory GHSA-55w6-rqp5-j5wx published by the maintainers, and the fix released in 2.5.1, 3.6.2, 4.0.2 and 4.1.1.
  • 2026-10-28: CVE-2026-82411 record published.

Credit / CTA

If you run Open Object or the Objects API, you need 2.5.1, 3.6.2, 4.0.2, 4.1.1 or later. The project's versioning page lists 4.0.x and 4.1.x as past their support dates, so on 4.x move to 4.2.x or 4.3.x, which both carry the fix. If you build DRF ViewSets, grep your @action methods for get_object_or_404 or direct queryset fetches, then check that each one calls self.check_object_permissions(request, obj) itself or goes through self.get_object().

The advisory credits two reporters: me and @raysabee. Comments and corrections welcome.


Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq). GitHub: @Santoshkumarpuppala

Top comments (0)