Public S3 Buckets: AI Code's Most Common Storage Mistake
AI tools generate S3 storage that's world-readable by default, in the Terraform that creates the bucket and the Go handler that writes to it. The exploit is one GET. Here's why the model ships it public and the fix across both files.
We audit AI-generated codebases, and storage is where the worst findings hide, because nobody looks at it. Ask any current model to set up file uploads on S3 and you get two files that agree with each other: Terraform that opens the bucket to the public internet, and a Go handler that writes every object with a public-read ACL and a key the client chose. We call it the public-bucket default. The uploaded passports, invoices, and signed contracts sit at URLs anyone can guess and fetch with no credentials. This isn’t an AWS problem. Since 2023 AWS blocks public access on new buckets by default; the model writes the code that turns that protection back off, because the examples it learned from predate the change. The fix touches both files.
The bucket is public and the keys are guessable
Two things have to be true for an attacker to read another user’s upload, and the model ships both. The bucket answers anonymous requests, and the object keys are predictable enough to guess. The Terraform does the first. The Go handler does the second by letting the client name the file.
The Terraform the model writes
Here is what you get for “Terraform to host user uploads on S3.” The variable names change between tools; the shape does not.
resource "aws_s3_bucket" "uploads" {
bucket = "acme-user-uploads"
}
# Re-enables object ACLs, which AWS disables by default since April 2023.
resource "aws_s3_bucket_ownership_controls" "uploads" {
bucket = aws_s3_bucket.uploads.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
# Turns off Block Public Access, which AWS enables by default since April 2023.
resource "aws_s3_bucket_public_access_block" "uploads" {
bucket = aws_s3_bucket.uploads.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
# Grants every anonymous caller read on every object.
resource "aws_s3_bucket_policy" "uploads" {
bucket = aws_s3_bucket.uploads.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.uploads.arn}/*"
}]
})
}
Read the comments back as a sequence and the problem is obvious. AWS ships three locks. This config undoes all three: it re-enables ACLs, disables Block Public Access, then attaches a policy that grants s3:GetObject to Principal = "*". The Sid is the giveaway. PublicReadGetObject is the literal name AWS uses in its static-website-hosting tutorial, and the model has read that tutorial ten thousand times. It cannot tell the difference between a public bucket of CSS files and a private bucket of user documents, so it reaches for the recipe it has seen most.
The Go handler that matches it
The upload handler trusts the bucket to be public and trusts the client to name the file:
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file", http.StatusBadRequest)
return
}
defer file.Close()
key := header.Filename // whatever the browser sent
_, err = s.s3.PutObject(r.Context(), &s3.PutObjectInput{
Bucket: aws.String("acme-user-uploads"),
Key: aws.String(key),
Body: file,
ACL: types.ObjectCannedACLPublicRead,
})
if err != nil {
http.Error(w, "upload failed", http.StatusInternalServerError)
return
}
url := fmt.Sprintf("https://acme-user-uploads.s3.amazonaws.com/%s", key)
fmt.Fprintf(w, `{"url":%q}`, url)
}
Three decisions, all wrong:
key := header.Filename. The client picks the object key. Two users who both uploadinvoice.pdfcollide, and an attacker can overwrite your file by uploading with your key.ACL: public-read. Even if the bucket policy were tighter, each object is stamped world-readable on the way in.- The response hands back the public URL, which tells the caller exactly what the key namespace looks like.
Exploiting it is a GET
You upload one file through your own account. The response tells you the URL shape:
https://acme-user-uploads.s3.amazonaws.com/invoice.pdf
The key is just a filename. So you guess other filenames:
curl https://acme-user-uploads.s3.amazonaws.com/passport.jpg
curl https://acme-user-uploads.s3.amazonaws.com/contract-acme.pdf
curl https://acme-user-uploads.s3.amazonaws.com/resume.pdf
No signature, no token, no session. Each one that exists comes back 200 with the file body. If the generated policy granted s3:ListBucket as well, which the “give my app full access to the bucket” prompt often produces, you skip the guessing entirely:
aws s3 ls s3://acme-user-uploads --recursive --no-sign-request
That command needs no AWS account. --no-sign-request means anonymous. It prints the whole index of every file every user ever uploaded.
The fix starts in Terraform
Stop undoing the defaults, and go one step further: make object ACLs impossible, so the old handler’s public-read call fails loudly instead of silently exposing a file.
resource "aws_s3_bucket" "uploads" {
bucket = "acme-user-uploads"
}
# BucketOwnerEnforced disables ACLs entirely. The PutObject ACL call
# in the old handler now returns an error instead of a public object.
resource "aws_s3_bucket_ownership_controls" "uploads" {
bucket = aws_s3_bucket.uploads.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
# All four on. This is the default; the only correct change is to leave it.
resource "aws_s3_bucket_public_access_block" "uploads" {
bucket = aws_s3_bucket.uploads.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "uploads" {
bucket = aws_s3_bucket.uploads.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_versioning" "uploads" {
bucket = aws_s3_bucket.uploads.id
versioning_configuration {
status = "Enabled"
}
}
There is no aws_s3_bucket_policy here. The bucket is private. Nothing anonymous reads from it. BucketOwnerEnforced is the load-bearing line: it turns the old handler’s silent mistake into a deploy-time error, which is the only kind of mistake you can trust yourself to notice. Versioning gives you a way back when someone overwrites an object, and KMS encryption is one block of config you will wish you had during an incident review.
The Go handler stops trusting the client
The server owns the key. The server owns identity. The object goes in private, and reads go through a short-lived URL that gets minted only after an ownership check.
import "crypto/rand"
func newKey(userID string) string {
// rand.Text returns a fresh, unguessable token (Go 1.24+).
return fmt.Sprintf("uploads/%s/%s", userID, rand.Text())
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
userID := s.userFromContext(r) // server-side identity, never the body
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file", http.StatusBadRequest)
return
}
defer file.Close()
key := newKey(userID) // the server names the file, not the client
_, err = s.s3.PutObject(r.Context(), &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: file,
// no ACL: the bucket rejects them, objects are private
})
if err != nil {
http.Error(w, "upload failed", http.StatusInternalServerError)
return
}
if err := s.store.SaveUpload(r.Context(), userID, key); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, `{"key":%q}`, key)
}
Reads are where the trust boundary actually lives. A valid object key is not permission to read it. Check ownership first, then mint a URL that expires:
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
userID := s.userFromContext(r)
key := r.PathValue("key")
// The key is not authorization. Look up who owns it.
owner, err := s.store.OwnerOf(r.Context(), key)
if err != nil || owner != userID {
http.Error(w, "not found", http.StatusNotFound)
return
}
ps := s3.NewPresignClient(s.s3)
req, err := ps.PresignGetObject(r.Context(), &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(5*time.Minute))
if err != nil {
http.Error(w, "error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, req.URL, http.StatusFound)
}
Return 404, not 403, when the owner does not match. A 403 confirms the key exists, which hands an attacker an enumeration oracle for free. This is the same defect we keep finding in different places: the unsigned-webhook default, RLS shipped disabled, and the page-vs-API split are all the same mistake. A trust boundary drawn one layer away from where the data actually lives.
The negative tests AI doesn’t write
Three checks. Run them against the deployed bucket and the deployed API:
# 1. A direct object URL must be denied now.
curl -s -o /dev/null -w "%{http_code}\n" \
https://acme-user-uploads.s3.amazonaws.com/uploads/some/key
# Expect: 403
# 2. Anonymous listing must be denied.
aws s3 ls s3://acme-user-uploads --no-sign-request
# Expect: AccessDenied
# 3. Another user's key, requested through your own API, must 404.
curl -H "Authorization: Bearer <user-B-token>" \
https://api.acme.com/files/uploads/<user-A-key>
# Expect: 404
If the first two return anything other than denied, your Terraform still has a public path. If the third returns the file, your ownership check is missing and the bucket being private bought you nothing.
What this fix doesn’t cover
A presigned URL is a bearer token for as long as it lives. Keep the TTL short, and never log the full URL, or you have moved the leak from S3 into your log aggregator. Signing reads also says nothing about what got uploaded: file-type validation, size limits, and malware scanning are separate problems this fix does not touch. And if you are on Supabase Storage rather than raw S3, the same default is waiting for you under a different name. A bucket toggled “public” there is s3:GetObject to * with a friendlier UI. The lesson ports directly.
The lesson
Public is not a property of a file. It is a decision, and the model made it for you, because a world-readable bucket is the shortest path from prompt to working upload. Flip the default. Nothing in object storage should be reachable unless your own code authorizes that exact read, for that user, on every request. The only question worth asking of a bucket is whether someone who knows the URL can pull the file behind it. If the answer is yes, nothing else you configured about that bucket matters.