DEVLOPN Audit

The "read-only" GCS role that exposes your full file listing

On a public bucket, objectViewer allows full anonymous LIST where legacyObjectReader blocks it. One permission makes the difference. Tested with curl.

A "read-only" GCS role can expose your complete file listing anonymously. I tested it, GET and LIST side by side, and the most recent role is the chattiest one.

The chatty role: objectViewer exposes the full anonymous bucket LIST, legacyObjectReader blocks it

The test

A bucket serving public files by direct URL. Two ways to grant read to allUsers:

roles/storage.objectViewer, the modern role recommended everywhere:

GET  https://storage.googleapis.com/BUCKET/object.jpg  → 200
LIST https://storage.googleapis.com/storage/v1/b/BUCKET/o → 200, the full listing

roles/storage.legacyObjectReader, the "legacy" role the console discourages:

GET  https://storage.googleapis.com/BUCKET/object.jpg  → 200
LIST https://storage.googleapis.com/storage/v1/b/BUCKET/o → 403

Reproduce it yourself, no authentication:

curl -s "https://storage.googleapis.com/storage/v1/b/YOUR_BUCKET/o?maxResults=10"

If that command returns JSON with your object names, anyone on the Internet has the same view.

The difference: one permission

objectViewer contains storage.objects.list. legacyObjectReader only contains storage.objects.get. That is the whole story.

For internal use, listing is a convenience. Granted to allUsers, it is an enumeration capability: object keys often reveal user IDs, document names, dates, internal structure. If your URLs contain an "unguessable" UUID, URL obscurity may have been your only barrier: anonymous LIST removes it in one request.

The three reflexes

For public content served by direct URL: legacyObjectReader. The "legacy" prefix discourages, wrongly. It is exactly the role that grants GET without LIST. The most recent role is not the safest one; here it is the opposite.

For any bucket that must never be public: publicAccessPrevention: enforced. It is a deliberate lock: reversible by whoever has the rights (unless an inherited org policy says otherwise), but it turns a silent IAM mistake into a deliberate, audited action.

In the audit checklist: the anonymous curl. GET on a known object, LIST on the bucket, no credentials. Ten seconds, and it is the only test that reflects what a stranger actually sees, regardless of what the console shows.

Takeaways

  • objectViewer for allUsers = GET 200 and LIST 200: full bucket listing exposed.
  • legacyObjectReader = GET 200 and LIST 403: the only difference is storage.objects.list.
  • Public by direct URL: legacy. Never public: publicAccessPrevention as a deliberate lock. In both cases: test anonymously with curl.

Have you ever run the anonymous LIST against your own buckets?