← Attack pathsidor: one changed digit exposes someone else's invoice

idor: one changed digit exposes someone else's invoice

$inv = get_object_or_404(Invoice, id=request.GET['id'], user=request.user)

the invoice that wasn't mine

somebody built an app where invoices live at a url like /invoice?id=4471. i was logged in, looking at my own invoice, id 4471. i bumped it to 4472 out of curiosity, not malice, and there it was. someone else's name, address, card digits, total owed. no login prompt, no "not authorized" page, nothing. the server just handed it over because i asked politely with a number in the url.

that's an idor, insecure direct object reference. it's been sitting near the top of the owasp top ten for years because it's stupidly common and stupidly easy to cause. the app checks "are you logged in" but forgets to check "is this specific thing actually yours."

why this happens

most of the time it's not laziness, it's a mental shortcut. the developer writes code that looks totally reasonable at first glance:

inv = Invoice.objects.get(id=request.GET['id'])

this line grabs whatever id shows up in the url and fetches it. that's it. no ownership check, no ties back to who's logged in. the request is authenticated (you're logged in as *someone*) but not authorized (the app never confirms that "someone" is allowed to see *this* invoice). authentication and authorization are two different locks, and a lot of bugs live in the gap between them.

the fix, line by line

the real fix isn't a bandaid, it's baking ownership into the lookup itself:

inv = get_object_or_404(Invoice,
                        id=request.GET['id'],
                        user=request.user)

let's break that down:

get_object_or_404 tries to fetch a matching record, and if nothing matches, it returns a clean 404 instead of leaking hints through error messages.

id=request.GET['id'] is the same id lookup as before, still coming straight from user input, still untrusted.

user=request.user is the part that actually matters. it tells the database "only match this invoice if it also belongs to the person making this request." now the query itself enforces ownership. if someone tries invoice 4472 and it belongs to a different account, the query returns nothing and they get a 404, not someone else's card number.

the key idea: the permission check happens in the same query that fetches the data, not somewhere after. that ordering matters more than people think.

why "check it in the template" isn't good enough

a common half-fix looks like this: fetch the invoice by id, then in the view or template do something like "if invoice.user != request.user, hide the sensitive fields." the problem is the data already got pulled out of the database and into memory. it's now sitting in a variable, in a response object, maybe in a log line, maybe in an api response that some other endpoint reuses without the same template logic. every downstream piece of code has to remember to re-check ownership, and if even one forgets, the leak is back. scoping the query itself means the unauthorized data never gets fetched in the first place. it's not in memory to leak because it was never loaded.

write the test that catches it before your users do

this bug is boring to test for, which is exactly why it should be automated. create two accounts, create an invoice under each, then try to fetch account b's invoice while logged in as account a.

def test_cannot_view_other_users_invoice(self):
    self.client.login(username='alice', password='...')
    response = self.client.get(f'/invoice?id={bobs_invoice.id}')
    self.assertEqual(response.status_code, 404)

run that test against every endpoint that takes an id from a url, query string, or form field. any endpoint pulling a record by id is a candidate for this exact bug: invoices, orders, messages, profile pages, uploaded files, support tickets, all of it.

the takeaway

idor isn't exotic. it's one skipped condition in a database query, and it's the kind of bug that sits quietly until someone changes a digit in a url out of boredom. if you run or build anything that serves records by id, go audit every one of those lookups today. make sure the query itself is scoped to the logged in user, not a check bolted on afterward. then write a two account test for each endpoint so the next person who "improves" the code can't accidentally reopen the door. the fix costs one keyword. the leak costs a lot more.

watch the reel ↗
the weekly drop

one command a week that makes you harder to hack.

a single tool, explained in plain english, every week. straight to your inbox.

no spam. one email a week. unsubscribe anytime.