Responsible disclosure: pulling 175,000+ event photos without logging in

I came across an event photography platform that sells "facial search" for race photos, upload a selfie, it finds you in the crowd across thousands of event photos. Reasonable product. The problem was the API behind it didn't actually enforce any of the gating the UI implied.

The company has since fixed everything below and asked that I not name them or the events involved, which I'm respecting. Domains, UUIDs, and identifiers here are placeholders. The bugs and the technique are real.

What the UI implied vs what the API allowed

Album and event metadata in the API responses included flags like can_user_view_all_images_album_wide: false and a facial_search gate. The frontend respected these, you couldn't browse an album freely without a face match. Fair enough.

Except none of that was enforced server-side. The endpoints returning full album contents, including time-limited direct-download URLs for original-resolution images, required no Authorization header at all.

# No auth header. Returns 200 with signed original-resolution URLs.
curl -s "https://api.example-events.com/api/v1/events/images-list/?album_uuid=<album_uuid>&format=json&page=1&page_size=2"

Each result carried a presigned S3 URL to the full-resolution original, a presigned thumbnail URL, and internal IDs. The can_user_view_all_images_album_wide flag was just data in the payload, nothing downstream read it.

Finding every album for an event

Album UUIDs turned out not to be hard to get, there was a second unauthenticated endpoint that listed every album for a given event UUID, and event UUIDs are public (they're in the event's own URL):

curl -s "https://api.example-events.com/api/v1/events/albums-list/?event_uuid=<event_uuid>&page=1&page_size=2"
# 200 - album id, title, uuid, image counts, facial_search flags

So the full chain was: public event page → event UUID → every album UUID for that event → every image and its presigned download URL. No login, no payment, no face match, at any step.

Pagination didn't help either

The web client requested page_size=20. The API didn't clamp that, it happily returned page_size=2029 or page_size=10000 in a single response. One album with just over 2,000 images came back in one request instead of ~100 paginated ones. Combined with no rate limiting (ten consecutive requests in under three seconds, all 200, no throttling headers), scripting a full export of an event's photo library was trivial rather than merely possible.

During testing against two public, already-published events, this chain retrieved metadata (including download URLs) for roughly 175,000 images total — about 116,000 from one event and 61,000 from another, across close to 60 albums, using nothing but the public event UUIDs.

Everything else that fell out of the same recon

A handful of smaller issues, roughly in severity order:

  • Inconsistent auth model. Some v2 endpoints correctly required a bearer token, but the highest-value asset (the raw photos, on v1) needed nothing.
  • OTP endpoint leaked an internal user_id in the send-OTP response, and rate limiting on OTP verification wasn't tested but looked thin enough to be a plausible enumeration/brute-force surface.
  • A payment status endpoint returned a generic 404 rather than 401 for unauthenticated requests against sampled order IDs, not confirmed exploitable, but the wrong error code for the wrong reason is usually a smell.
  • Predictable S3 key layout ({event_uuid}/{album_id}/{filename}), only a real problem if presigning or bucket policy ever slips, but combined with the above the filenames were already fully disclosed anyway.
  • A dev/staging host had DEBUG=True exposed publicly, framework version, stack paths, and a Django debug toolbar mounted at a guessable path.
  • Organizer-facing dashboards had auth that was similarly easy to bypass, viewing albums and event data meant for organizers without proper session validation. I didn't test write actions there.

None of these individually is exotic. What made it bad was the combination: public event UUID → unauthenticated album list → unauthenticated image list with no page-size cap and no rate limit → complete photo library, for a product whose entire pitch was "we gate access to your photos behind a face match."

Why this mattered

This is a facial-search product handling identifiable images of people at public events. The stated access model (view-all flags, facial-search gating) implied a level of control that the server never actually enforced. That's a gap between what the product promises participants and organizers, and what the backend does, which is a worse problem than a single leaky endpoint because fixing one route doesn't fix the model.

Disclosure and fix

Reported everything above directly to the team. They took it seriously and shipped fixes within days of receiving the full write-up, server-side enforcement of the view-all/facial-search flags, a hard cap on page_size, and rate limiting on the bulk-listing endpoints. I re-checked the original reproduction steps afterward and they no longer worked.

If you're building anything that returns "restricted" data with a flag in the JSON payload: that flag has to be read by the server before it decides what to send back, not just by whichever client happens to be well-behaved that day.