TL;DR

  • What: Nautobot's GraphQL API enforced object permissions at the root of a query but not when a query traversed into related models. In my local test on v3.1.4, an account with view permission on Location and no device permission got a 403 from the REST device list and an empty list from a top-level devices query. A nested query through location.devices returned the device's id and name.
  • Impact: per the advisory, users with access to some models but not others could retrieve fields from models they were not meant to reach. It needs a valid account, and it is read-only. Rated Moderate 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N). CWE-200 and CWE-285.
  • Fixed in 2.4.38 and 3.2.0, advisory GHSA-mfwj-pjgx-22v2. The advisory reads: "Credit for this discovery goes to Habibullo Izzatilloyev. It was also reported by Santosh Kumar Puppala."

Why you should care

Nautobot describes itself as a Network Source of Truth and Network Automation Platform. Its core models cover devices and racks, IP networks and addresses, circuits and cables, and it offers GraphQL alongside REST. Object permissions decide which of those records a user may see.

GraphQL lets the client choose the path to the data. A check at the root of a query covers the objects that root returns. It says nothing about the objects the query reaches next, unless each step through a relation checks again.

The setup

Nautobot is built on Django and exposes the models registered for GraphQL through graphene-django. Access is governed by ObjectPermission. A permission grants actions on object types, and it can carry constraints, for example view devices at one location. There are no deny rules. The account in this test simply had no grant on Device.

REST views, UI views and the root GraphQL resolvers filter their querysets through one method, restrict(user, action). For a user with no grant on the model, it returns an empty queryset.

The request that matters here is one nested query, shown as I ran it:

POST /api/graphql/ { location(id:"<LOC-A uuid>"){ name devices { id name } } } 

It asks for a parent the account may view, and a relation the account has no grant for.

The bug

The resolvers are generated in nautobot/core/graphql/generators.py. The code below is from tag v3.1.4 (commit 94012e0). The two root resolvers restrict the queryset:

# nautobot/core/graphql/generators.py (v3.1.4) def generate_single_item_resolver(schema_type, resolver_name): ... def single_resolver(self, info, **kwargs): obj_id = kwargs.get("id", None) if obj_id: queryset = model.objects.all() if hasattr(queryset, "restrict"): queryset = queryset.restrict(info.context.user, "view") ... def generate_list_resolver(schema_type, resolver_name): ... def list_resolver(self, info, limit=None, offset=None, **kwargs): ... queryset = model.objects.all() if hasattr(queryset, "restrict"): queryset = queryset.restrict(info.context.user, "view") ... 

The resolver behind list-style relations does not:

# nautobot/core/graphql/generators.py (v3.1.4) def generate_filter_resolver(schema_type, resolver_name, field_name): ... filterset_class = schema_type._meta.filterset_class @gql_optimizer.resolver_hints(model_field=field_name) def resolve_filter(self, info, **kwargs): field = getattr(self, field_name) if not filterset_class or not kwargs: return field.all() ... matching_ids = info.context._gql_filter_cache[cache_key] return [obj for obj in field.all() if obj.pk in matching_ids] 

nautobot/core/graphql/schema.py installs generate_filter_resolver for many-to-many, reverse foreign-key and generic relation fields whose target model has a registered GraphQL type. It skips reverse one-to-ones. Device.location has related_name="devices", so location.devices goes through this resolver and returns location.devices.all(), with no permission check on the devices.

That is the path I tested, and it was not the only one. The fix's changelog lists these as well:

  • rel_* fields that traverse Relationship associations
  • forward foreign keys, such as racks { location }
  • reverse relations, including *_set fields and reverse one-to-ones such as module_bay { installed_module }
  • custom and property-backed accessors: a Device's all_interfaces, cable and connection peers, dynamic group memberships, assigned tags, and the legacy location accessors on Prefix and VLAN

Some of these were generated and some were hand-written, for example resolve_dynamic_groups on the Device GraphQL type (class DeviceType in nautobot/dcim/graphql/types.py) at v3.1.4. So this was not one forgotten call. The advisory's description is the accurate one: permissions were enforced at the root of the query and not when traversing into related models.

REST had the same kind of gap in a different place. Its ?depth parameter also traversed to related objects without enforcing permissions on them, and that was fixed in the same releases under a separate advisory, GHSA-h8rv-c7c8-cvmx.

Sequence diagram: REST and top-level GraphQL refuse Account A; the nested location.devices query returns DEV-A

The "aha"

Nautobot asked whether the user could see an object at the root of the query, and did not ask again when the query stepped into a related model. The client chooses the steps.

Proof of concept (benign)

I ran networktocode/nautobot:3.1.4 locally in Docker, with synthetic data only. No hosted or third-party system was touched. Account A holds exactly one ObjectPermission: dcim | location | view. It has no device permission of any kind. The seed is one Location, LOC-A, and one Device, DEV-A, assigned to it. These are the recorded requests and responses, with the UUIDs replaced by placeholders:

# [1] negative control: REST device list (Token A) GET /api/dcim/devices/?limit=50 -> 403 {"detail":"You do not have permission to perform this action."}  # [2] sanity check: top-level GraphQL (Token A) POST /api/graphql/ {"query":"{ devices { id name } }"} -> {"data":{"devices":[]}}  # [3] the bypass: nested GraphQL (Token A) POST /api/graphql/ {"query":"{ location(id:\"<LOC-A uuid>\"){ name devices { id name } } }"} -> {"data":{"location":{"name":"LOC-A","devices":[{"id":"<DEV-A uuid>","name":"DEV-A"}]}}} 

The proof is the split. In the same test run, the same token got a 403 from REST and an empty list from top-level GraphQL, and the nested query returned DEV-A. An admin's REST query also returned DEV-A, which confirms the device is real.

From the source, the 403 and the empty list come from different places. REST's default permission class, TokenPermissions, requires dcim.view_device and refuses the request before the view restricts any queryset. The top-level GraphQL resolver calls restrict(), which returns no rows for a user with no grant.

Honest boundary: the recorded requests asked for id and name on one seeded device, so that is all I can show. Serial numbers, IP addresses and interfaces were not part of them. I did not test a constrained device grant, the web UI or REST ?depth. The maintainers' new tests do cover constrained grants, for example a view grant on Rack limited to one name.

The fix

Shipped in 3.2.0 (commit a05eb7d) and 2.4.38 (commit 52a17d8), both released 2026-07-27. My report suggested calling restrict() in this resolver. That is not what shipped, and the maintainers' comment says why. The resolver as shipped in v3.2.0:

# nautobot/core/graphql/generators.py (v3.2.0)  def resolve_filter(self, info, **kwargs): field = getattr(self, field_name) related_model = field.model # Object-permission enforcement: the related manager (`field`) is prefetched by the optimizer, so we  # keep it intact and filter the loaded objects in Python against the user's permitted PKs, rather than  # re-querying with `restrict()` (which would defeat the prefetch and cause N+1 queries).  # `None` means unrestricted access, in which case no permission filtering is applied.  permitted_ids = get_permitted_pks(info, related_model) if not filterset_class or not kwargs: objects = field.all() if permitted_ids is None: return objects return [obj for obj in objects if obj.pk in permitted_ids] ... matching_ids = info.context._gql_filter_cache[cache_key] return [ obj for obj in field.all() if obj.pk in matching_ids and (permitted_ids is None or obj.pk in permitted_ids) ] 

get_permitted_pks in nautobot/core/graphql/utils.py works out the set of primary keys a user may view for a model and caches it on the request, so the check costs at most one query per model per GraphQL request.

The 3.2.0 commit is 932 additions and 79 deletions across 15 files, because it covers the other paths too:

  • Forward foreign keys and one-to-ones get generate_fk_resolver. A related object the user may not view now comes back as null, so the schema marks all forward foreign keys as nullable.
  • Reverse one-to-ones get generate_single_relation_resolver.
  • generate_relationship_resolver does call restrict(), because it already issues a fresh query per parent object.
  • Hand-written and property-backed accessors are wrapped with two new helpers, permission_safe_resolver and permission_safe_attribute_resolver.
  • A new test, test_all_related_object_fields_are_permission_enforced, scans the schema and fails on any field that returns a restrictable model through a resolver not marked as enforcing permissions.

Takeaways

  1. A permission check at the root of a GraphQL query does not cover what the query reaches next. Every resolver that returns persisted objects has to answer the question again, including default resolvers you never wrote and accessors built on model properties.
  2. Make a missing check fail a test. The most reusable part of this fix is the guard test. It walks the schema, so a new accessor that returns related objects without enforcing permissions gets caught before it ships.
  3. Plan for the query cost of the check. A per-object restrict() on a prefetched relation means one query per parent. Nautobot computes one set of permitted keys per model per request and filters in Python, which keeps the prefetch.

Disclosure timeline (UTC)

  • 2026-06-23: reproduced locally on v3.1.4 (synthetic data).
  • 2026-07-06: reported privately by email to the Nautobot security team.
  • 2026-07-15: the maintainers replied that another reporter had already identified the issue and that I would be credited as an additional reporter.
  • 2026-07-27: v2.4.38 and v3.2.0 released, and advisory GHSA-mfwj-pjgx-22v2 published by the maintainers.

Credit / CTA

If you run Nautobot, upgrade. The fix first shipped in 2.4.38 and 3.2.0, and later releases carry further security fixes. On 2.4, take the latest 2.4 release. On 3.0 or 3.1 there is no fixed release on your line, so move to the latest 3.2 release. The advisory lists no workaround and notes that the weakness also exists in EOL v1.x. After upgrading, users without view permission on a related model will see null on forward foreign keys and shorter lists on relations. Apps with custom GraphQL types should review the new helpers above.

If you work with GraphQL generally, this is a quick test: take a user with no permission on a model, request those records directly, then request them again by traversing from something the user can see. If the two answers differ, your traversal isn't checking.

Credit for this discovery goes to Habibullo Izzatilloyev. It was also reported by me. Comments and corrections welcome.

References

  1. Nautobot security advisory GHSA-mfwj-pjgx-22v2, "Object view permissions bypass via GraphQL traversal", published 2026-07-27. https://github.com/nautobot/nautobot/security/advisories/GHSA-mfwj-pjgx-22v2
  2. Nautobot security advisory GHSA-h8rv-c7c8-cvmx, 'Object view permissions bypass via REST API "depth"', published 2026-07-27. https://github.com/nautobot/nautobot/security/advisories/GHSA-h8rv-c7c8-cvmx
  3. Fix commit for 3.2.0, a05eb7d. https://github.com/nautobot/nautobot/commit/a05eb7d0c939dd4205aca839955e4b3ee5f32af4
  4. Fix commit for 2.4.38, 52a17d8. https://github.com/nautobot/nautobot/commit/52a17d8e4806b909d5de9f0a121c92c2bbc6d719
  5. nautobot/core/graphql/generators.py at v3.1.4 (before the fix). https://github.com/nautobot/nautobot/blob/v3.1.4/nautobot/core/graphql/generators.py
  6. nautobot/core/graphql/generators.py at v3.2.0 (after the fix). https://github.com/nautobot/nautobot/blob/v3.2.0/nautobot/core/graphql/generators.py
  7. Nautobot release notes, v3.2.0 and v2.4.38. https://github.com/nautobot/nautobot/releases/tag/v3.2.0 and https://github.com/nautobot/nautobot/releases/tag/v2.4.38
  8. MITRE CWE-200, Exposure of Sensitive Information to an Unauthorized Actor. https://cwe.mitre.org/data/definitions/200.html
  9. MITRE CWE-285, Improper Authorization. https://cwe.mitre.org/data/definitions/285.html

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