Case Study · BOLA

The Print Button That Skipped the Bouncer

Aug 2026 · 12 min read · Imperonlabs Research

Real finding, fully masked. The client, the districts, and the identifiers have been rewritten. Everything below runs against app.example.com with placeholder session IDs. For the wider family of these bugs, the PayloadsAllTheThings IDOR notes are the map I keep open.

The best access control bugs are not the ones where the app forgot to lock the door. They are the ones where the app locked the front door beautifully, then left a side window open that leads to the same room. This is one of those.

I was testing a multi tenant education platform. Multi tenant means one application serves many separate organizations, in this case school districts, and the whole promise of the product is that District A can never see District B's data. That promise is the single most important security boundary in any SaaS like this, and it is exactly the boundary I spend my time trying to break.

I had a teacher account. In this platform a teacher is about as low as roles go. Scoped to one session, one slice of one district, allowed to see the students in front of them and nothing else. If any door in this app was going to stay shut for me, it should have been every door that led to another district.

The endpoint that did its job

The natural place to start is the endpoint that lists student action plans, the per student learning records that carry real personal data. It looked like this:

GET /api/admin/v1/student_action_plans?session_id=<mine> HTTP/2
Host: app.example.com
Cookie: <teacher session, scoped to ONE session>

With my own session ID, it returned my 24 records. Fine. Then I did the first thing you always do against a multi tenant endpoint: I asked it for someone else's data. I grabbed a foreign session ID belonging to a different district and sent it:

GET /api/admin/v1/student_action_plans?session_id=<foreign> HTTP/2

The response came back 200 OK with an empty list. That is the correct behavior, and it is worth pausing on. The server did not error, did not leak, did not 403 in a way that confirms the record exists. It resolved my identity, checked that the requested session was not in my scope, and quietly handed me nothing. Whoever built this endpoint understood tenant isolation and implemented it properly. A lot of testers see that empty array, tick "IDOR: not vulnerable," and move on.

The sibling that did not

Here is the habit that pays. When an endpoint enforces authorization correctly, do not just accept it and leave. Go look at its neighbors. Controllers rarely have one action. A resource that has an index almost always has siblings: a show, an export, an email, a download, and very often a print. Those extra actions get bolted on later, frequently by a different person under deadline, and they routinely inherit the route prefix without inheriting the authorization the base action so carefully applied.

So I looked for the print action, and there it was:

GET /api/admin/v1/student_action_plans/print?session_id=<foreign> HTTP/2
Host: app.example.com
Cookie: <same teacher session>
Accept: application/json

Same prefix. Same parameter. Same low privilege cookie that got an empty list a moment ago. The response:

HTTP/2 200 OK
content-type: application/json; charset=utf-8

[ ...132 student action plans from a district I have no access to... ]

One hundred and thirty two records from a foreign district, handed to a teacher scoped to a completely different one. The base endpoint filtered by my tenant. The /print sibling never applied the filter at all. It took the session_id from the query string, trusted it completely, and dumped the whole set.

Proving it was real data and not a mirage

A single 200 with a big array is not proof. Sometimes an endpoint echoes your own records in a confusing shape, or returns a canned sample, and you embarrass yourself reporting a leak that is not one. So I did three checks before I believed my own eyes.

First, counts. My own session returned 24 records. Two different foreign sessions returned 132 and 100. Three different, non overlapping totals. If the endpoint were echoing my data, every session ID would give me the same 24.

Second, content. The records carried a home_language field, and the distribution of languages in each district matched the real demographics of that district, not mine. That is not something an echo could fake.

Third, ground truth. I had a brief, authorized look through a super_admin view earlier, and the record counts from the leak matched what the admin view showed for those districts exactly. Same numbers, reached from an account that should have seen none of them.

Three disjoint datasets, correct counts, district specific content. This was genuine cross tenant data, not a reflection of my own.

Why this one is a five alarm fire

Two properties turn a single leaky endpoint into a platform wide breach.

The session IDs were enumerable. They were not long random tokens. They followed a predictable, structured format, which means an attacker does not need to find valid IDs one at a time. They can walk the space and pull every district in a loop.

And the actor was the lowest role on the platform. This was not an admin abusing power they were trusted with. A single teacher account, the kind handed out by the hundreds, could systematically harvest the student action plans of every organization on the system: curriculum levels, reading focus areas, strategy codes, home languages, and parent email delivery status. That is children's data and guardians' contact information, across every customer, reachable from the cheapest seat in the house.

I pulled exactly enough to prove the three checks above and stopped. You demonstrate the boundary is broken. You do not go collect the consequences.

The fix is boring, which is the point

There is nothing clever in the remediation, and that is what makes this bug frustrating for the team that shipped it. The base endpoint already had the correct code. The /print action simply needed to run the same check:

  • After resolving session_id, verify the session sits inside the caller's authorized scope before returning anything. If it does not, return [] or 403 or 404, whatever the base action returns. Consistency is the whole game.
  • Then audit every controller for the same shape. Any index that filters by tenant almost certainly has a print, export, email, report, or download sibling, and each of those is a candidate for the exact same omission. Fix them as a class, not one at a time, because they were created as a class.
  • Add a regression test that logs in as a scoped role, requests a foreign session_id on /print, and asserts an empty or denied response. The base endpoint probably has that test already. The sibling did not, which is precisely why it drifted.

The lesson I keep from it

Authorization is not a property of an endpoint. It is a property of every action on every endpoint, and the ones that leak are almost never the main one you would think to test. They are the quiet siblings: the export, the print, the "download as PDF" that someone added in a hurry three sprints after the secure version shipped.

When the front door checks your ID properly, do not walk away impressed. Walk around the building and try every window. One of them leads to the same room, and nobody remembered to lock it.