S3 in Spring Boot: Dateien sicher speichern und richtig berechtigen

1. Übersicht

Amazon S3 ist eine beliebte Wahl zum Speichern von Dateien wie Rechnungen, Profilfotos oder Anhänge. Das Hochladen dauert normalerweise nur ein paar Zeilen Code. Der schwierigere Teil ist die Berechtigung: Wer darf lesen, wer darf schreiben, und wie teilt man Dateien, ohne den Bucket öffentlich zu machen?

In diesem Artikel werden Sie AWS SDK for Java v2 in Spring Boot 3.x verwenden, um Dateien zu speichern und zu lesen. Anschließend konfigurieren Sie die Berechtigungen mit IAM Policy und Bucket Policy und verwenden dann presigned URLs zum zeitlich begrenzten Teilen von Dateien. Am Ende des Artikels finden Sie einen Test, der mit LocalStack ausgeführt wird.

2. Projektsetup

2.1. Dependencies

Sie sollten die BOM des AWS SDK verwenden, um sicherzustellen, dass die Module immer die gleiche Version haben:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>software.amazon.awssdk</groupId>
            <artifactId>bom</artifactId>
            <version>2.31.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>s3</artifactId>
    </dependency>
</dependencies>

Das Modul s3 enthält bereits sowohl S3Client als auch S3Presigner, daher benötigen Sie keine zusätzlichen Module.

2.2. Bean-Konfiguration

Deklarieren Sie Region und Bucket-Namen in application.properties:

app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod

Erstellen Sie anschließend zwei Beans S3Client und S3Presigner:

@Configuration
public class S3Config {

    @Bean
    S3Client s3Client(@Value("${app.s3.region}") String region) {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean
    S3Presigner s3Presigner(@Value("${app.s3.region}") String region) {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

Der obige Code deklariert keine Credentials. Das SDK verwendet DefaultCredentialsProvider und sucht nacheinander nach Credentials in Umgebungsvariablen, in der Datei ~/.aws/credentials und dann in der IAM-Rolle von EC2/ECS/EKS. In der Produktionsumgebung sollten Sie die Anwendung mit einer IAM-Rolle ausführen.

Hinweis: Hardcodieren Sie keinen Access Key in den Code oder in application.properties. Ein über Git geleckter Key ist eines der häufigsten AWS-Probleme.

3. Dateien speichern und laden

Das Beispiel in diesem Artikel ist ein Service zum Speichern von PDF-Rechnungen für Kunden. Jede Datei liegt unter einem Präfix basierend auf customerId. Diese Art der Schlüsselbenennung erleichtert die Überprüfung des Eigentums in Abschnitt 5.

@Service
public class InvoiceStorageService {

    private final S3Client s3;
    private final S3Presigner presigner;
    private final String bucket;

    public InvoiceStorageService(S3Client s3, S3Presigner presigner,
                                 @Value("${app.s3.bucket}") String bucket) {
        this.s3 = s3;
        this.presigner = presigner;
        this.bucket = bucket;
    }

    public String upload(long customerId, InputStream content, long size) {
        String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

        PutObjectRequest request = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .contentType("application/pdf")
                .serverSideEncryption(ServerSideEncryption.AES256)
                .build();

        s3.putObject(request, RequestBody.fromInputStream(content, size));
        return key;
    }

    public byte[] download(String key) {
        return s3.getObjectAsBytes(b -> b.bucket(bucket).key(key)).asByteArray();
    }

    public void delete(String key) {
        s3.deleteObject(b -> b.bucket(bucket).key(key));
    }
}

Die wichtigsten Punkte im Code:

  • Der Schlüssel hat die Form invoices/42/7f3c...pdf. Der UUID-Teil vermeidet Namenskollisionen. Sie verschieben auch nicht den Dateinamen des Benutzers in den Schlüssel, um merkwürdige Zeichen und Path-Traversal zu vermeiden.
  • contentType wird zusammen mit dem Object gespeichert. Wenn der Browser die Datei über eine vorsignierte URL herunterlädt, wird das PDF korrekt angezeigt.
  • serverSideEncryption(AES256) fordert S3 auf, die Datei beim Speichern zu verschlüsseln (SSE-S3). S3 hat diesen Modus standardmäßig aktiviert. Es ist dennoch sinnvoll, es explizit in der Anfrage anzugeben, wenn die Bucket-Policy einen Verschlüsselungs-Header erzwingt.
  • RequestBody.fromInputStream muss die Größe size vorher kennen. Wenn Sie MultipartFile erhalten, übergeben Sie file.getInputStream() und file.getSize().
  • getObjectAsBytes liest die gesamte Datei in den Speicher. Dieses Verfahren ist nur für kleine Dateien geeignet.

Hinweis: Bei großen Dateien (Hunderte MB) verwenden Sie nicht getObjectAsBytes. Verwenden Sie stattdessen s3.getObject(...), um ResponseInputStream zu erhalten und direkt in die Response zu streamen. Eine Alternative ist, den Client direkt über eine vorsignierte URL zu laden (Abschnitt 5).

4. S3-Berechtigungsmodell

Ein Request an S3 wird nur akzeptiert, wenn er mehrere Kontrollebenen passiert:

  1. Block Public Access: Schalter auf Account- und Bucket-Ebene. Wenn aktiviert, werden alle Policies und ACLs, die öffentliche Berechtigungen gewähren, unwirksam.
  2. IAM Policy: an Benutzer oder Rolle angehängt, beschreibt, was diese Identität tun darf.
  3. Bucket Policy: an den Bucket angehängt, beschreibt, wer was mit dem Bucket tun darf.
  4. ACL: älterer Mechanismus. Neue Buckets verwenden standardmäßig Object Ownership BucketOwnerEnforced, d. h. ACLs sind deaktiviert.

Die Evaluierungsregel ist sehr einfach: ein explizites Deny an beliebiger Stelle gewinnt immer. Wenn es kein Deny gibt, benötigt der Request mindestens ein passendes Allow.

Empfehlungen für die meisten Anwendungen:

  • Halten Sie Block Public Access in allen vier Optionen aktiviert.
  • Halten Sie ACLs deaktiviert (BucketOwnerEnforced).
  • Gewähren Sie der Anwendung Berechtigungen über IAM Role.
  • Verwenden Sie Bucket Policy für allgemeine Beschränkungen, z. B. erzwungenes TLS.

4.1. IAM Policy nach dem Prinzip der minimalen Berechtigung

Die Role der Anwendung muss nur Objects unter dem Präfix invoices/ lesen, schreiben und löschen:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvoiceObjects",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::nonla-invoices-prod/invoices/*"
    },
    {
      "Sid": "ListInvoicePrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::nonla-invoices-prod",
      "Condition": {
        "StringLike": { "s3:prefix": "invoices/*" }
      }
    }
  ]
}

Beachten Sie die zwei verschiedenen ARN-Formate:

  • Operationen auf Objects verwenden ARNs mit /* am Ende.
  • s3:ListBucket ist eine Operation auf dem Bucket und sollte daher einen ARN ohne /* verwenden.

Das Verwechseln dieser beiden ARN-Formate ist die häufigste Ursache für AccessDenied-Fehler. Ein weiteres Detail: Fehlt s3:ListBucket, gibt S3 403 zurück, wenn das Object nicht existiert, anstatt 404. Dies ist ein bewusstes S3-Mechanismus, um nicht offenzulegen, ob ein Schlüssel existiert.

4.2. Bucket Policy zur erzwungenen Nutzung von TLS

Bucket Policies sind gut geeignet, um Regeln festzulegen, die für alle Aufrufer gelten, einschließlich Administratoren:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::nonla-invoices-prod",
        "arn:aws:s3:::nonla-invoices-prod/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

Diese Statement ist ein Deny, daher werden alle HTTP-Requests normalerweise blockiert. Keine andere Allow kann sie überschreiben. SDK v2 verwendet standardmäßig bereits HTTPS, daher ist Ihre Anwendung nicht betroffen.

5. Dateien mit vorsignierter URL teilen

Kunden müssen Rechnungen herunterladen, aber Sie möchten den Bucket nicht öffentlich machen oder alle Dateien durch den Server streamen. Vorsignierte URLs lösen genau dieses Problem. Dies ist eine URL, die mit den Credentials der Anwendung signiert wurde und nur in einem kurzen Zeitfenster verwendet werden kann.

Fügen Sie zu InvoiceStorageService die folgenden zwei Methoden hinzu:

public URL presignDownload(long customerId, String key, Duration ttl) {
    if (!key.startsWith("invoices/%d/".formatted(customerId))) {
        throw new ForbiddenFileAccessException(customerId, key);
    }

    GetObjectPresignRequest request = GetObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .getObjectRequest(b -> b.bucket(bucket).key(key))
            .build();

    return presigner.presignGetObject(request).url();
}

public URL presignUpload(long customerId, Duration ttl) {
    String key = "invoices/%d/%s.pdf".formatted(customerId, UUID.randomUUID());

    PutObjectPresignRequest request = PutObjectPresignRequest.builder()
            .signatureDuration(ttl)
            .putObjectRequest(b -> b.bucket(bucket).key(key).contentType("application/pdf"))
            .build();

    return presigner.presignPutObject(request).url();
}

Eine einfache Exception-Klasse:

public class ForbiddenFileAccessException extends RuntimeException {
    public ForbiddenFileAccessException(long customerId, String key) {
        super("Customer %d cannot access %s".formatted(customerId, key));
    }
}

So funktioniert der Code:

  • Die startsWith-Überprüfung ist die Berechtigung auf Anwendungsebene. S3 kennt nur, dass Ihre Anwendung alle invoices/* lesen darf. S3 weiß nicht, dass Kunde 42 die Rechnungen von Kunde 43 nicht sehen darf. Wenn Sie diese Zeile weglassen, kann jeder, der den Schlüssel errät, die Datei eines anderen abrufen.
  • signatureDuration setzt die Gültigkeitsdauer der URL. Für heruntergeladene Dateien sind normalerweise ein paar Minuten ausreichend.
  • Bei vorsigniertem PUT muss der Client den korrekten Header Content-Type: application/pdf senden. Andernfalls stimmt die Signatur nicht überein.

Die generierte URL hat die Form:

https://nonla-invoices-prod.s3.ap-southeast-1.amazonaws.com/invoices/42/7f3c...pdf
  ?X-Amz-Algorithm=AWS4-HMAC-SHA256
  &X-Amz-Date=20261013T080000Z
  &X-Amz-Expires=300
  &X-Amz-Credential=...
  &X-Amz-Signature=...

Der Client lädt direkt zu S3 mit vorsigniertem PUT hoch:

curl -X PUT -H "Content-Type: application/pdf" \
     --upload-file invoice.pdf "<presigned-url>"

Hinweis: Vorsignierte URLs tragen die Berechtigungen des Unterzeichners. Wenn die IAM-Rolle der Anwendung die s3:GetObject-Berechtigung verliert, werden auch die bereits ausgegebenen URLs ungültig. Die maximale Gültigkeitsdauer beträgt 7 Tage. Wenn Sie mit temporären Credentials einer IAM-Rolle signieren, expires die URL sogar früher, zusammen mit der Session der Rolle (normalerweise ein paar Stunden). Speichern Sie vorsignierte URLs nicht langfristig in der Datenbank. Speichern Sie stattdessen den Schlüssel und generieren Sie jedes Mal eine neue URL, wenn Sie sie benötigen.

6. Test mit LocalStack

Fügen Sie Testcontainers hinzu, um einen S3-Emulator in Docker auszuführen:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.x verwaltet bereits die Version von Testcontainers, daher müssen Sie version nicht angeben.

@Testcontainers
class InvoiceStorageServiceTest {

    @Container
    static LocalStackContainer localstack =
            new LocalStackContainer(DockerImageName.parse("localstack/localstack:3.8"))
                    .withServices(LocalStackContainer.Service.S3);

    static InvoiceStorageService service;

    @BeforeAll
    static void setUp() {
        var credentials = StaticCredentialsProvider.create(
                AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()));
        var region = Region.of(localstack.getRegion());

        S3Client s3 = S3Client.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .forcePathStyle(true)
                .build();

        S3Presigner presigner = S3Presigner.builder()
                .endpointOverride(localstack.getEndpoint())
                .credentialsProvider(credentials)
                .region(region)
                .build();

        s3.createBucket(b -> b.bucket("test-invoices"));
        service = new InvoiceStorageService(s3, presigner, "test-invoices");
    }

    @Test
    void givenUploadedInvoice_whenDownload_thenContentIsSame() {
        byte[] pdf = "%PDF-1.7 fake invoice".getBytes(StandardCharsets.UTF_8);

        String key = service.upload(42L, new ByteArrayInputStream(pdf), pdf.length);

        assertThat(key).startsWith("invoices/42/").endsWith(".pdf");
        assertThat(service.download(key)).isEqualTo(pdf);
    }

    @Test
    void givenOwnKey_whenPresign_thenUrlHasExpiry() {
        URL url = service.presignDownload(42L, "invoices/42/a.pdf", Duration.ofMinutes(5));

        assertThat(url.getQuery()).contains("X-Amz-Expires=300");
    }

    @Test
    void givenOtherCustomersKey_whenPresign_thenForbidden() {
        assertThatThrownBy(() ->
                service.presignDownload(42L, "invoices/43/b.pdf", Duration.ofMinutes(5)))
                .isInstanceOf(ForbiddenFileAccessException.class);
    }
}

Ein paar beachtenswerte Details:

  • forcePathStyle(true) ermöglicht den Zugriff auf den Bucket in der Form http://localhost:4566/test-invoices/.... LocalStack benötigt dieses Format, da es Subdomains wie test-invoices.localhost nicht auflösen kann.
  • Der dritte Test ist der wichtigste aus Sicherheitssicht. Er stellt sicher, dass Kunde 42 keine URL für Dateien von Kunde 43 anfordern kann.

LocalStack bewertet IAM-Policies in der kostenlosen Version nicht. Daher sollten Sie Ihre Policies mit dem IAM Policy Simulator oder in einer Staging-Umgebung überprüfen.

7. Häufige Fehler

  • AccessDenied trotz s3:GetObject: Der ARN fehlt /*, der Bucket-Name ist falsch, oder eine Bucket-Policy Deny blockiert. Wenn der Bucket SSE-KMS verwendet, benötigt die Rolle auch die Berechtigung kms:Decrypt für den entsprechenden KMS-Schlüssel.
  • 403 anstelle von 404, wenn das Object nicht existiert: Die Rolle fehlt s3:ListBucket. Dies ist korrektes S3-Verhalten, kein Bug.
  • SignatureDoesNotMatch bei vorsigniertem PUT: Der Client sendet einen anderen Content-Type als zum Zeitpunkt der Signatur, oder der Presigner ist mit einer anderen Region konfiguriert als das Bucket.
  • Öffentlich machen mit ACL public-read: Mit BucketOwnerEnforced werden Requests mit ACLs abgelehnt. Wenn Sie wirklich öffentliche Inhalte benötigen, z. B. Produktfotos, stellen Sie CloudFront vor den Bucket und verwenden Sie Origin Access Control. Deaktivieren Sie nicht Block Public Access.
  • Für jeden Request einen neuen S3Client erstellen: Der Client unterhält einen Connection Pool und sollte gemeinsam genutzt werden. Deklarieren Sie ihn als Singleton-Bean wie in Abschnitt 2.2.

Hinweis: S3 ist kein Dateisystem. Es gibt keine Operationen zum „Umbenennen” oder „Anhängen”. Umbenennen bedeutet, auf einen neuen Schlüssel zu kopieren und den alten zu löschen. Wenn Sie fortlaufend an eine Datei anhängen müssen, ist S3 nicht die richtige Wahl.

8. Fazit

In diesem Artikel haben Sie AWS SDK v2 in Spring Boot verwendet, um Dateien zu S3 hochzuladen, herunterzuladen und zu löschen, mit Schlüsseln, die nach customerId strukturiert sind. Sie haben auch Berechtigungen auf mehreren Ebenen konfiguriert:

  • Block Public Access und deaktivierte ACLs, um den Bucket immer privat zu halten.
  • IAM Policy mit minimalen Berechtigungen für die Anwendungsrolle.
  • Bucket Policy für allgemeine Regeln wie erzwungenes TLS.
  • Überprüfung der Berechtigung auf Anwendungsebene vor der Ausstellung vorsignierter URLs.

Der wichtigste Punkt: S3 kennt nur, was Ihre Anwendung tun darf. Welcher Benutzer welche Dateien sehen darf, liegt in der Verantwortung Ihres Codes. Deshalb sollten Sie Tests haben, die den Fall abdecken, dass ein Benutzer auf Dateien eines anderen zugreift.

awss3spring-bootiamjava