Your S3 bucket says "public." Did you mean that?
"Public" on a bucket means publicly listable, file by file.
There's a difference between "this one file is public" and "this whole bucket is public," and the AWS console doesn't always make it feel as sharp as it is. The first means a stranger who knows an object's URL can fetch it. The second means a stranger who knows nothing can ask the bucket for a list of everything in it and then fetch each item by name. People reach for the second when they only wanted the first.
List versus get, and why it matters
S3 has two separate permissions that both feel like "public." s3:GetObject lets someone download an object if they have its exact key. s3:ListBucket lets someone enumerate the keys in the first place. You can grant one without the other, and you usually want to: serve files by their known URL, but never let anyone list what's there.
When ListBucket is open to everyone, the bucket's contents stop being secret-by-obscurity. A request to the bucket endpoint with no credentials returns an XML document listing every object key. The attacker reads the keys, then downloads whatever looks valuable. Obscure file paths were your only protection, and listing removes it.
How a bucket ends up listable
The usual route is setting up static hosting or a public CDN origin and reaching for the broadest setting that makes the error go away. You're getting 403s while wiring up a site, you flip the bucket to allow public access and attach a policy with "Action": "s3:*" or a ListBucket grant to Principal: "*", the site loads, and you move on. The grant that fixed your asset loading also opened enumeration of the entire bucket.
The other route is mixing concerns in one bucket. The same bucket holds your public site assets and your private exports because it was convenient. A policy broad enough for the assets now covers the exports too. AWS will warn you in the console with a "Publicly accessible" badge, but the warning competes with a hundred other things and it's easy to read "public" as "the part I meant to be public."
GCS has the same shape. allUsers with storage.objects.list on a bucket gives anyone the object listing. The console wording differs; the exposure is identical.
What's at stake
The thing about a listable bucket is the same thing about a leftover database dump: there's no second step. Nobody has to break in. The listing is the feature working exactly as configured, the download is GetObject working exactly as configured, and your logs show ordinary requests the whole time.
What gets taken depends on what you stored. User uploads with personal data. Internal reports and exports. Database backups people park in object storage, which means the bucket leak becomes the full-data leak. Source archives. Anything a deploy script wrote there and forgot. Because the listing reveals every key at once, the attacker doesn't even have to be patient; they pull the whole inventory in one request and pick through it offline.
Checking it from outside
You can test list access without any credentials, which is the whole point, because that's what an anonymous attacker does. For an S3 bucket, request the bucket endpoint directly: https://your-bucket-name.s3.amazonaws.com/. If it returns an AccessDenied XML error, listing is off, good. If it returns a ListBucketResult document full of <Key> entries, anyone can enumerate it.
The fix is to separate the two permissions and stop granting ListBucket to the world. Keep GetObject public only on the prefix that genuinely needs it.
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::your-app-prod",
"arn:aws:s3:::your-app-prod/*"
]
}Better yet, don't keep public assets and private data in the same bucket. Put exports and backups in a bucket with "Block all public access" left fully on, and serve public files from a separate one or through signed URLs.
Whether your bucket hands its inventory to a stranger is one anonymous request away from an answer, and that's the request SurfaceCheckr fires at the bucket endpoints tied to your domain. It reads the response from outside, the same ListBucketResult an attacker would get, and tells you if the keys came back. No AWS credentials, no reading your objects, no touching the account. We check the one thing a stranger checks first: can the bucket be enumerated at all. That's a passive external test, not a review of your IAM policy, and it's exactly the view from the curb that matters when someone goes looking.
Find it before someone else does.
Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.