S3 in Spring Boot: ファイルを安全に保存し、権限を正しく管理する

1. 概要

Amazon S3は請求書、プロフィール画像、添付ファイルなどを保存するための一般的な選択肢です。アップロード部分は通常わずか数行のコードで済みます。より難しいのは権限管理です。誰が読めて、誰が書けるのか、そしてbucketを公開せずにファイルを共有するにはどうするか。

このガイドでは、Spring Boot 3.xでAWS SDK for Java v2を使ってファイルを保存・読み取ります。その後、IAM policyとbucket policyで権限を設定し、presigned URLを使って有効期限付きでファイルを共有します。記事の最後にはLocalStackで実行するテストがあります。

2. プロジェクトのセットアップ

2.1. Dependency

AWS SDKのBOMを使って、モジュールのバージョンを一貫させるべきです。

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

モジュールs3にはS3ClientとS3Presignerが既に含まれているため、他のモジュールを追加する必要はありません。

2.2. Beanの設定

application.propertiesでregionとbucket名を定義します。

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

次に、S3ClientとS3Presignerの2つのbeanを作成します。

@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();
    }
}

上記のコードはcredentialsを宣言していません。SDKはDefaultCredentialsProviderを使い、環境変数、~/.aws/credentialsファイル、そしてEC2/ECS/EKSのIAM roleの順に探します。本番環境では、アプリケーションをIAM roleで実行することをお勧めします。

注意: コードやアプリケーション設定にアクセスキーをハードコードしないでください。GitでのKey漏洩はAWSの最も一般的なインシデントの1つです。

3. ファイルの保存と読み込み

このガイドの例は、顧客のPDF請求書を保存するサービスです。各ファイルはcustomerIdに基づいたprefixの下に配置されます。このようなkey命名により、セクション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));
    }
}

コードの主な点:

  • Key はinvoices/42/7f3c...pdfの形式です。UUID部分は名前の重複を防ぎます。ユーザーが提供したファイル名をkeyに含めないため、不正な文字とpath traversalを回避できます。
  • contentTypeはobjectと一緒に保存されます。ブラウザがpresigned URLでファイルを取得するとき、PDFが正しく表示されます。
  • serverSideEncryption(AES256)はS3にファイル保存時に暗号化を要求します(SSE-S3)。S3は現在デフォルトでこのモードを有効にしていますが、bucket policyが暗号化ヘッダーを要求する場合は明示的に指定することが有益です。
  • RequestBody.fromInputStreamは事前にsizeを知る必要があります。MultipartFileを受け取る場合、file.getInputStream()とfile.getSize()を渡します。
  • getObjectAsBytesはファイル全体をメモリに読み込みます。この方法は小さいファイルにのみ適しています。

注意: 大きなファイル(数百MB)の場合、getObjectAsBytesを使わないでください。代わりにs3.getObject(...)を使ってResponseInputStreamを取得し、レスポンスに直接ストリーミングします。別の方法は、クライアントがpresigned URLで直接ダウンロードすることです(セクション5)。

4. S3の権限モデル

S3へのリクエストは、複数の検証レイヤーを通過した場合にのみ受け入れられます。

  1. Block Public Access: accountレベルとbucketレベルのトグルです。有効にすると、すべてのpolicyとACLで付与された公開アクセス権は無効になります。
  2. IAM policy: userまたはroleにアタッチされ、その身元が何ができるかを記述します。
  3. Bucket policy: bucketにアタッチされ、そのbucket上で誰が何ができるかを記述します。
  4. ACL: レガシー機構です。新しいbucketはデフォルトでObject Ownership BucketOwnerEnforcedを使用し、ACLが無効になります。

評価ルールは非常にシンプルです。明示的なDenyはどこにあっても常に優先されます。Denyがない場合、リクエストには少なくとも1つの適切なAllowが必要です。

ほとんどのアプリケーションに推奨:

  • 4つのオプションすべてでBlock Public Accessを有効に保つ。
  • ACL無効(BucketOwnerEnforced)を保つ。
  • IAM roleを介してアプリケーションに権限を付与する。
  • bucket policyを一般的な制約(例:TLS必須化)に使用する。

4.1. 最小権限の原則に従うIAM policy

アプリケーションのroleは、prefixinvoices/の下でobjectを読み取り、書き込み、削除するだけで済みます。

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

2つの異なるARN形式に注意してください。

  • object上の操作はARNの末尾に/*を使用します。
  • s3:ListBucketはbucket上の操作なので、ARNは/*なしです。

この2つのARNを混同することは、AccessDeniedエラーの最も一般的な原因です。もう1つの詳細: s3:ListBucketがない場合、objectが存在しないとき、S3は404の代わりに403を返します。これはS3の意図的なメカニズムで、「このkeyが存在するかどうか」の情報が漏洩するのを防ぎます。

4.2. TLS必須化するBucket policy

Bucket policyは、admin を含むすべての呼び出し元に適用されるルールを設定するのに適しています。

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

このStatementはDenyなので、通常のHTTPを介したすべてのリクエストがブロックされます。他のAllowはこれを上書きできません。SDK v2はデフォルトでHTTPSを使用するため、アプリケーションは影響を受けません。

5. Presigned URLでのファイル共有

顧客は請求書をダウンロードする必要がありますが、bucketを公開したくないか、すべてのファイルをサーバー経由でストリーミングしたくありません。Presigned URLはこの問題を正確に解決します。これはアプリケーションの認証情報で署名されたURLで、短期間だけ使用できます。

InvoiceStorageServiceに次の2つのメソッドを追加します。

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

シンプルなexception class:

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

コードの動作方法:

  • startsWithチェック行はアプリケーションレイヤーでの権限管理です。S3は、アプリケーションがinvoices/*全体を読み取る権限があることだけを知っています。S3は、顧客42が顧客43の請求書を表示できないことを知りません。この行を削除すると、keyを推測した人は他の誰かのファイルをダウンロードできます。
  • signatureDurationはURLの有効期限を設定します。ダウンロード用ファイルでは、数分で十分なことが多いです。
  • Presigned PUTの場合、クライアントは正確にContent-Type: application/pdfヘッダーを送信する必要があります。そうでなければ、署名が一致しません。

生成されたURLの形式:

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=...

クライアントはpresigned PUTでS3に直接アップロードします。

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

注意: Presigned URLは署名者の権限を持ちます。アプリケーションのIAM roleがs3:GetObject権限を失うと、発行されたURLも無効になります。最大有効期限は7日です。IAM roleの一時的な認証情報で署名する場合、URLはroleのセッション(通常数時間)と同時に期限切れになります。Presigned URLをデータベースに長期保存しないでください。keyを保存して、必要になるたびに新しいURLを生成してください。

6. LocalStackでのテスト

Testcontainersを追加してDockerでS3をエミュレートします。

<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はTestcontainersのバージョンを管理しているため、versionを指定する必要がありません。

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

注目すべき詳細:

  • forcePathStyle(true)はhttp://localhost:4566/test-invoices/...形式でbucketにアクセスできます。LocalStackはこの形式が必要です。test-invoices.localhostのようなsubdomainを解決できないためです。
  • 3番目のテストは、セキュリティの観点から最も重要です。これにより、顧客42が顧客43のファイルのURLをリクエストできないことが確認されます。

LocalStackの無料版はIAM policyを評価しません。したがって、IAM Policy Simulatorを使用するか、ステージング環境でpolicyを実際にテストする必要があります。

7. よくあるエラー

  • AccessDeniedなのにs3:GetObjectがある: ARNに/*がない、bucket名が間違っている、またはbucket policyDenyがブロックしています。bucketがSSE-KMSを使用している場合、roleは対応するKMS keyに対するkms:Decrypt権限も必要です。
  • objectが存在しないとき404の代わりに403: roleにs3:ListBucketがありません。これはS3の正しい動作で、バグではありません。
  • Presigned PUT でのSignatureDoesNotMatch: クライアントが署名時と異なるContent-Typeを送信しているか、presignerがbucketのregionと異なるregionで設定されています。
  • ACLpublic-readで公開: BucketOwnerEnforcedでは、ACL付きのリクエストが拒否されます。本当に公開コンテンツが必要な場合(例:製品画像)、bucketの前にCloudFrontを配置し、Origin Access Controlを使用してください。Block Public Accessを無効にしないでください。
  • リクエストごとに新しいS3Clientを作成: clientは接続プールを保持し、再利用する必要があります。セクション2.2のようにsingletonなbeanとして宣言してください。

注意: S3はファイルシステムではありません。「rename」または「append」操作はありません。ファイル名変更とは、新しいkeyにコピーしてから古いkeyを削除することを意味します。ファイルに継続的に追記する必要がある場合、S3は適切な選択ではありません。

8. 結論

このガイドでは、AWS SDK v2とSpring Bootを使ってS3でファイルをアップロード、ダウンロード、削除し、customerIdに基づいてkey設計しました。複数のレイヤーで権限も設定しました。

  • Block Public AccessとACL無効でbucketを常にプライベートに保つ。
  • アプリケーションのroleに対するIAM policy最小権限。
  • TLS必須化などの一般的なルールに対するbucket policy。
  • Presigned URL発行前のアプリケーションレイヤーでの所有権チェック。

最も重要な点: S3はアプリケーションが何ができるかだけを知っています。どのユーザーがどのファイルを表示できるかは、依然としてあなたのコードの責任です。だからこそ、ユーザーが他の誰かのファイルにアクセスするケースをカバーするテストが必要です。

awss3spring-bootiamjava