Read this article in English →
security · · Streatix

Öffentliche S3-Buckets: Der häufigste Security-Fail in KI-generiertem Code

KI-Tools konfigurieren S3-Buckets standardmäßig als öffentlich lesbar – im Terraform-Code ebenso wie im Go-Handler. Ein einziges GET reicht als Exploit. Hier erklären wir, warum KI-Modelle diese Lücke einbauen und wie man beide Dateien sicher abdichtet.

Bei unseren Audits von KI-generiertem Code finden wir die kritischsten Sicherheitslücken meist beim Storage – schlicht, weil dort niemand genau hinschaut. Lässt man ein beliebiges aktuelles KI-Modell einen File-Upload zu S3 implementieren, generiert es meist zwei Dateien, die Hand in Hand ins Verderben führen: Ein Terraform-Skript, das den Bucket für das öffentliche Internet aufreißt, und einen Go-Handler, der jedes Objekt mit einer public-read-ACL versieht und den Client auch noch den Dateinamen bestimmen lässt. Wir nennen das die Public-Bucket-Falle. Pässe, Rechnungen und Verträge landen so unter URLs, die sich leicht erraten und ohne Authentifizierung abrufen lassen. Das ist kein Bug auf AWS-Seite. Seit April 2023 blockiert AWS den öffentlichen Zugriff für neue Buckets standardmäßig; doch das Modell schreibt Code, der genau diesen Schutz wieder aushebelt. Der Grund: Die Trainingsdaten sind meist älter als dieses Update. Um das Problem zu beheben, müssen beide Dateien angefasst werden.

Der Bucket ist öffentlich und die Keys sind erratbar

Damit ein Angreifer fremde Uploads lesen kann, müssen zwei Bedingungen erfüllt sein – und die KI liefert beide frei Haus: Der Bucket beantwortet anonyme Anfragen, und die Object-Keys sind so vorhersehbar, dass man sie erraten kann. Der Terraform-Code sorgt für Ersteres. Der Go-Handler kümmert sich um Letzteres, indem er dem Client die Namensgebung überlässt.

Der generierte Terraform-Code

Das ist das typische Resultat für den Prompt „Terraform to host user uploads on S3“. Die Variablennamen variieren je nach Tool, die Struktur bleibt gleich.

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}/*"
    }]
  })
}

Liest man die Konfiguration der Reihe nach, wird das Problem offensichtlich. AWS bietet von Haus aus drei Sicherheitsmechanismen. Dieses Setup hebelt alle drei aus: Es reaktiviert ACLs, schaltet den „Block Public Access“-Schutz ab und hängt zu guter Letzt eine Policy an, die s3:GetObject für Principal = "*" freigibt. Die Sid ist hier der verräterische Hinweis. PublicReadGetObject ist exakt der Bezeichner, den AWS im Tutorial für das Hosting statischer Websites nutzt, und das KI-Modell hat dieses Tutorial in seinen Trainingsdaten zehntausendfach gesehen. Da es nicht zwischen einem öffentlichen Bucket für CSS-Assets und einem privaten Datengrab für sensible Nutzerdokumente unterscheiden kann, greift es blind zum häufigsten Muster.

Der exakt darauf abgestimmte Go-Handler

Der dazugehörige Upload-Handler verlässt sich darauf, dass der Bucket öffentlich ist – und vertraut dem Client bei der Dateibenennung voll und ganz:

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)
}

Drei fatale Fehlentscheidungen:

  1. key := header.Filename. Der Client bestimmt den Object-Key. Laden zwei Nutzer eine invoice.pdf hoch, kommt es zur Kollision. Schlimmer noch: Ein Angreifer kann fremde Dateien überschreiben, indem er den gleichen Dateinamen wählt.
  2. ACL: public-read. Selbst bei einer strengeren Bucket-Policy wird jedes Objekt schon beim Upload unwiderruflich als weltweit lesbar markiert.
  3. Das Ausliefern der URL. Die API-Antwort liefert dem Aufrufer direkt die öffentliche URL mit und verrät ihm damit exakt, wie der Key-Namespace aufgebaut ist.

Der Exploit: Ein simples GET

Man lädt über den eigenen Account eine Datei hoch. Die Response verrät das URL-Schema:

https://acme-user-uploads.s3.amazonaws.com/invoice.pdf

Der Key ist schlicht der Dateiname. Ein Angreifer fängt also an, weitere Namen zu erraten:

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

Ohne Signatur, Token oder Session: Jede existierende Datei wird mit einem 200 OK samt Inhalt ausgeliefert. Gewährt die generierte Policy zudem noch s3:ListBucket (ein häufiges Resultat von Prompts wie „give my app full access to the bucket“), entfällt sogar das Raten:

aws s3 ls s3://acme-user-uploads --recursive --no-sign-request

Für diesen Befehl ist nicht einmal ein AWS-Account nötig. --no-sign-request erzwingt eine anonyme Anfrage. Das Ergebnis ist ein kompletter Index aller Dateien, die jemals hochgeladen wurden.

Der Fix: Terraform härten

Schluss mit dem Überschreiben sicherer Defaults. Gehen wir stattdessen einen Schritt weiter: Wir machen Object-ACLs serverseitig unmöglich. Dadurch schlägt der public-read-Aufruf des alten Go-Handlers lautstark fehl, anstatt Dateien stillschweigend zu leaken.

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"
  }
}

Auffällig: Es gibt hier keine aws_s3_bucket_policy. Der Bucket bleibt strikt privat. Keine anonyme Anfrage wird zugelassen. BucketOwnerEnforced ist hier der wichtigste Hebel: Es verwandelt den lautlosen Logikfehler des alten Handlers in einen harten Fehler zur Deploy- oder Laufzeit – die einzige Fehlerart, auf die man sich verlassen kann. Das aktivierte Versioning dient als Rettungsanker bei überschriebenen Dateien, und die KMS-Verschlüsselung ist genau der Konfigurationsblock, über den man bei einem Post-Mortem-Review extrem froh sein wird.

Der Go-Handler: Zero Trust gegenüber dem Client

Der Server generiert den Key. Der Server prüft die Identität. Das Objekt wird privat abgelegt, und Lesezugriffe erfolgen ausschließlich über kurzlebige URLs, die erst nach einer strengen Berechtigungsprüfung ausgestellt werden.

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)
}

Beim Lesen verläuft die eigentliche Vertrauensgrenze (Trust Boundary). Die bloße Kenntnis eines Object-Keys ist keine Leseberechtigung. Erst muss die Eigentümerschaft validiert werden, bevor der Code eine temporäre URL ausstellt:

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)
}

Wichtig: Geben Sie ein 404 zurück, kein 403, falls der Nutzer nicht der Eigentümer ist. Ein 403 Forbidden bestätigt die Existenz des Keys und liefert Angreifern damit ein kostenloses Enumeration-Orakel. Das ist das grundlegende Muster, das wir bei Audits in verschiedensten Ausprägungen finden: Ungesicherte Webhooks als Standard, ausgeschaltete RLS in Produktion und fehlende Autorisierung auf API-Ebene beruhen alle auf demselben Denkfehler. Die Vertrauensgrenze wird auf der falschen Ebene gezogen – viel zu weit entfernt von den eigentlichen Daten.

Negativtests: Was die KI weglässt

Drei entscheidende Checks, die direkt gegen den deployten Bucket und die API gefahren werden sollten:

# 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

Wenn die ersten beiden Tests etwas anderes als „verweigert“ liefern, weist das Terraform-Setup noch Lücken auf. Wenn der dritte Test die Datei ausliefert, fehlt die Berechtigungsprüfung im Code – dann hilft auch der restriktivste private Bucket nichts.

Was dieser Fix nicht abdeckt

Eine Presigned-URL fungiert de facto als Bearer-Token, solange sie gültig ist. Daher muss die TTL kurz gehalten werden. Die vollständige URL darf niemals geloggt werden, andernfalls verlagert man das Datenleck lediglich von S3 in den Log-Aggregator. Das Signieren von Lesezugriffen löst zudem keine inhaltlichen Probleme: Dateityp-Validierung, Größenbeschränkungen und Malware-Scans sind separate Baustellen, die dieser Fix nicht anrührt. Nutzt man Supabase Storage statt nativem S3, tappt man im Übrigen in dieselbe Falle – nur unter anderem Namen. Ein Supabase-Bucket auf „Public“ ist letztlich nichts anderes als s3:GetObject für * mit einer hübscheren UI. Die Lektionen lassen sich 1:1 übertragen.

Die Lektion

„Öffentlich“ ist keine Eigenschaft einer Datei. Es ist eine bewusste architektonische Entscheidung. KI-Modelle nehmen uns diese Entscheidung ab, weil ein weltweit lesbarer Bucket der Weg des geringsten Widerstands vom Prompt zum funktionierenden Code ist. Drehen Sie diesen Standardgedanken um: Nichts im Object Storage darf von außen erreichbar sein, es sei denn, der eigene Backend-Code autorisiert exakt diesen Lesezugriff für diesen spezifischen Nutzer bei genau dieser Anfrage. Die einzige relevante Frage für einen S3-Bucket lautet: Kann jemand, der nur die URL kennt, die dahinterliegende Datei abrufen? Lautet die Antwort „Ja“, ist jede andere Security-Konfiguration an diesem Bucket ohnehin Makulatur.

Wenn Sie das für Ihre eigene App wollen

Dreißigminütiges Audit, kostenlos. Wir rufen dieselben Endpunkte per curl auf und lesen dieselben Dateien. Liste in Ihrem Postfach binnen achtundvierzig Stunden.

Kostenloses Audit buchen