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.fromInputStream precisa saber o size antecipadamente. Se você receber um MultipartFile, passe file.getInputStream() e file.getSize().

  • getObjectAsBytes carrega 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. Use s3.getObject(...) para obter ResponseInputStream e 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:

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

  2. IAM policy: anexado a um usuário ou role, descreve o que essa identidade pode fazer.

  3. Bucket policy: anexado a um bucket, descreve quem pode fazer o quê nesse bucket.

  4. ACL: um mecanismo antigo. Novos buckets usam Object Ownership BucketOwnerEnforced por 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 todo invoices/*. 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.

  • signatureDuration define 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/pdf correto. 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 forma http://localhost:4566/test-invoices/.... LocalStack precisa disso porque não resolve subdomínios como test-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

  • AccessDenied mesmo com s3:GetObject: ARN sem /*, nome do bucket incorreto, ou uma bucket policy Deny bloqueando. Se o bucket usa SSE-KMS, o role também precisa da permissão kms:Decrypt na chave KMS correspondente.

  • 403 em vez de 404 quando objeto não existe: role sem s3:ListBucket. Este é o comportamento correto do S3, não é um bug.

  • SignatureDoesNotMatch com presigned PUT: cliente envia Content-Type diferente 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: com BucketOwnerEnforced, 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 S3Client para 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.

awss3spring-bootiamjava