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へのリクエストは、複数の検証レイヤーを通過した場合にのみ受け入れられます。
- Block Public Access: accountレベルとbucketレベルのトグルです。有効にすると、すべてのpolicyとACLで付与された公開アクセス権は無効になります。
- IAM policy: userまたはroleにアタッチされ、その身元が何ができるかを記述します。
- Bucket policy: bucketにアタッチされ、そのbucket上で誰が何ができるかを記述します。
- 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で設定されています。 - ACL
public-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はアプリケーションが何ができるかだけを知っています。どのユーザーがどのファイルを表示できるかは、依然としてあなたのコードの責任です。だからこそ、ユーザーが他の誰かのファイルにアクセスするケースをカバーするテストが必要です。