We spent 7 September reading our own registry's read paths, and found four ways to see things we should not have been able to see. They were all fixed the same day. None of them was a clever attack; each was a route that had been written with the right intentions and never asked who was calling it.
The package README, and the cache that made it worse
The /agent/* surface — what our SDK, MCP server and CLI read — was mounted with a rate limiter and no authorization at all, and never consulted package visibility. GET /agent/:pkg returned the full README of a private package. Search walked the complete package list. Worst of the three, the recommendations endpoint, which is computed from shared ownership, named precisely the private packages of whoever owned the public one. That is a leak that gets more specific the more you know: one public package name is enough to enumerate its owner's private ones.
The fix had one non-obvious part. The authorization gate had to move ahead of the response cache, because that cache is keyed by package name alone. Returning an authorized caller's response into a key that the next anonymous caller can read is not a performance optimization, it is a leak with a TTL.
The user profile, built from the owner's own view
GET /api/v1/users/:username/profile had no auth either, and it assembled its list from the "packages by user" query — which is the query the owner uses, and which includes private packages by design. The reproduction is one line: the response lists a private package alongside a public one.
The interesting part is that the underlying query was not wrong. It was correct for its original caller, and it had been reused for a public endpoint without anyone re-examining what it filtered.
Two admin handlers, and why refusals answer 404
Three handlers on the admin surface — package detail, readme, owners — were reachable by any logged-in session, so any account could read another account's private version list, dist-tags, README and owner list.
These now answer 404 rather than 403 when the caller is not entitled. That is deliberate: a 403 tells the caller the package exists and they merely lack access, which turns the endpoint into a way to test whether a private name is taken. "Not found" and "not yours" have to be indistinguishable from outside.
The audit log, and the query nobody called
The audit feed returned the whole registry's event log to any session. The fix was almost embarrassing in how little it required: a correctly scoped query, ListAuditEventsByActor, already existed in the codebase. Nothing imported it.
That is the one I would put in front of anyone building an authz layer. The safe path was written, reviewed, and merged — and never wired. A capability that exists but has no callers is not a control; it is documentation.
What we took from it
Three things, and we have written them into how we review routes.
The read path is where this lives. Every one of these four was a GET. We had been reviewing writes carefully — validation, ownership, tenancy — and treating reads as harmless, which is exactly backwards: a read leaks data and a write leaks a error message.
An endpoint's filter has to match its audience, not its origin. Two of the four were reuses of an internal query, correct where it was written and wrong where it was moved.
And the shape of the refusal is part of the security property. Equality between "does not exist" and "not yours" is a feature, and it belongs in a test.
