Web Development Bug Report

IDOR in a Django invoice download endpoint let users fetch other customers' PDFs

A customer changed the number in an invoice download URL and got someone else's PDF. The endpoint looked up invoices by sequential ID with no ownership check. We scoped every query to the account, added an object policy and cross-tenant tests.

Daniel Tran Daniel Tran @fun_dev · November 18, 2025 · 8 min read · 16 Views
Banner: IDOR in a Django invoice download endpoint exposing other customers' PDFs

A customer of our subscription billing product wrote to support with a screenshot and a polite but alarming message: they had been downloading invoices for their bookkeeper, noticed the URL ended in a number, typed the next number out of curiosity, and received a PDF for a different company. They deleted the file and told us. We opened a security incident within the hour.

The URL pattern was:

GET /api/v2/invoices/48213/pdf/   -> 200 application/pdf  (their invoice)
GET /api/v2/invoices/48214/pdf/   -> 200 application/pdf  (another customer's invoice)

The JSON detail endpoint GET /api/v2/invoices/48214/ correctly returned 404 for the same user. Only the PDF action leaked.

Reproducing it

On staging with two test accounts in different organisations:

  1. Log in as alice@acme.example and list invoices; note one ID.
  2. Log in as bob@globex.example in a private window.
  3. Request /api/v2/invoices/<alice-id>/pdf/ as Bob. It returned 200 with Alice's PDF.

What we ruled out

  • A caching layer serving the wrong user's file. The responses carried Cache-Control: private, no-store, the CDN logged MISS, and the effect was perfectly repeatable by ID.
  • A broken permission class globally. Other actions on the same viewset returned 404 across tenants, so the default queryset filtering worked.
  • Signed URL leakage. PDFs were generated on the fly and streamed; there were no storage URLs involved.

Root cause

The viewset scoped its queryset to the user's organisation in get_queryset(), which protected the standard retrieve, update and destroy actions. The pdf action, added later as a custom @action, fetched the invoice directly from the model manager instead of calling self.get_object():

@action(detail=True, methods=["get"])
def pdf(self, request, pk=None):
    invoice = Invoice.objects.get(pk=pk)  # not scoped, no object permission check
    return render_invoice_pdf(invoice)

That single line bypassed both the scoped queryset and DRF's object permission checks. Because IDs were sequential integers, every invoice in the database was one increment away.

What is happening under the hood

An insecure direct object reference happens when the server uses an identifier supplied by the client to fetch an object and never asks "is this caller allowed to see this object?". It is the textbook example of broken access control, the first category in the OWASP Top 10:2025.

In Django REST framework, GenericAPIView.get_object() does two things: it looks the object up in self.get_queryset() (which we had scoped) and it calls check_object_permissions(), which runs every permission class's has_object_permission(). Code that queries the model directly gets neither. This is easy to miss in review because the custom action looks just like its neighbours.

Sequential IDs did not cause the bug, but they made it trivially exploitable and made the scale of exposure large: anyone could walk the entire ID range.

Environment

Component Version / value
OS Ubuntu 24.04 LTS containers
Runtime Python 3.13.7
Framework Django 5.2 LTS
API Django REST framework 3.16
Database PostgreSQL 17
PDF generation WeasyPrint, rendered per request

Minimal reproduction

class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = InvoiceSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        return Invoice.objects.filter(organization=self.request.user.organization)

    @action(detail=True, methods=["get"])
    def pdf(self, request, pk=None):
        invoice = Invoice.objects.get(pk=pk)  # BROKEN
        return render_invoice_pdf(invoice)

Who is affected

Any API where custom endpoints, background exports, or "download" and "preview" routes fetch objects by ID outside the framework's scoped lookup. It is common in Django, Rails (Invoice.find(params[:id]) instead of current_account.invoices.find(...)), Laravel (Invoice::findOrFail($id) without a policy) and Express handlers.

The fix

1. Use the scoped lookup everywhere. The action now goes through get_object():

  @action(detail=True, methods=["get"])
  def pdf(self, request, pk=None):
-     invoice = Invoice.objects.get(pk=pk)
+     invoice = self.get_object()
      return render_invoice_pdf(invoice)

2. Add an explicit object permission. Scoped querysets are our main control, but we added an object-level permission so that a future direct lookup that does call check_object_permissions still fails closed:

class BelongsToUserOrganization(BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.organization_id == request.user.organization_id
permission_classes = [IsAuthenticated, BelongsToUserOrganization]

3. Make unscoped access awkward. We added a manager method that requires an organisation, and made code review flag Invoice.objects.get( in views:

class InvoiceQuerySet(models.QuerySet):
    def for_org(self, org):
        return self.filter(organization=org)

class Invoice(models.Model):
    objects = InvoiceQuerySet.as_manager()
    public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    ...

4. Stop exposing sequential IDs. URLs now use public_id, a random UUID. This is not the fix; it is a speed bump that stops ID walking if another authorization bug appears. We kept the integer primary key internally.

class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
    lookup_field = "public_id"

Alternatives and trade-offs

  • UUIDs alone. Tempting, and often proposed as "the fix". They are not authorization: UUIDs appear in emails, logs, browser history and shared screenshots. A leaked UUID with no ownership check is still a breach.
  • Row-level security in PostgreSQL. Policies keyed on a session variable for the organisation give a strong backstop. We are piloting it for invoices, but it needs careful handling of connection pooling and admin tooling, so it is not our first line.
  • Signed, expiring download links. Good for emailing invoices; they still need the ownership check at the moment of signing.

How to test the fix

We wrote a test that enumerates every route in the viewset with a detail lookup and checks it returns 404 for a user from another organisation. New actions are covered automatically:

import pytest
from django.urls import reverse

DETAIL_ROUTES = ["invoice-detail", "invoice-pdf"]

@pytest.mark.django_db
@pytest.mark.parametrize("route", DETAIL_ROUTES)
def test_cross_tenant_access_is_404(api_client, invoice_factory, user_factory, route):
    invoice = invoice_factory(organization__name="Acme")
    outsider = user_factory(organization__name="Globex")
    api_client.force_authenticate(outsider)

    r = api_client.get(reverse(route, kwargs={"public_id": invoice.public_id}))
    assert r.status_code == 404
pytest tests/api/test_invoice_access.py -q

We also have a meta-test that fails if a viewset defines an @action(detail=True) that is not listed in a cross-tenant test module.

Confirming in production

  • The customer's original URL now returns 404 for their account.
  • Structured logs record user_id, organization_id and the resolved invoice's organization_id for each PDF render. We used the historic version of that log (it already had user and invoice IDs) to check the past 400 days for mismatches.
SELECT l.user_id, count(*) AS foreign_downloads
FROM pdf_download_log l
JOIN invoices i ON i.id = l.invoice_id
JOIN users u ON u.id = l.user_id
WHERE i.organization_id <> u.organization_id
GROUP BY l.user_id;
  • The query returned two users: the reporting customer (3 downloads) and one internal support account (impersonation, legitimate). We notified the three affected organisations as our breach procedure and local law required.

Regression guard

  • The cross-tenant test and the @action meta-test run in CI.
  • A Semgrep rule flags .objects.get(pk= and .objects.filter(pk= inside views.py and api/.
  • Our pull request template asks "Does this endpoint load an object by an ID from the request? Which test proves another tenant cannot?"

Security implications

This was an access control breach affecting invoice PDFs (company names, addresses, line items and amounts, no payment card data). The fix restores authorization on the server; UUIDs and logging reduce future blast radius and improve detection. The investigation query depends on logs that include both identities, which is now a standard for every download endpoint.

Prevention checklist

  • Load objects only through scoped queries or the framework's checked lookup.
  • Treat every custom action, export and download as a new endpoint that needs its own authz test.
  • Test cross-tenant access with two real accounts, automatically, for every detail route.
  • Use non-sequential public IDs as defence in depth, never as the control.
  • Log actor and object owner together so you can answer "who saw what" after the fact.

References

Add a label

#django #authorization #idor #django-rest-framework #access-control
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.