S3 no Spring Boot: armazenar arquivos com segurança e permissões corretas
1. Visão geral
Amazon S3 é uma escolha comum para armazenar arquivos como faturas, avatares ou anexos. A parte de upload geralmente leva apenas algumas linhas de código. A parte mais desafiadora é a permissão: quem pode ler, quem pode escrever, e como compartilhar arquivos sem expor o bucket publicamente.
Neste artigo, você usará o AWS SDK for Java v2 no Spring Boot 3.x para armazenar e ler arquivos. Em seguida, configurará permissões usando IAM policy e bucket policy, e depois usará presigned URL para compartilhar arquivos com prazo de validade. No final, há um teste executando com LocalStack.
2. Configuração do Projeto
2.1. Dependência
Você deve usar o BOM do AWS SDK para garantir que todos os módulos tenham a mesma versão:
<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>
O módulo s3 já inclui S3Client e S3Presigner, então você não precisa adicionar mais nenhum módulo.
2.2. Configuração de Bean
Declare a região e o nome do bucket em application.properties:
app.s3.region=ap-southeast-1
app.s3.bucket=nonla-invoices-prod
Em seguida, crie dois beans S3Client e 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();
}
}
O código acima não declara credenciais. O SDK usará DefaultCredentialsProvider e procurará credenciais sequencialmente em variáveis de ambiente, no arquivo ~/.aws/credentials e depois no IAM role do EC2/ECS/EKS. Em um ambiente de produção, você deve executar a aplicação usando um IAM role.
Atenção: Não hard-code access keys no código ou em
application.properties. Vazamento de keys via Git é um dos incidentes AWS mais comuns.
3. Armazenar e Ler Arquivos
O exemplo neste artigo é um serviço que armazena faturas PDF para clientes. Cada arquivo fica sob um prefixo baseado em customerId. Esta forma de estruturar a chave facilita a verificação de propriedade na seção 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));
}
}
Os pontos principais do código:
-
A chave tem a forma
invoices/42/7f3c...pdf. A parte UUID evita conflitos de nomes. Você também não coloca o nome do arquivo do usuário na chave, evitando caracteres estranhos e path traversal. -
O
contentTypeé armazenado junto com o objeto. Quando um navegador baixa o arquivo através de uma presigned URL, ele exibe o PDF corretamente. -
serverSideEncryption(AES256)solicita que o S3 criptografe o arquivo ao armazenar (SSE-S3). Atualmente, o S3 tem isso ativado por padrão. Especificar na requisição é ainda útil se a bucket policy exigir headers de criptografia. -
RequestBody.fromInputStreamprecisa saber osizeantecipadamente. Se você receber umMultipartFile, passefile.getInputStream()efile.getSize(). -
getObjectAsBytescarrega o arquivo inteiro na memória. Este método é adequado apenas para arquivos pequenos.
Atenção: Para arquivos grandes (centenas de MB), não use
getObjectAsBytes. Uses3.getObject(...)para obterResponseInputStreame transmita-o diretamente para a resposta. Outra opção é deixar o cliente fazer download direto via presigned URL (seção 5).
4. Modelo de Permissões do S3
Uma requisição ao S3 é aceita apenas se passar por várias camadas de verificação:
-
Block Public Access: uma configuração no nível da conta e do bucket. Quando ativado, qualquer policy ou ACL que conceda permissões públicas é desabilitado.
-
IAM policy: anexado a um usuário ou role, descreve o que essa identidade pode fazer.
-
Bucket policy: anexado a um bucket, descreve quem pode fazer o quê nesse bucket.
-
ACL: um mecanismo antigo. Novos buckets usam Object Ownership
BucketOwnerEnforcedpor padrão, o que desativa ACLs.
A regra de avaliação é muito simples: um Deny explícito em qualquer lugar sempre vence. Se não houver Deny, a requisição precisa de pelo menos um Allow correspondente.
Recomendação para a maioria das aplicações:
-
Mantenha Block Public Access ativado em todas as quatro opções.
-
Mantenha ACL desativado (
BucketOwnerEnforced). -
Conceda permissões à aplicação através de IAM role.
-
Use bucket policy para restrições comuns, por exemplo, exigir TLS.
4.1. IAM policy com Princípio de Menor Privilégio
O role da aplicação precisa apenas ler, escrever e deletar objetos sob o prefixo invoices/:
{
"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/*" }
}
}
]
}
Observe os dois formatos ARN diferentes:
-
Operações em objetos usam ARN com
/*no final. -
s3:ListBucketé uma operação no bucket, então use ARN sem/*.
Confundir esses dois ARNs é a causa mais comum do erro AccessDenied. Há mais um detalhe: se s3:ListBucket estiver faltando, quando um objeto não existe, o S3 retorna 403 em vez de 404. Este é um mecanismo intencional do S3 para não revelar se a chave existe ou não.
4.2. Bucket Policy Exigindo TLS
Bucket policy é apropriado para definir regras que se aplicam a todos os chamadores, incluindo admins:
{
"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" }
}
}
]
}
Este statement é Deny, então qualquer requisição via HTTP simples é bloqueada. Nenhum outro Allow pode sobrescrevê-lo. SDK v2 usa HTTPS por padrão, então sua aplicação não é afetada.
5. Compartilhando Arquivos com Presigned URL
O cliente precisa fazer download da fatura, mas você não quer abrir o bucket publicamente nem fazer stream de cada arquivo pelo servidor. Presigned URL resolve exatamente isso. É uma URL assinada com as credenciais da aplicação e válida apenas por um curto período de tempo.
Adicione os dois métodos a seguir a InvoiceStorageService:
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();
}
Uma classe de exceção simples:
public class ForbiddenFileAccessException extends RuntimeException {
public ForbiddenFileAccessException(long customerId, String key) {
super("Customer %d cannot access %s".formatted(customerId, key));
}
}
Como o código funciona:
-
A verificação
startsWithé a camada de permissão no nível da aplicação. O S3 apenas sabe que sua aplicação tem permissão para ler todoinvoices/*. O S3 não sabe que o cliente 42 não pode ver faturas do cliente 43. Se você remover esta linha, qualquer pessoa que adivinhe a chave pode baixar o arquivo de outra pessoa. -
signatureDurationdefine a validade da URL. Para download de arquivos, alguns minutos geralmente são suficientes. -
Para presigned PUT, o cliente deve enviar o header
Content-Type: application/pdfcorreto. Caso contrário, a assinatura não corresponderá.
A URL gerada tem este formato:
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=...
O cliente faz upload direto para o S3 usando presigned PUT:
curl -X PUT -H "Content-Type: application/pdf" \
--upload-file invoice.pdf "<presigned-url>"
Atenção: Uma presigned URL carrega as permissões de quem a assinou. Se o IAM role da aplicação perder a permissão
s3:GetObject, a URL emitida também expirará. O prazo máximo é de 7 dias. Quando assinada com credenciais temporárias de um IAM role, a URL expira mais cedo, junto com a sessão do role (geralmente poucas horas). Não salve presigned URLs em um banco de dados para uso prolongado. Salve a chave e gere uma nova URL sempre que necessário.
6. Testes com LocalStack
Adicione Testcontainers para executar um S3 emulado em Docker:
<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 já gerencia a versão do Testcontainers, então você não precisa especificar 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);
}
}
Alguns detalhes dignos de nota:
-
forcePathStyle(true)permite acessar o bucket na formahttp://localhost:4566/test-invoices/.... LocalStack precisa disso porque não resolve subdomínios comotest-invoices.localhost. -
O terceiro teste é o mais importante em termos de segurança. Ele garante que o cliente 42 não possa obter uma URL para arquivo do cliente 43.
LocalStack não avalia IAM policies na versão gratuita. Portanto, você deve testar políticas reais usando o IAM Policy Simulator ou em um ambiente de staging.
7. Erros Comuns
-
AccessDeniedmesmo coms3:GetObject: ARN sem/*, nome do bucket incorreto, ou uma bucket policyDenybloqueando. Se o bucket usa SSE-KMS, o role também precisa da permissãokms:Decryptna chave KMS correspondente. -
403em vez de404quando objeto não existe: role sems3:ListBucket. Este é o comportamento correto do S3, não é um bug. -
SignatureDoesNotMatchcom presigned PUT: cliente enviaContent-Typediferente do que foi usado na assinatura, ou o presigner configurado em uma região diferente da região do bucket. -
Tornar público com ACL
public-read: comBucketOwnerEnforced, requisições com ACL são rejeitadas. Se você realmente precisa de conteúdo público, como imagens de produtos, coloque CloudFront na frente do bucket e use Origin Access Control. Não desabilite Block Public Access. -
Criar novo
S3Clientpara cada requisição: o client mantém um pool de conexões e deve ser compartilhado. Declare-o como singleton bean como na seção 2.2.
Atenção: S3 não é um file system. Você não tem operações de “rename” ou “append”. Renomear significa copiar para uma nova chave e depois deletar a antiga. Se você precisa escrever continuamente em um arquivo, S3 não é a escolha certa.
8. Conclusão
Neste artigo, você aprendeu a usar o AWS SDK v2 no Spring Boot para fazer upload, download e deletar arquivos no S3, com chaves estruturadas por customerId. Você também configurou permissões em múltiplas camadas:
-
Block Public Access e ACL desativado para manter o bucket sempre privado.
-
IAM policy com princípio de menor privilégio para o role da aplicação.
-
Bucket policy para regras comuns como exigir TLS.
-
Verificação de propriedade no nível da aplicação antes de emitir presigned URLs.
O ponto mais importante a lembrar: o S3 só sabe o que sua aplicação pode fazer. Determinar qual usuário pode ver qual arquivo ainda é responsabilidade do código que você escreve. Essa é também a razão pela qual você deve ter testes cobrindo o caso em que um usuário tenta acessar arquivo de outro usuário.