skip to content

On a Django real-estate site, agents open other agencies' unpublished listings by editing the pk in a DetailView URL; why, and how do you close it with the generic views' hooks?

level: seniorimportance: should knowfreq 42%

answer

  1. the lookup trusts the whole table
  2. one hook feeds list and detail
  3. not found beats forbidden here
  4. overrides that bypass the hook

basics

~20 s

With model = Listing, DetailView looks the pk up in Listing._default_manager.all(), every row. Override get_queryset() in a mixin shared by the list, detail and edit views to return only rows the user may see; get_object() then 404s on everything else.

solid answer

~40 s

`DetailView.get_object()` filters `get_queryset()` by the captured pk, and with only `model = Listing` that is the whole table, so any guessed id renders. The generic fix is to scope the source, not the lookup: override `get_queryset()` to return published listings plus the requesting agent's own drafts, starting from `super().get_queryset()`. Put that in one mixin used by the `ListView`, the `DetailView` and the update and delete views, because all of them resolve objects through the same `get_queryset()`/`get_object()` pair. A foreign draft is then simply not found: `.get()` raises `DoesNotExist` and the view returns 404, which also avoids confirming the listing exists. Guard against regressions: an overridden `get_object()` that calls `Listing.objects.get()` bypasses the scope, so add a test that requests another agency's draft and expects 404.

code

python · 19 lines
python
from django.test import TestCase

from listings.tests.factories import make_agent, make_listing


class ListingScopeTests(TestCase):
    def test_agent_cannot_open_another_agencys_draft(self):
        agent = make_agent(agency="North")
        foreign_draft = make_listing(agency="South", status="draft")
        self.client.force_login(agent)
        response = self.client.get(f"/listings/{foreign_draft.pk}/")
        self.assertEqual(response.status_code, 404)

    def test_agent_can_open_own_draft(self):
        agent = make_agent(agency="North")
        own_draft = make_listing(agency="North", status="draft")
        self.client.force_login(agent)
        response = self.client.get(f"/listings/{own_draft.pk}/")
        self.assertEqual(response.status_code, 200)

go deeper

for a junior

Recall that DetailView finds its object inside get_queryset(), so an unfiltered model means every row is reachable by id.

for a middle

Explain how get_object() filters get_queryset() and raises Http404 on DoesNotExist, which makes scoping the queryset a clean access boundary.

for a senior

Show the shared mixin across list, detail and edit views, the 404-versus-403 choice, and the regressions that bypass get_queryset().

for a principal

Decide where visibility rules live: a view mixin, a custom manager or a permissions layer, and how to keep every new view inside them.

## Why the page leaks An agency's listing pages are built from Django's display generics: ```python class ListingDetailView(DetailView): model = Listing ``` On a request to `/listings/1043/`, `DetailView.get()` calls `get_object()`, which: 1. Calls `get_queryset()`; with only `model` set, that is `Listing._default_manager.all()`, **every listing in the table**. 2. Filters it by `pk=1043`. 3. Calls `.get()` and renders whatever it finds. Nothing in that chain knows about publication status or agencies. The index page may look safe because someone filtered the `ListView`, but the detail page has its own, unfiltered source. Anyone who edits the number in the URL can walk through drafts. ## The generic-view fix: scope get_queryset() `get_object()` only ever searches inside `get_queryset()`. Restrict that and every lookup is restricted with it: ```python from django.db.models import Q from django.views.generic import DetailView, ListView from listings.models import Listing # Assumes the custom user model has an optional `agency` foreign key. class VisibleListingsMixin: model = Listing def get_queryset(self): queryset = super().get_queryset() user = self.request.user if user.is_authenticated and user.agency_id: return queryset.filter(Q(status="published") | Q(agency_id=user.agency_id)) return queryset.filter(status="published") class ListingListView(VisibleListingsMixin, ListView): pass class ListingDetailView(VisibleListingsMixin, DetailView): pass ``` - The mixin comes **first** in the bases so its `get_queryset()` runs and reaches the generic one through `super()`. - **One definition** now governs the index, the detail page and, if the edit views use the mixin too, the update and delete pages. Those editing generics find their object through the same `get_object()`, so a separate check in each is unnecessary and easy to forget. - The anonymous branch never touches `user.agency_id`, so logged-out visitors see only published listings. ## 404 versus 403 | Approach | Response for a foreign draft | Reveals the listing exists? | |---|---|---| | Scope `get_queryset()` | 404 from `get_object()` | No | | Fetch unscoped, then check and raise `PermissionDenied` | 403 | Yes | For resources whose existence is itself sensitive (unpublished listings, other tenants' data) the 404 is usually preferable. A 403 is appropriate when the user may know the object exists but not act on it; that is a deliberate choice, made in a permission check rather than in `get_queryset()`. ## Regressions to watch for - **An overridden `get_object()`** that does `Listing.objects.get(pk=self.kwargs["pk"])` skips `get_queryset()` and reopens the hole. Overrides should call `super().get_object()` or filter `self.get_queryset()`. - **A new view** added without the mixin. A code review rule ("every `Listing` view inherits `VisibleListingsMixin`") or a shared base class keeps it from drifting. - **Extra context** in `get_context_data()`, such as "similar listings", that queries `Listing.objects` directly and leaks drafts into a public page. - **Sequential ids** make guessing trivial. Scoping is the real control; `query_pk_and_slug = True` only makes enumeration harder. ## Keeping the rule in one place The mixin is the view-layer home for the rule, but the same visibility question appears elsewhere: a sitemap, an RSS feed, an export command, a search index. To keep one definition, move the filter onto the model's QuerySet and call it from the mixin: - Define a custom QuerySet method, for example `visible_to(user)`, and expose it through the model's manager. - Make the mixin's `get_queryset()` return `super().get_queryset().visible_to(self.request.user)`. - Use the same method in feeds and commands, so a change to the rule, such as a new "under offer" status, lands everywhere at once. The view mixin then stays thin, and the rule is testable without HTTP at all. ## Proving it with a test - Create two agencies, each with a draft listing. - Log in as an agent of the first agency and request the second agency's draft detail URL: expect **404**. - Request the first agency's own draft: expect **200**. - Request the index as an anonymous visitor: expect only published listings. These tests pin the behaviour to the shared mixin, so a future view that forgets it fails in CI rather than in production.

  • Why put the scoping in get_queryset() rather than in get_object()?
    `get_queryset()` is the single source both `ListView` and `DetailView` read, and the editing generics find their object through it as well. Scoping there covers every view that shares the mixin, and `get_object()`'s existing `.get()` turns an out-of-scope id into a 404 for free. A check inside one `get_object()` override protects only that view.
  • A colleague adds a 'similar homes' block to the detail page with Listing.objects.filter(city=...). What is the risk?
    That query bypasses the scoped `get_queryset()`, so the public page can show other agencies' drafts in the block even though the main object is protected. Build it from `self.get_queryset()` (excluding `self.object`), or from a shared scoped manager method, so the same visibility rule applies.

saying these in an interview costs you the question

  • Believing filtering the ListView also protects the DetailView
  • Thinking query_pk_and_slug alone secures unpublished listings
  • Overriding get_object() with Listing.objects.get() and skipping get_queryset()
  • Returning 403 for hidden drafts without considering that it confirms they exist
  • Adding a separate check in each view instead of one shared scoped get_queryset()