Lab 17 — Upload sem passar pela aplicação
O problema, e a empresa que o tem
A Cadência — a mesma equipe de duas pessoas do L01 e do L03 — abriu no aplicativo das lojas um recurso novo: reivindicação de garantia. O lojista anexa um laudo técnico do produto — um PDF com fotos e um vídeo curto de manuseio, tudo junto — e a Cadência encaminha ao fabricante. Alguns laudos passam de 200 MB.
O endpoint foi escrito do mesmo jeito que todo o resto da API: recebe o IFormFile, materializa em memória, reenvia ao S3. Funcionou nos testes, onde ninguém anexou vídeo. Em produção, a terceira loja que tentou enviar um laudo de verdade derrubou a task — e como o `deregistration_delay` do L03 foi calculado para uma API sem requisição longa, a queda apareceu também como 502 no painel de deploy, num lugar que já estava resolvido.
O conserto óbvio — aumentar o limite do Kestrel e a memória da task — teria funcionado até o próximo laudo maior. Não é um problema de tamanho de limite: é a aplicação estar no caminho físico do byte quando ela não precisa estar.
O que este laboratório NÃO é
Não é o pipeline que interpreta o laudo — extrair dado da nota fiscal, decidir se a garantia procede, acionar o fabricante. Isso é trabalho de negócio disparado pelo mesmo evento que este módulo já produz, e é o L28. Também não é um antivírus de verdade: o verificador aqui confere a assinatura binária do arquivo e chama um ponto de extensão para varredura, sem prescrever qual motor usar.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Explicar por que uma URL PUT pré-assinada comum não impõe teto de tamanho nenhum.
- Configurar uma política POST com `content-length-range` e provar que o S3 recusa o arquivo fora da faixa antes de gravar qualquer byte.
- Assinar uma requisição a partir de credenciais temporárias e demonstrar que o prazo pedido é limitado pela vida da própria credencial.
- Configurar CORS no bucket e diagnosticar por que um erro de CORS parece falha genérica de rede, mesmo com o objeto já gravado.
- Explicar por que multipart não tem condição de tamanho, e implementar controle de admissão por soma de partes na aplicação.
- Configurar a regra de ciclo de vida que aborta multipart incompleto, e medir seu efeito na fatura de armazenamento.
- Desenhar a cadeia de validação assíncrona — EventBridge, fila, DLQ — que substitui a validação síncrona que saiu da API.
- Provar, com medição, que a memória da task deixou de variar com o tamanho do laudo.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| URL pré-assinada (presigned URL) | CLF-C02, DVA-C02 | a API assina uma política em vez de transportar o arquivo | a URL herda a permissão de quem assina, e nunca de quem a usa |
| Expiração ligada à credencial | DVA-C02, SAP-C02 | credencial temporária da task expira antes do prazo pedido | IAM user chega a 7 dias; credencial temporária nunca passa da própria vida |
| `content-length-range` em política POST | DVA-C02 | único caminho que barra tamanho ANTES de gravar | a diferença entre isso e um `PUT` assinado sem cabeçalho de tamanho |
| Upload multipart | DVA-C02, SAA-C03 | admissão por soma de partes, porque o S3 não valida tamanho aqui | faixa de 5 MiB a 5 GiB por parte, até 10.000 partes, sem mínimo na última |
| CORS em bucket S3 | DVA-C02 | obrigatório porque a requisição de upload é cross-origin | por que a ausência de CORS pode deixar o objeto gravado e o cliente achando que falhou |
| Notificação de evento do S3 via EventBridge | DVA-C02, SAA-C03 | gatilho da validação assíncrona, filtrado por prefixo | a diferença entre notificação direta do bucket e passar pelo EventBridge |
| Ciclo de vida de bucket | CLF-C02, SAA-C03 | `abort_incomplete_multipart_upload` custeando parte abandonada | que multipart incompleto cobra armazenamento até algo o apagar |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve uma URL pré-assinada e pergunta "qual o tamanho máximo do upload?" como se fosse propriedade da URL. Não é: depende inteiramente de qual condição foi assinada dentro dela. Sem `content-length-range` (POST) ou `Content-Length` nos cabeçalhos assinados (PUT), a resposta correta é "não há teto".
Requisitos, e como cada um muda o desenho
Requisito que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Laudo até 200 MB sem estourar a task | sim, sempre | a task sai do caminho do byte; upload vai direto ao S3 |
| Teto de tamanho aplicado ANTES de gravar | obrigatório | política POST com `content-length-range`, não PUT simples |
| Só o dono do laudo pode enviar para a chave dele | obrigatório | chave do objeto escolhida pela API, nunca pelo cliente; cota por loja no DynamoDB |
| Arquivo malicioso não fica acessível | obrigatório | dois buckets — quarentena e publicado — e cópia só após aprovação |
| Banda da loja não trava a aplicação | a mais lenta tem 8 Mbit/s de subida | upload sai do caminho da task; prazo de assinatura com folga sobre o tempo estimado |
| Custo de armazenamento não pode crescer sem controle | orçamento fixo mensal | ciclo de vida com `abort_incomplete_multipart_upload` e expiração de quarentena órfã |
| Equipe de duas pessoas, sem plantão | igual ao L03 | DLQ mais alarme, para não depender de alguém olhando a fila o tempo todo |
| Domínio único para front e API | herdado do L05 | a rota de assinatura fica na mesma origem do site; só o upload ao S3 é cross-origin |
Arquitetura mínima: a aplicação como cano
Este é o desenho que a Cadência tem hoje, e ele é legítimo como ponto de partida: sobe laudo de verdade, com poucas linhas. O laboratório começa por medir o defeito dele — porque um número torna o problema discutível, e "a task caiu" não.
- → POST multipart/form-data, 200 MB
- → repassa o corpo sem armazenar
- → conexão presa por ~200 s
- → os mesmos bytes, agora saindo
- → PutObject com a chave escolhida pela API
- Fora da AWS
- Rede e entrega
- Compute
- Armazenamento
Este desenho funciona, e é por isso que ele está em produção em tanto lugar: são vinte linhas de C# e nenhum recurso novo. Conclua daqui que o defeito não é de configuração — é de posição: a task está no caminho do byte, e paga memória, CPU e ocupação por isso. Percorra os passos e repare que o terceiro problema não é de upload nenhum: é o deploy do L03 quebrando.
- O corpo inteiro atravessa a borda e o balanceador. Nem o CloudFront nem o ALB seguram o arquivo: os dois repassam. A borda que o L05 colocou na frente serve páginas de graça e não muda nada aqui — upload não é armazenável em cache, e a rota `/api/*` foi configurada exatamente para não ser armazenada.
- O primeiro teto que aparece não é o seu. O Kestrel recusa corpo acima de 30.000.000 bytes por padrão e devolve 413. Quem levanta esse limite sem pensar troca um erro claro por um problema de memória silencioso — e é o passo seguinte.
- A memória estoura por causa de UMA linha do tutorial. O `IFormFile` do ASP.NET Core já derrama para arquivo temporário acima de 64 KB; quem estoura a memória é a linha seguinte, `await arquivo.CopyToAsync(ms)` com `ms` sendo um `MemoryStream`. Três envios simultâneos de 200 MB pedem 600 MB só de buffer numa task de 1 GB, e o contêiner morre com código 137 — que é o cgroup matando o processo, não uma exceção do .NET.
- O byte é pago duas vezes e transportado duas vezes. Ele sobe da loja até a task e desce da task até o S3. A segunda perna atravessa o NAT Gateway, que cobra por GB processado, e ocupa vCPU-segundo de Fargate para a task fazer o trabalho de um cano. O tempo total também dobra na pior hipótese: nada é enviado ao S3 antes de o cliente terminar.
- O defeito que ninguém liga ao upload: o deploy volta a dar 502. O L03 derivou `deregistration_delay = 30` de uma hipótese escrita: nenhuma rota faz upload longo. Esta rota revoga essa hipótese. Agora existe requisição de 200 s em voo, a drenagem de 30 s a corta, e o sintoma aparece como 502 durante o deploy — longe da tela de upload, num lugar que já estava resolvido.
- Por que alguém publica assim. Porque é o menor número de linhas que funciona, e porque funciona de verdade até os 5 MB de um PDF. O desenho não é ingênuo: ele é a extrapolação de um caso em que estava certo. A pergunta útil não é "quem fez isso", é "a partir de qual tamanho isto deixa de valer" — e a resposta se mede.
O que "código 137" realmente significa, e por que ninguém vê exceção
Quando o cgroup mata o processo por estouro de memória, o .NET não recebe chance de lançar uma exceção tratável — o contêiner simplesmente some. O log da aplicação fica limpo, exatamente como no 503 do L03, e a investigação começa pelo `stoppedReason` da task no ECS, não pelo `try/catch` que todo mundo procura primeiro.
Arquitetura para produção: a aplicação assina
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → POST /api/laudos/assinatura (mesma origem)
- → pedido de autorização, alguns KB
- → política assinada: chave, teto e prazo
- → POST do arquivo direto ao S3 (cross-origin)
- → Object Created com chave e tamanho
- → entrega filtrada por prefixo
- → uma mensagem por objeto novo
- → lê os 512 primeiros bytes e o tamanho real
- → copia o que foi aprovado
- → grava o veredito e o tamanho
- → consulta a cota antes de assinar
- → cifra o objeto na escrita
- → origem do download, com acesso de origem
- Fora da AWS
- Rede e entrega
- Compute
- Armazenamento
- Integração de apps
- Banco de dados
- Segurança e identidade
Não é o desenho anterior com uma caixa a mais: o caminho do byte mudou de lugar e a validação mudou de tempo. Conclua daqui que separar autorizar de transportar cria uma terceira obrigação — validar depois do fato — e que ela é o que torna o desenho honesto. Percorra os passos: cada peça nova rastreia a um requisito da tabela anterior.
- A aplicação decide, e a decisão cabe em alguns KB. A requisição de assinatura leva nome, tamanho declarado e tipo. A API confere a sessão, a cota da loja e o teto contratado, e devolve uma política assinada. Este é o único momento em que a aplicação está no caminho — e o que passa por ela é metadado, não conteúdo.
- Quem escolhe a chave do objeto é o servidor. A chave sai de `lojas/{lojaId}/laudos/{ano}/{uuid}` com a extensão derivada do tipo aceito — nunca do nome que o cliente mandou. Duas coisas se resolvem de uma vez: nome com `../` deixa de ter efeito, e um cliente para de poder sobrescrever o objeto de outro adivinhando a chave.
- O byte não passa pela sua conta de computação. O navegador envia o arquivo ao endpoint do S3. A task não gasta memória, não gasta vCPU-segundo proporcional ao tamanho e não fica presa pela banda da loja. Como esta requisição vai para outro host, ela é cross-origin — e é por isso que o bucket precisa de CORS, sem o qual o navegador nem chega a enviar.
- O teto de tamanho é condição da política, não código seu. A política assinada carrega `content-length-range` com mínimo e máximo em bytes. É o S3 que recusa o arquivo grande, antes de armazenar, com 400 e código de entidade grande demais. Se este envio fosse por `PUT` assinado simples, não haveria teto nenhum — e essa diferença tem seção própria mais adiante.
- A validação não desapareceu: mudou de tempo e de lugar. Tipo real do arquivo, antivírus, dimensão de imagem e duração de vídeo passam a ser trabalho disparado por evento. O objeto nasce em quarentena e só é copiado para o bucket publicado depois de aprovado. Quem esquece esta etapa não simplificou o upload: apenas deixou de validar.
- O estado do laudo vive na tabela, não no bucket. O S3 não guarda "aprovado" ou "recusado" de forma consultável em escala, e listar bucket para descobrir estado é o antipadrão que a seção de escala trata. O verificador grava o veredito, o tamanho medido e o motivo da recusa, e é isso que a tela da loja lê.
- O download volta pela borda, não pelo bucket. Servir direto do S3 público desfaria o cuidado todo. O bucket publicado continua privado e a distribuição do L05 lê dele com acesso de origem; para arquivo restrito, uma URL assinada de leitura com prazo curto resolve — mesma mecânica deste módulo, sentido contrário.
O que sai do orçamento de computação da Cadência
vCPU-segundo e memória da task deixam de crescer com o tamanho do laudo — o que sobra é o custo, muito menor, de assinar alguns KB de metadado. O byte também para de pagar NAT Gateway duas vezes, porque não sai mais da VPC para entrar de novo.
O caminho de um upload, ponta a ponta
Os oito passos abaixo separam duas fases que o desenho mínimo misturava numa só: "autorizar" e "transportar". A aplicação só participa da primeira.
// O que /api/laudos/assinatura devolve ao navegador. Repare no que NÃO está
// aqui: nenhum byte do arquivo, nenhuma credencial de longa duração — só uma
// política que o navegador reproduz de volta ao fazer o POST ao S3.
{
"url": "https://ffv-lab-laudos-quarentena.s3.sa-east-1.amazonaws.com/",
"campos": {
"key": "lojas/lj_0231/laudos/2026/6f1a9e2c8b0d4a3e.pdf",
"policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0wOC0wN1QxNTozMjowMFoiLCJjb25kaXRpb25zIjpb...",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "ASIAEXAMPLE.../20260807/sa-east-1/s3/aws4_request",
"x-amz-date": "20260807T151700Z",
"x-amz-signature": "6d6c9e1f...",
"x-amz-meta-loja-id": "lj_0231",
"x-amz-security-token": "IQoJb3JpZ2luX2VjEA...(token da task role)"
},
// O que o navegador NÃO recebe e não precisa saber: o teto de 220 MB está
// DENTRO de "policy" (base64), assinado — mexer nele invalida "x-amz-signature".
"validoAte": "2026-08-07T15:32:00Z"
}Por que a política vai em base64 dentro de "policy"
O campo `policy` é a especificação JSON das condições — bucket, prefixo da chave, faixa de tamanho, prazo — codificada em base64. O S3 decodifica, reconstrói a assinatura com a chave derivada do `x-amz-credential` e compara com `x-amz-signature`. Qualquer byte diferente no `policy` recebido muda a assinatura esperada, e a requisição é recusada antes de qualquer condição interna ser avaliada.
As decisões, e o que se perde em cada uma
📋 Trinta lojas da Cadência (8 fora do Brasil) enviam laudos técnicos de garantia — PDF com fotos e um vídeo curto de manuseio — de até 200 MB, pela conexão da própria loja, sem equipe de infraestrutura dedicada e sem orçamento para um serviço de antivírus corporativo.
O POST assinado resolve o problema declarado — 200 MB não pode mais estourar a task — sem exigir nenhum recurso novo além do que já muda de lugar: a validação, que sempre existiu, só passa a acontecer depois do upload em vez de durante. O teto de tamanho vira condição do S3, não código que se esquece de atualizar. Multipart resolveria o mesmo problema de memória, mas cobraria a aplicação com um controle de admissão que o S3 não oferece — e 200 MB cabe folgado num único POST, que tem teto de operação em 5 GB. A complexidade de multipart só se paga quando o arquivo típico cresce ou quando retomar upload interrompido importa mais que a simplicidade — é a seção de multipart adiante.
Alt: PUT pré-assinado simples, sem Content-Length assinado — É o caminho mais copiado de tutorial, e é exatamente o que este laboratório mostra que NÃO impõe teto nenhum: qualquer tamanho passa, porque não há condição nenhuma amarrada à assinatura.
Alt: Multipart upload desde o primeiro byte — Resolve memória da task, mas empurra o controle de tamanho para a aplicação somar partes — mais código para um arquivo que cabe inteiro num único POST. Vale a pena a partir de vídeos maiores, e a seção 12 mostra como fazer certo.
Alt: Amazon GuardDuty Malware Protection for S3 no lugar do verificador caseiro — Tira a manutenção de um scanner de antivírus das suas mãos, mas cobra por GB varrido e reduz o controle fino sobre o veredito. Vale reconsiderar quando o volume de laudos justificar não manter a lógica de sniff de tipo em casa — confira o nome e a cobertura atuais do recurso antes de decidir.
Alt: Upload continua pela API, só que em streaming (sem materializar em memória) — Reduziria o estouro de memória sem tirar a task do caminho, mas mantém o tempo de conexão presa à banda da loja e a cobrança dupla de NAT Gateway. Resolve um sintoma do desenho mínimo sem resolver a posição errada da task.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Mecanismo de upload | POST com política assinada | PUT assinado simples; PUT com Content-Length assinado | único caminho com teto de tamanho verificado pelo S3 antes de gravar | a política POST é mais verbosa de montar do que uma URL PUT |
| Separação de buckets | quarentena e publicado, físicos | um bucket só com prefixo "aprovado/" e "pendente/" | objeto não aprovado nunca fica alcançável pelo caminho que serve o público | duas políticas de ciclo de vida e duas configurações de CORS para manter |
| Onde validar tipo e antivírus | depois do upload, por evento | validar no navegador antes de enviar | validação no cliente é sugestão, não controle — o servidor não pode confiar nela | o laudo malicioso ocupa espaço em quarentena por alguns segundos a minutos |
| Desacoplar S3 de quem consome | EventBridge entre o bucket e a fila | notificação direta do S3 para a fila | permite acrescentar um segundo consumidor (auditoria, métricas) sem tocar no bucket | mais um salto e mais uma peça para explicar a quem só quer "avisar a fila" |
| Tamanho até 220 MB | POST simples, sem multipart | multipart desde o primeiro byte | cabe folgado no teto de operação única do S3 (5 GB); multipart cobraria admissão própria sem necessidade | se o laudo típico crescer, a decisão precisa ser revisitada — é a seção 12 |
| Prazo de assinatura | 10 minutos | 1 hora (padrão do console); 7 dias (máximo da SDK) | cobre a banda mais lenta da Cadência com folga sem deixar a URL válida por muito tempo | loja com internet muito instável pode precisar pedir nova assinatura |
A dívida que este desenho não paga
A cota de laudos por loja é lida antes de assinar, mas nada impede que a mesma loja peça cem assinaturas em paralelo antes de qualquer uma virar objeto — a contagem só atualiza quando o veredito é gravado. Um limite de requisições por minuto na rota de assinatura fecha essa lacuna, e não está neste módulo.
Construir: os buckets, o CORS e o ciclo de vida
Dois buckets, uma chave KMS compartilhada, e duas regras de ciclo de vida que fazem o trabalho que "lembrar de limpar depois" nunca faz de forma confiável.
# buckets.tf — quarentena, publicado, CORS, e o ciclo de vida que paga a
# multipart abandonada se ninguem configurar isto
resource "aws_kms_key" "laudos" {
description = "Cifra os buckets de laudos da Cadencia"
enable_key_rotation = true
}
resource "aws_s3_bucket" "quarentena" {
bucket = "${var.projeto}-laudos-quarentena"
}
resource "aws_s3_bucket_public_access_block" "quarentena" {
bucket = aws_s3_bucket.quarentena.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "quarentena" {
bucket = aws_s3_bucket.quarentena.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.laudos.arn
}
}
}
# CORS e' o que faz o NAVEGADOR aceitar ler a resposta de um host diferente do
# seu. Sem isto, o formulario nao "falha bonito": o upload pode ate ter sido
# aceito pelo S3, e o navegador ainda assim reporta erro de rede generico —
# e' a seccao de seguranca que detalha por que o sintoma engana tanto.
resource "aws_s3_bucket_cors_configuration" "quarentena" {
bucket = aws_s3_bucket.quarentena.id
cors_rule {
allowed_methods = ["POST", "PUT"]
allowed_origins = ["https://${var.dominio}"] # o mesmo dominio resolvido no L05; nunca "*"
allowed_headers = ["*"]
expose_headers = ["ETag"] # sem isto o JS nao le o ETag da resposta do S3
max_age_seconds = 3000
}
}
resource "aws_s3_bucket_lifecycle_configuration" "quarentena" {
bucket = aws_s3_bucket.quarentena.id
rule {
id = "aborta-multipart-incompleto"
status = "Enabled"
# Uma parte enviada e nunca concluida (aba fechada, rede caiu na loja)
# continua ocupando armazenamento e sendo cobrada — o S3 NAO a apaga
# sozinho. E' o achado de custo mais caro deste laboratorio, e sem esta
# regra ele nasce sem protecao nenhuma.
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
rule {
id = "remove-quarentena-orfa"
status = "Enabled"
# Se o verificador falhar em laco e a mensagem esgotar as tentativas ate
# a DLQ, o objeto fica em quarentena sem veredito para sempre. Este prazo
# e' o backstop de TEMPO; a DLQ e' o backstop de ALARME — sao proteccoes
# diferentes, e a tabela de seguranca cobre as duas.
expiration {
days = 3
}
}
}
resource "aws_s3_bucket" "publicado" {
bucket = "${var.projeto}-laudos-publicados"
}
resource "aws_s3_bucket_public_access_block" "publicado" {
bucket = aws_s3_bucket.publicado.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "publicado" {
bucket = aws_s3_bucket.publicado.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.laudos.arn
}
}
}
# O bucket publicado tambem recebe multipart em tese (copia grande do
# verificador), entao a mesma protecao de custo se aplica aqui.
resource "aws_s3_bucket_lifecycle_configuration" "publicado" {
bucket = aws_s3_bucket.publicado.id
rule {
id = "aborta-multipart-incompleto"
status = "Enabled"
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
Multipart incompleto cobra até alguém apagar
Cada parte enviada de um multipart upload é armazenamento cobrado, mesmo que o upload nunca seja concluído. Um formulário mal implementado que reinicia o upload sem abortar o anterior, ou um usuário que fecha a aba no meio, deixa partes órfãs que não aparecem em nenhuma listagem comum de objetos — só em `list-multipart-uploads`. Sem `abort_incomplete_multipart_upload`, essa dívida cresce sozinha, mês após mês, sem nenhum alarme avisando.
CORS restrito ao domínio, nunca `*`
Um bucket com `allowed_origins = ["*"]` aceita upload iniciado por JavaScript rodando em QUALQUER site que tenha conseguido uma assinatura válida — e como a assinatura em si já limita chave e tamanho, a tentação de liberar CORS geral parece inofensiva. Não é: ela também permite que um site hostil leia a resposta do S3 via JavaScript, incluindo o ETag, e use isso para inferir se um objeto já existe.
Construir: o gatilho de validação, e os dois papéis IAM
O S3 não fala com a fila diretamente aqui: o EventBridge fica no meio para que um segundo consumidor — auditoria, métricas, o L28 — possa se inscrever sem alterar o bucket nem a fila existente.
# eventos.tf — o gatilho que desacopla o S3 de quem consome
resource "aws_s3_bucket_notification" "quarentena_para_eventbridge" {
bucket = aws_s3_bucket.quarentena.id
eventbridge = true # delega ao EventBridge em vez de assinar SNS/SQS direto na notificacao
}
resource "aws_cloudwatch_event_rule" "laudo_criado" {
name = "${var.projeto}-laudo-criado"
event_pattern = jsonencode({
source = ["aws.s3"]
detail-type = ["Object Created"]
detail = {
bucket = { name = [aws_s3_bucket.quarentena.id] }
object = { key = [{ prefix = "lojas/" }] }
}
})
}
resource "aws_cloudwatch_event_target" "para_fila" {
rule = aws_cloudwatch_event_rule.laudo_criado.name
arn = aws_sqs_queue.verificacao.arn
}
resource "aws_sqs_queue" "verificacao_dlq" {
name = "${var.projeto}-laudos-verificacao-dlq"
message_retention_seconds = 1209600 # 14 dias — tempo de sobra para investigar antes de perder o evento
}
resource "aws_sqs_queue" "verificacao" {
name = "${var.projeto}-laudos-verificacao"
# >= tempo que o worker leva para ler o cabecalho, rodar o antivirus e
# copiar. Curto demais reentrega a MESMA mensagem enquanto ainda esta sendo
# processada, e dois workers competem pelo mesmo laudo.
visibility_timeout_seconds = 120
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.verificacao_dlq.arn
maxReceiveCount = 5
})
}
resource "aws_sqs_queue_policy" "permite_eventbridge" {
queue_url = aws_sqs_queue.verificacao.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "events.amazonaws.com" }
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.verificacao.arn
Condition = {
ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.laudo_criado.arn }
}
}]
})
}
# O alarme que fecha o laco: mensagem parada na DLQ e' um laudo que ninguem
# vai processar sozinho. Sem isto, a fila 3 desta seccao vira um cemiterio
# silencioso.
resource "aws_cloudwatch_metric_alarm" "dlq_com_mensagem" {
alarm_name = "${var.projeto}-laudos-dlq-com-mensagem"
namespace = "AWS/SQS"
metric_name = "ApproximateNumberOfMessagesVisible"
statistic = "Maximum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
dimensions = { QueueName = aws_sqs_queue.verificacao_dlq.name }
alarm_actions = [aws_sns_topic.alertas.arn]
}
# iam.tf — dois papeis, dois raciocinios de menor privilegio
# A API SO PRECISA poder assinar o que a propria credencial autoriza — porque
# a assinatura HERDA exatamente essa permissao (e' o que a documentacao de
# presigned URL confirma: "limited by the permissions of the user who
# creates it"). Escopar ao prefixo "lojas/" e' o que garante que um bug na
# aplicacao que esqueca o `starts-with` na politica nao alcance o bucket
# inteiro — so alcancaria a arvore de prefixos que a IAM ja autoriza.
data "aws_iam_policy_document" "api_assina" {
statement {
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.quarentena.arn}/lojas/*"]
}
statement {
effect = "Allow"
actions = ["dynamodb:GetItem", "dynamodb:Query"]
resources = [aws_dynamodb_table.laudos.arn, "${aws_dynamodb_table.laudos.arn}/index/*"]
}
}
resource "aws_iam_role_policy" "api_assina" {
role = aws_iam_role.api_task.id
policy = data.aws_iam_policy_document.api_assina.json
}
# O verificador le e apaga da quarentena, escreve no publicado, consome a
# fila e grava o veredito. Nenhuma dessas quatro acoes precisa da OUTRA
# permissao — e' a mesma logica de execution role vs task role do L03,
# aplicada a um segundo servico com responsabilidade diferente.
data "aws_iam_policy_document" "worker_verifica" {
statement {
effect = "Allow"
actions = ["s3:GetObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.quarentena.arn}/*"]
}
statement {
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.publicado.arn}/*"]
}
statement {
effect = "Allow"
actions = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
resources = [aws_sqs_queue.verificacao.arn]
}
statement {
effect = "Allow"
actions = ["dynamodb:PutItem", "dynamodb:UpdateItem"]
resources = [aws_dynamodb_table.laudos.arn]
}
statement {
effect = "Allow"
actions = ["kms:Decrypt", "kms:GenerateDataKey"]
resources = [aws_kms_key.laudos.arn]
}
}
resource "aws_iam_role_policy" "worker_verifica" {
role = aws_iam_role.worker_task.id
policy = data.aws_iam_policy_document.worker_verifica.json
}
Por que a permissão de assinar é o verdadeiro limite, não só a política
A política POST que a API monta é a fronteira que o CLIENTE não pode mover. Mas existe uma fronteira anterior: a API só consegue assinar operações que a PRÓPRIA task role permite. Se essa role tivesse `s3:PutObject` no bucket inteiro em vez de escopado a `lojas/*`, um bug que esquecesse o `starts-with` na política teria alcance total — a IAM é a rede sob a rede, não um detalhe de menor privilégio genérico.
Construir: a API que só assina
O SDK da AWS para .NET não tem um método pronto equivalente ao `generate_presigned_post` do boto3 — por isso a assinatura é montada com a cadeia de HMAC do próprio SigV4, o mesmo processo documentado para qualquer requisição assinada.
// AssinadorPostS3.cs — a API decide e assina; nunca ve o corpo do arquivo
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Amazon.Runtime;
namespace Cadencia.Api.Laudos;
public sealed record PostAssinado(string Url, Dictionary<string, string> Campos, DateTimeOffset ValidoAte);
public sealed class AssinadorPostS3
{
// 220 MiB: o laudo declarado nunca passa de 200 MB, e a folga cobre o
// overhead do multipart/form-data (campos, boundary) sem abrir mao do
// teto. O numero vem da hipotese declarada na abertura do modulo — se a
// sua maior peca do laudo mudar (por exemplo, video em resolucao maior),
// este valor tem de ser revisado, nao herdado.
public const long TamanhoMaximoLaudoBytes = 230_686_720;
private const long TamanhoMinimoBytes = 1024; // barra so o POST vazio ou de teste
private readonly string _bucket;
private readonly string _regiao;
public AssinadorPostS3(string bucket, string regiao) => (_bucket, _regiao) = (bucket, regiao);
// O SDK da AWS para .NET NAO tem um `generate_presigned_post` pronto
// como o boto3 tem em Python — por isso a cadeia de HMAC abaixo e'
// montada a mao, seguindo o MESMO processo de assinatura que qualquer
// requisicao SigV4 usa. Aqui o "corpo" assinado e' a politica JSON, nao
// uma requisicao HTTP inteira.
public async Task<PostAssinado> AssinarAsync(string chaveObjeto, string lojaId, TimeSpan validade)
{
var credenciais = await ObterCredenciaisDaTaskAsync();
var agora = DateTimeOffset.UtcNow;
var dataCurta = agora.ToString("yyyyMMdd");
var dataCompleta = agora.ToString("yyyyMMddTHHmmssZ");
var escopo = $"{dataCurta}/{_regiao}/s3/aws4_request";
var credencial = $"{credenciais.AccessKey}/{escopo}";
// Cada condicao aqui e' uma fronteira que o CLIENTE nao pode mover:
// a politica inteira e' o que se assina, entao mexer em qualquer
// campo — inclusive o teto de tamanho — invalida a assinatura.
var condicoes = new List<object>
{
new Dictionary<string, string> { ["bucket"] = _bucket },
new object[] { "starts-with", "$key", chaveObjeto },
new object[] { "content-length-range", TamanhoMinimoBytes, TamanhoMaximoLaudoBytes },
new Dictionary<string, string> { ["x-amz-meta-loja-id"] = lojaId },
new Dictionary<string, string> { ["x-amz-algorithm"] = "AWS4-HMAC-SHA256" },
new Dictionary<string, string> { ["x-amz-credential"] = credencial },
new Dictionary<string, string> { ["x-amz-date"] = dataCompleta },
};
if (!string.IsNullOrEmpty(credenciais.Token))
condicoes.Add(new Dictionary<string, string> { ["x-amz-security-token"] = credenciais.Token });
var politica = new Dictionary<string, object>
{
// O prazo e' curto de proposito: 10 min cobre o tempo de UM
// upload na banda mais lenta que a Cadencia suporta hoje (8
// Mbit/s de subida, ~200 s para 200 MB), com folga — nao
// verifiquei se a AWS reavalia a expiracao so no INICIO da
// requisicao ou durante toda a transferencia, entao a folga e'
// deliberada, nao um numero decorado.
["expiration"] = agora.Add(validade).ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
["conditions"] = condicoes,
};
var politicaJson = JsonSerializer.Serialize(politica);
var politicaBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(politicaJson));
static byte[] Hmac(byte[] chave, string dado) =>
new HMACSHA256(chave).ComputeHash(Encoding.UTF8.GetBytes(dado));
// A cadeia de derivacao do SigV4: quatro HMACs encadeados, cada um
// amarrando a assinatura a uma dimensao (dia, regiao, servico, tipo
// de request) sem nunca reusar a chave secreta diretamente.
var kData = Hmac(Encoding.UTF8.GetBytes("AWS4" + credenciais.SecretKey), dataCurta);
var kRegiao = Hmac(kData, _regiao);
var kServico = Hmac(kRegiao, "s3");
var kAssinatura = Hmac(kServico, "aws4_request");
var assinatura = Convert.ToHexString(Hmac(kAssinatura, politicaBase64)).ToLowerInvariant();
var campos = new Dictionary<string, string>
{
["key"] = chaveObjeto,
["policy"] = politicaBase64,
["x-amz-algorithm"] = "AWS4-HMAC-SHA256",
["x-amz-credential"] = credencial,
["x-amz-date"] = dataCompleta,
["x-amz-signature"] = assinatura,
["x-amz-meta-loja-id"] = lojaId,
};
if (!string.IsNullOrEmpty(credenciais.Token))
campos["x-amz-security-token"] = credenciais.Token;
return new PostAssinado($"https://{_bucket}.s3.{_regiao}.amazonaws.com/", campos, agora.Add(validade));
}
private static async Task<ImmutableCredentials> ObterCredenciaisDaTaskAsync()
{
var credenciais = FallbackCredentialsFactory.GetCredentials();
return await credenciais.GetCredentialsAsync();
}
}
// Program.cs (trecho) — a rota que so assina, nunca le o arquivo
app.MapPost("/api/laudos/assinatura", async (
PedidoAssinatura pedido, ClaimsPrincipal usuario, AppDb db, AssinadorPostS3 assinador) =>
{
var lojaId = usuario.FindFirstValue("loja_id")
?? throw new InvalidOperationException("sessao sem loja associada");
// A cota impede que uma loja sozinha consuma o orcamento mensal de
// armazenamento das outras 29 — controle de negocio, nao de bytes.
var cota = await db.CotasDeLoja.FindAsync(lojaId);
if (cota is null || cota.LaudosNoMes >= cota.LimiteMensal)
return Results.Json(new { erro = "cota mensal de laudos atingida" }, statusCode: 429);
if (pedido.TamanhoDeclaradoBytes is < 1024 or > AssinadorPostS3.TamanhoMaximoLaudoBytes)
return Results.BadRequest(new { erro = "tamanho declarado fora da faixa aceita" });
// A extensao vem do TIPO ACEITO pela aplicacao, nunca do nome que o
// cliente mandou — e' o mesmo raciocinio do L03 para tag de imagem:
// identidade decidida pelo servidor, nao aceita por confianca no cliente.
var extensao = ExtensaoParaTipo(pedido.TipoMime)
?? throw new ArgumentException($"tipo nao aceito: {pedido.TipoMime}");
var chave = $"lojas/{lojaId}/laudos/{DateTime.UtcNow:yyyy}/{Guid.NewGuid():N}.{extensao}";
var assinado = await assinador.AssinarAsync(chave, lojaId, TimeSpan.FromMinutes(10));
return Results.Ok(new
{
url = assinado.Url,
campos = assinado.Campos,
validoAte = assinado.ValidoAte,
});
});
public sealed record PedidoAssinatura(long TamanhoDeclaradoBytes, string TipoMime);
A chave secreta nunca sai do processo, e é isso que sustenta o desenho
A `SecretKey` usada nos HMACs vem da credencial temporária da própria task — ela nunca é enviada ao navegador nem aparece na resposta. O que sai é só o resultado: a assinatura hexadecimal e a política em base64. Se algum dia essa função vazar a chave secreta num log de depuração, qualquer pessoa com o log consegue assinar políticas arbitrárias até a credencial expirar — trate isso como exposição de credencial, não como bug de log.
O prazo pedido pode não ser o prazo entregue
Se a task role da API girar a cada 2 horas — dentro da faixa de 1 a 6 h que o ECS documenta — uma assinatura pedida por 10 minutos nunca sente esse limite. Mas se alguém copiar este código para um contexto que usa `AssumeRole` com sessão de 1 hora, e pedir uma validade de 2 horas por engano, a URL morre com a sessão, não com o valor pedido — silenciosamente, sem nenhum erro no momento da assinatura.
Construir: multipart, e o controle de admissão que falta
Os laudos de hoje cabem no POST simples. Esta seção existe porque a certificação cobra multipart, e porque o dia em que um vídeo de manuseio passar de alguns gigabytes chega — e nesse dia, o teto de tamanho deixa de ser algo que o S3 verifica sozinho.
| Item | Limite confirmado na documentação |
|---|---|
| Tamanho de objeto | até 48,8 TiB |
| Tamanho de cada parte | 5 MiB a 5 GiB — sem mínimo na última parte |
| Número de partes | até 10.000 por upload |
| Condição de tamanho na iniciação ou nas partes | nenhuma — é o ponto central desta seção |
// Multipart.cs — quando o POST simples nao basta, e o teto de tamanho vira
// DECISAO DA APLICACAO, porque o S3 nao oferece condicao nenhuma para isso
using System.Collections.Concurrent;
using Amazon.S3;
using Amazon.S3.Model;
namespace Cadencia.Api.Laudos;
// Em producao isto e' uma linha por upload no DynamoDB com TTL, nao memoria
// do processo — a task pode reiniciar no meio de um upload de 40 minutos, e
// o contador precisa sobreviver a isso.
public sealed class ControleDeAdmissaoMultipart
{
private readonly ConcurrentDictionary<string, long> _bytesDeclaradosPorUpload = new();
public bool AutorizarParte(string uploadId, long tamanhoDaParteBytes, long tetoTotalBytes)
{
var acumulado = _bytesDeclaradosPorUpload.AddOrUpdate(
uploadId, tamanhoDaParteBytes, (_, atual) => atual + tamanhoDaParteBytes);
// O S3 aceitaria a parte de qualquer jeito: multipart nao tem
// `content-length-range`. Quem recusa aqui e' a aplicacao, ANTES de
// emitir a URL assinada da parte seguinte — recusar so na conclusao,
// depois de todos os bytes ja terem subido, nao economiza nada.
if (acumulado > tetoTotalBytes)
{
_bytesDeclaradosPorUpload.TryRemove(uploadId, out _);
return false;
}
return true;
}
public void Concluir(string uploadId) => _bytesDeclaradosPorUpload.TryRemove(uploadId, out _);
}
public static class RotasMultipart
{
public static void Mapear(WebApplication app)
{
app.MapPost("/api/laudos/multipart/iniciar", async (
PedidoIniciarMultipart pedido, IAmazonS3 s3, ClaimsPrincipal usuario) =>
{
var lojaId = usuario.FindFirstValue("loja_id")!;
var chave = $"lojas/{lojaId}/laudos/{DateTime.UtcNow:yyyy}/{Guid.NewGuid():N}.mp4";
var resposta = await s3.InitiateMultipartUploadAsync(new InitiateMultipartUploadRequest
{
BucketName = ConfigLaudos.BucketQuarentena,
Key = chave,
ServerSideEncryptionMethod = ServerSideEncryptionMethod.AWSKMS,
});
return Results.Ok(new { uploadId = resposta.UploadId, chave });
});
app.MapPost("/api/laudos/multipart/parte", (
PedidoParte pedido, IAmazonS3 s3, ControleDeAdmissaoMultipart controle) =>
{
if (!controle.AutorizarParte(pedido.UploadId, pedido.TamanhoDeclaradoBytes,
AssinadorPostS3.TamanhoMaximoLaudoBytes))
{
// A soma passou do teto: aborta o upload INTEIRO agora, nao so
// recusa a parte. Uma parte recusada sem abortar deixa as
// anteriores penduradas, cobrando ate o ciclo de vida
// alcanca-las em 1 dia — este cancelamento e' o que evita essa
// espera.
s3.AbortMultipartUploadAsync(ConfigLaudos.BucketQuarentena, pedido.Chave, pedido.UploadId);
return Results.BadRequest(new { erro = "soma das partes excede o limite do laudo" });
}
var url = s3.GetPreSignedURL(new GetPreSignedUrlRequest
{
BucketName = ConfigLaudos.BucketQuarentena,
Key = pedido.Chave,
Verb = HttpVerb.PUT,
Expires = DateTime.UtcNow.AddMinutes(10),
UploadId = pedido.UploadId,
PartNumber = pedido.NumeroDaParte,
});
return Results.Ok(new { url });
});
app.MapPost("/api/laudos/multipart/concluir", async (
PedidoConcluirMultipart pedido, IAmazonS3 s3, ControleDeAdmissaoMultipart controle) =>
{
var req = new CompleteMultipartUploadRequest
{
BucketName = ConfigLaudos.BucketQuarentena,
Key = pedido.Chave,
UploadId = pedido.UploadId,
PartETags = pedido.Partes.Select(p => new PartETag(p.NumeroDaParte, p.ETag)).ToList(),
};
await s3.CompleteMultipartUploadAsync(req);
controle.Concluir(pedido.UploadId);
return Results.Ok();
});
}
}
public sealed record PedidoIniciarMultipart(long TamanhoTotalDeclaradoBytes);
public sealed record PedidoParte(string UploadId, string Chave, int NumeroDaParte, long TamanhoDeclaradoBytes);
public sealed record PedidoConcluirMultipart(string UploadId, string Chave, List<ParteConcluida> Partes);
public sealed record ParteConcluida(int NumeroDaParte, string ETag);
Sem admissão própria, multipart não tem teto nenhum
Nem `InitiateMultipartUpload`, nem `UploadPart`, nem `CompleteMultipartUpload` aceitam uma condição de tamanho total. Um cliente pode enviar 10.000 partes de 5 GiB cada — os 48,8 TiB do limite de objeto — e o S3 completa o upload sem reclamar. A única fronteira é a que a aplicação constrói somando o que cada parte declara, e recusando ANTES de emitir a URL da parte seguinte.
Abortar o upload é parte do controle, não um extra
Recusar a parte sem chamar `AbortMultipartUploadAsync` deixa as partes já aceitas penduradas no S3, cobrando armazenamento até a regra de ciclo de vida alcançá-las — até 24 horas depois, no desenho deste módulo. Abortar na hora fecha a cobrança no mesmo instante em que a decisão foi tomada.
Construir: o verificador
Um serviço de fundo, sem rota HTTP, que só existe para transformar "objeto apareceu" em "objeto está apto". Ele lê pouco, decide, e apaga o que processou.
// ConsumidorDeLaudos.cs — le a fila, decide, e so entao move o objeto
using System.Text.Json;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.SQS;
using Amazon.SQS.Model;
namespace Cadencia.Worker.Laudos;
public sealed class ConsumidorDeLaudos(
IAmazonSQS sqs, IAmazonS3 s3, ILogger<ConsumidorDeLaudos> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var resposta = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = ConfigLaudos.FilaVerificacaoUrl,
MaxNumberOfMessages = 5,
WaitTimeSeconds = 20, // long poll: menos requisicoes vazias, que custam o mesmo
}, ct);
foreach (var msg in resposta.Messages)
{
try
{
await ProcessarAsync(msg, ct);
await sqs.DeleteMessageAsync(ConfigLaudos.FilaVerificacaoUrl, msg.ReceiptHandle, ct);
}
catch (Exception ex)
{
// NAO deleta: a mensagem volta a ficar visivel apos o
// timeout, e depois de 5 tentativas vai para a DLQ. Sem
// essa fila, um erro aqui perde o evento e o laudo fica
// orfao em quarentena sem ninguem notar.
log.LogError(ex, "falha ao verificar laudo, mensagem retorna a fila");
}
}
}
}
private async Task ProcessarAsync(Message msg, CancellationToken ct)
{
var evento = JsonSerializer.Deserialize<EventoObjetoCriado>(msg.Body)!;
var bucketOrigem = evento.Detail.Bucket.Name;
var chave = evento.Detail.Object.Key;
// Le so os primeiros 512 bytes: o bastante para conferir a
// assinatura binaria (numero magico) do arquivo sem baixar 200 MB
// para decidir se o tipo declarado bate com o tipo real.
using var cabecalho = await s3.GetObjectAsync(new GetObjectRequest
{
BucketName = bucketOrigem,
Key = chave,
ByteRange = new ByteRange(0, 511),
}, ct);
var tipoReal = SniffarTipo(cabecalho.ResponseStream);
var tipoOk = TipoEhAceito(tipoReal);
var antivirusOk = tipoOk && await ChecarAntivirusAsync(bucketOrigem, chave, ct);
var aprovado = tipoOk && antivirusOk;
if (aprovado)
{
await s3.CopyObjectAsync(new CopyObjectRequest
{
SourceBucket = bucketOrigem,
SourceKey = chave,
DestinationBucket = ConfigLaudos.BucketPublicado,
DestinationKey = chave,
ServerSideEncryptionMethod = ServerSideEncryptionMethod.AWSKMS,
}, ct);
}
// O objeto some da quarentena nos dois casos: aprovado, porque ja foi
// copiado; recusado, porque quarentena nao e' deposito permanente de
// arquivo malicioso.
await s3.DeleteObjectAsync(bucketOrigem, chave, ct);
await GravarVeredictoAsync(chave, aprovado, tipoReal, evento.Detail.Object.Size, ct);
}
}
Por que ler 512 bytes basta para o sniff de tipo
A assinatura binária que identifica a maioria dos formatos — PDF, JPEG, MP4, ZIP — está nos primeiros bytes do arquivo. Ler um `ByteRange` pequeno em vez do objeto inteiro é o que faz o verificador continuar barato mesmo quando o laudo tem 200 MB: o custo de processá-lo deixou de crescer com o tamanho, exatamente como o custo de recebê-lo.
Implantar, e provar que o byte não passou pela task
Cinco provas. A terceira é a que mais gente pula, e é a que sustenta o achado central do módulo: sem nenhuma configuração adicional, uma URL PUT assinada aceita qualquer tamanho.
#!/usr/bin/env bash
# provas.sh — cinco medicoes; nenhuma conclusao vem de "parece que funcionou"
set -euo pipefail
DOMINIO="$(terraform output -raw dominio)"
BUCKET_QUAR="$(terraform output -raw bucket_quarentena)"
FILA="$(terraform output -raw fila_verificacao_url)"
# ── Prova 1: a memoria da task NAO se move mais com o tamanho do arquivo ────
# Gera um laudo de 200 MB e envia PELA URL ASSINADA. Observe a memoria da
# task da API enquanto o upload roda: ela nao deve variar com o tamanho.
dd if=/dev/urandom of=/tmp/laudo-200mb.pdf bs=1M count=200
ASSINATURA=$(curl -s -X POST "https://${DOMINIO}/api/laudos/assinatura" \
-H 'Content-Type: application/json' \
-d '{"tamanhoDeclaradoBytes": 209715200, "tipoMime": "application/pdf"}')
URL=$(echo "$ASSINATURA" | jq -r '.url')
# ... monta o multipart/form-data com os campos de "campos" e faz o POST ...
# Esperado: MemoryUtilized da task da API (CloudWatch Container Insights)
# continua achatada durante o upload inteiro — quem sobe e desce e' o S3.
# ── Prova 2: o teto de tamanho e' recusado pelo S3, nao pela aplicacao ──────
# Um arquivo de 250 MB, acima dos 220 MB assinados, tem de ser recusado ANTES
# de qualquer byte ser gravado.
dd if=/dev/urandom of=/tmp/laudo-250mb.pdf bs=1M count=250
# Reusa a mesma assinatura (que permite ate 220 MB) e tenta enviar o de 250.
# Esperado: HTTP 400 do S3, com <Code>EntityTooLarge</Code> no corpo XML.
# Se isto passar como 200, a condicao content-length-range nao foi aplicada.
# ── Prova 3: PUT assinado SEM Content-Length nao tem teto nenhum ────────────
# A comparacao que sustenta o achado central do modulo.
python3 - <<'PY'
import boto3
s3 = boto3.client("s3")
print(s3.generate_presigned_url("put_object",
Params={"Bucket": "ffv-lab-laudos-quarentena", "Key": "teste/sem-teto.bin"},
ExpiresIn=600))
PY
# Envie um arquivo de qualquer tamanho pela URL acima com curl -T. Esperado:
# aceita 500 MB do mesmo jeito que aceitaria 5 KB — nao ha condicao de
# tamanho embutida numa PUT assinada sem cabecalhos adicionais.
# ── Prova 4: o multipart incompleto realmente cobra, e a regra o mata ───────
aws s3api create-multipart-upload --bucket "$BUCKET_QUAR" \
--key "teste/abandonado.bin" --query 'UploadId' --output text
# NAO conclua o upload. Espere 25h e confira:
aws s3api list-multipart-uploads --bucket "$BUCKET_QUAR" \
--query 'Uploads[?Key==`teste/abandonado.bin`]'
# Esperado apos 25h: lista vazia — a regra de ciclo de vida de 1 dia abortou.
# Antes das 25h, o upload aparece, e cada parte enviada esta sendo cobrada.
# ── Prova 5: o veredito chega, e o tempo entre upload e aprovacao e' medido ─
INICIO=$(date +%s)
# ... repita o upload da prova 1 ...
aws sqs receive-message --queue-url "$FILA" --wait-time-seconds 20 >/dev/null
FIM=$(date +%s)
echo "latencia de validacao: $((FIM - INICIO))s"
# Nao ha SLA documentado para isto — meca a distribuicao real no seu ambiente
# antes de prometer um numero a quem consome o veredito.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Memória não varia com o tamanho | Container Insights durante o upload | linha achatada de `MemoryUtilized` na task da API | memória subindo com o tamanho do arquivo indica que o byte ainda passa pela task |
| 2 · Teto aplicado antes de gravar | envio de arquivo maior que o assinado | 400 com `EntityTooLarge`, nenhum objeto criado | se o objeto aparece no bucket, `content-length-range` não foi assinada corretamente |
| 3 · PUT sem teto realmente não tem teto | PUT assinado sem cabeçalho extra | arquivo de qualquer tamanho é aceito | se isso te surpreendeu, é exatamente o achado que este módulo existe para provar |
| 4 · Multipart incompleto cobra até ser abortado | `list-multipart-uploads` após 25h | lista vazia — a regra de 1 dia abortou | se a parte ainda aparece, a regra de ciclo de vida não está aplicada ao bucket certo |
| 5 · O veredito chega em tempo mensurável | cronômetro entre upload e mensagem | um número, não uma sensação | sem medir, "a validação está lenta" não tem como virar decisão de capacidade |
A equipe da Cadência gera uma URL `PUT` pré-assinada informando apenas bucket, chave e prazo de expiração — sem nenhum cabeçalho adicional assinado. Um laudo de 5 GB é enviado por essa URL, mesmo a aplicação esperando no máximo 200 MB. O que acontece?
Quebrar de propósito: três falhas e o diagnóstico
As três produzem o mesmo sintoma superficial no navegador — "não consegui enviar" — e são causas completamente diferentes.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| CORS ausente no bucket | remova o `aws_s3_bucket_cors_configuration` e tente enviar pelo navegador | erro de rede genérico no console; às vezes o objeto aparece no bucket mesmo assim | aba Network do navegador: a requisição ao S3 mostra `blocked by CORS policy` | configurar CORS com o domínio exato da loja em `allowed_origins` |
| Content-length-range mal calculado | assine a política com máximo em bytes, mas envie um valor em bits por engano | todo upload é recusado com `EntityTooLarge`, mesmo arquivos pequenos | decodifique o campo `policy` (base64) e confira o array de `content-length-range` | unidade correta é byte, não bit nem MB — confira o cálculo antes de assinar |
| Task role de assinatura ampla demais | dê `s3:PutObject` no bucket inteiro à task da API, sem prefixo | nenhum sintoma imediato — o desenho continua funcionando igual | IAM Access Analyzer, ou revisão manual de `resources` na policy | restringir a `arn:...:bucket/lojas/*`, para que um bug de política não vire acesso total |
A falha que não aparece em nenhum log até o incidente
Uma task role de assinatura ampla é invisível em operação normal: tudo continua funcionando, porque a política assinada de qualquer upload legítimo já restringe a chave. O problema só se manifesta se um bug de código — um `starts-with` esquecido, uma condição comentada durante um debug e não devolvida — deixar de restringir a chave na política. Nesse dia, a única coisa que ainda protege o resto do bucket é a IAM, e não o código que todo mundo revisou.
Segurança: o que muda quando o cliente fala direto com o S3
A superfície deixa de ser "quem pode chamar minha API" e passa a incluir "o que alguém consegue fazer com uma assinatura legítima, mas usada de um jeito que a aplicação não previu".
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Arquivo malicioso publicado sem verificação | baixa | alto | quarentena separada; cópia ao bucket público só após veredito aprovado | métricas do verificador e da DLQ | remover objeto do bucket publicado; investigar como passou pela checagem |
| Upload de tamanho não previsto pela banda estimada | média | baixo | `content-length-range` recusa antes de gravar | alarme de `EntityTooLarge` no CloudTrail de eventos de dados do S3 | revisar o teto com a distribuição real de tamanho medida |
| Assinatura reaproveitada após o prazo | baixa | baixo | validade curta (10 min) e `x-amz-date` amarrado à assinatura | o próprio S3 recusa com `RequestTimeTooSkewed` ou expiração | nenhuma — a URL simplesmente para de funcionar |
| Loja esgotando cota das outras 29 com envios paralelos | média | médio | este laboratório NÃO resolve — falta limite de requisições na rota de assinatura | contagem de assinaturas emitidas por loja por minuto | aplicar limite de requisições; é dívida registrada na seção de decisões |
| Objeto órfão em quarentena por falha do verificador | média | baixo | DLQ com alarme; expiração de 3 dias no ciclo de vida da quarentena | alarme de mensagem visível na DLQ | reprocessar da DLQ manualmente após corrigir a causa |
| Task role de assinatura mais ampla que o necessário | baixa | alto | escopo por prefixo `lojas/*`, nunca o bucket inteiro | IAM Access Analyzer sobre uso real | restringir o `Resource` da policy ao prefixo — é o L41 |
CORS não é controle de segurança contra servidor a servidor
CORS é aplicado pelo NAVEGADOR, não pelo S3. Um script rodando fora de um navegador — um `curl`, um worker em outra conta — ignora CORS completamente e usa a URL assinada normalmente. A proteção real contra uso indevido da URL é o prazo curto e as condições da política, não a origem declarada em CORS.
Observabilidade: as perguntas que o painel tem de responder
Como a validação agora é assíncrona, o painel tem uma responsabilidade nova: dizer se um laudo está preso entre "chegou" e "foi decidido".
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| A task da API voltou a crescer em memória com upload? | `MemoryUtilized` da task | algum caminho voltou a materializar o arquivo em memória | variação > 20% durante upload |
| Quantos laudos estão presos entre upload e veredito? | `ApproximateNumberOfMessagesVisible` | backlog do verificador crescendo mais rápido do que ele processa | > 50 por 5 min |
| Algum laudo está definitivamente perdido? | `ApproximateNumberOfMessagesVisible` na DLQ | o verificador falhou 5 vezes seguidas no mesmo objeto | qualquer valor > 0 |
| Quanto tempo entre upload e veredito? | diferença de timestamp entre evento e gravação no Dynamo | a fila está saudável mas o verificador está lento | p95 acima de 2 min |
| Quantos uploads estão sendo recusados por tamanho? | contagem de 400 `EntityTooLarge` no S3 | lojas tentando enviar acima do esperado — hipótese do módulo pode estar errada | > 5% dos uploads no dia |
| Há multipart abandonado se acumulando? | `list-multipart-uploads` periódico | a regra de ciclo de vida não está aplicada ou não está funcionando | qualquer upload com mais de 25h |
A métrica que não existe: "upload em andamento"
Como o byte vai direto ao S3, a aplicação não sabe que um upload está em progresso até ele terminar — ou até nunca terminar. Não há evento de "início de upload" nem "progresso", só "objeto criado". Quem precisa de barra de progresso confiável para o usuário depende do próprio navegador medir, não do backend.
Escala: 10, 10 mil, 1 milhão
| Volume | O que acontece | O que passa a doer | O que fazer |
|---|---|---|---|
| 40 laudos/dia, pico às 18h | a fila absorve o pico sem esforço | nada; é o cenário deste laboratório | nada |
| 500 laudos/dia | o verificador com um único worker começa a atrasar no pico | latência de veredito sobe, mas nada quebra — a fila segura o volume | aumentar `desired_count` do serviço verificador, que é sem estado |
| 10 mil laudos/dia | concorrência de assinatura na API cresce junto | a tabela de cota no DynamoDB pode virar hot partition se a chave for só `lojaId` | confirmar padrão de acesso e considerar sufixo de particionamento por dia |
| 1 milhão de laudos/dia (hipotético, fora da escala da Cadência) | volume de eventos do EventBridge e mensagens SQS em outra ordem de grandeza | custo de EventBridge por evento processado passa a aparecer na fatura | considerar Batch Operations do S3 ou agregação de eventos antes de disparar por objeto |
| Falha de uma AZ | S3, SQS, EventBridge e DynamoDB são serviços regionais | nada estrutural — ao contrário do ALB/ECS do L03, não há pinning de sub-rede aqui | nada a fazer neste desenho; é uma simplificação real, não uma omissão |
O upload em si não tem limite de taxa por conta documentado como fixo
O S3 escala automaticamente a taxa de requisições PUT por prefixo, mas ainda vale espalhar a carga: a chave `lojas/{lojaId}/laudos/{ano}/{uuid}` já distribui por loja e ano, o que evita que todos os objetos do dia colidam no mesmo prefixo quente. Não testei o comportamento sob os 500 uploads simultâneos — meça antes de prometer.
Custo: o que este laboratório acrescenta à fatura
A troca de desenho não é gratuita: ela move o custo de computação (task) para algumas dimensões novas, quase todas pequenas — exceto uma, se ninguém configurar o ciclo de vida.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Hoje | 40 laudos/dia, ~200 MB médios | armazenamento dos dois buckets, poucas centenas de eventos e mensagens por dia | desprezível frente ao custo da task que deixou de estourar | nenhuma; otimizar aqui é atenção gasta onde não há dinheiro |
| Se 20% dos uploads forem abandonados sem a regra de ciclo de vida | 8 uploads/dia nunca concluídos, partes de até 200 MB cada | armazenamento de multipart incompleto crescendo todo dia, sem limite | linear e sem teto — é o cenário que a regra de ciclo de vida existe para evitar | a única otimização real é a regra em si; sem ela não há ajuste fino que resolva |
| Alta escala hipotética (10 mil laudos/dia) | volume de eventos do EventBridge e mensagens SQS cresce proporcionalmente | linha visível de EventBridge e SQS na fatura, além do armazenamento | cresce com o volume de laudos, não com o tamanho deles | agregar eventos ou filtrar mais cedo reduz o número de mensagens processadas |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| Armazenamento S3 (dois buckets) | GB-mês, e por classe de armazenamento | quarentena deveria ter volume médio baixo — objeto aprovado sai rápido; objeto preso ali por muito tempo é sintoma, não custo normal |
| Partes de multipart incompleto | GB-mês, cobrado como se fosse objeto completo | é a linha que cresce sozinha sem a regra de ciclo de vida — o achado de custo mais caro deste módulo |
| Requisições PUT/POST ao S3 | por mil requisições | proporcional ao número de laudos, não ao tamanho — 200 MB custa o mesmo que 1 KB nesta dimensão |
| EventBridge e SQS | por evento e por requisição, respectivamente | uma mensagem por objeto novo; não escala com o tamanho do laudo |
| Computação do verificador (Fargate) | vCPU-segundo e GB-segundo | lê só 512 bytes por laudo mais o tempo de cópia — muito menor que materializar o arquivo inteiro, que era o custo do desenho mínimo |
O custo que desapareceu, e onde ele estava escondido
No desenho mínimo, cada laudo pagava NAT Gateway duas vezes — na entrada até a task e na saída até o S3 — além de vCPU-segundo proporcional ao tempo de upload inteiro. Esse custo nunca apareceu como linha isolada na fatura; estava diluído no total de Fargate e de processamento de dados do NAT. Ele não aparece mais, e não há como medi-lo depois de removido — só antes, se alguém tivesse isolado a métrica.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | fila com DLQ e alarme dão visibilidade de falha assíncrona | ninguém drena a DLQ até o alarme disparar | playbook de redrive documentado e testado | alta |
| Segurança | assinatura escopada por IAM, CORS restrito, validação depois do fato | sem limite de requisições na rota de assinatura | limite de taxa por loja na rota de assinatura | alta |
| Confiabilidade | falha do verificador não perde o evento — ele volta à fila e depois vai à DLQ | objeto pode ficar em quarentena até 3 dias se a DLQ não for drenada | reduzir a janela de expiração da quarentena com dado real de tempo de resposta | média |
| Eficiência de performance | upload não é mais limitado pela capacidade da task nem pela banda dela | multipart ainda é sequencial no cliente de exemplo, sem paralelismo de partes | paralelizar upload de partes no navegador para laudos grandes | baixa |
| Otimização de custos | byte não passa mais por NAT nem ocupa vCPU-segundo por transporte | multipart incompleto sem a regra de ciclo de vida cresceria sem teto | a regra já cobre; revisar o prazo de 1 dia com dado real de abandono | média |
| Sustentabilidade | byte sobe uma vez e é copiado uma vez — não sobe e desce mais pela task | a cópia de quarentena para publicado duplica temporariamente o armazenamento | considerar mover em vez de copiar quando o SDK suportar de forma atômica | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro.
Upload pela própria API, arquivo materializado em memória. É onde a Cadência estava, e continua legítimo para arquivo pequeno em ambiente de teste.URL POST assinada com teto de tamanho, dois buckets, validação assíncrona por EventBridge e SQS, admissão própria para multipart.Troca o verificador caseiro por um serviço gerenciado de varredura (por exemplo, GuardDuty Malware Protection for S3) mantendo a mesma cadeia de eventos.O mesmo evento de objeto aprovado dispara extração de dado do laudo e decisão de garantia — é o L28.S3 Transfer Acceleration para as 8 lojas fora do Brasil, cujo upload de 200 MB sofre mais com a distância até a região.Extração de dano por IA a partir das fotos e do vídeo, com revisão humana no desenho — é o território do L92.A ordem não é negociável, e o motivo é concreto
Extrair dado de um laudo (nível 4 ou 6) não faz sentido sobre um arquivo que pode ainda estar malicioso ou corrompido — por isso esses níveis dependem do bucket publicado deste módulo, que só recebe o que passou pela validação. Pular direto para IA sobre o bucket de quarentena processaria arquivo que nunca deveria ter sido aceito.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. "Como tirar a aplicação do caminho do byte" tem resposta determinística: assinatura com condição de tamanho, e validação por evento. Um modelo não melhora nenhuma dessas duas coisas.
Há um lugar em que IA acrescentaria valor real, e ele não é este módulo: é ler o CONTEÚDO do laudo aprovado — extrair o defeito relatado, cruzar com a nota fiscal, sugerir se a garantia procede. Isso pede compreensão de linguagem natural e de imagem, que regra determinística não cobre bem. É exatamente o L92 (IDP: documento → extração → interpretação → revisão humana), e ele depende deste laboratório — só faz sentido processar um laudo que já passou pela quarentena.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria aqui? | nenhum diretamente — assinatura e validação de tipo são determinísticas por design, e é isso que as torna auditáveis |
| Por que não usar IA para decidir se o arquivo é malicioso? | a checagem de tipo real por assinatura binária já é determinística e barata; IA entraria só se o objetivo fosse classificar conteúdo, não verificar formato |
| Onde IA realmente ajuda nesta cadeia? | depois da aprovação: interpretar o conteúdo do laudo, no L92 |
| Qual seria o risco de forçar IA aqui? | trocar uma verificação de tipo exata e auditável por uma resposta probabilística para um problema que não precisa de uma |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "olhar o arquivo e dizer se parece seguro" troca uma checagem de assinatura binária — determinística, rápida, auditável — por um julgamento probabilístico que ainda precisaria de um antivírus de verdade por trás. A pergunta "este arquivo é do tipo que eu esperava?" tem resposta exata; reservar IA para ela é gastar latência e orçamento num lugar que não precisa.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| Upload direto pela API/contêiner | é o menor número de linhas que funciona, e funciona de verdade até uns 5 MB | a task materializa o arquivo em memória e paga banda e tempo de conexão do cliente | contêiner morto com código 137 sem exceção nos logs | URL assinada; a task nunca vê o corpo | arquivo pequeno e previsível, como avatar de perfil |
| PUT assinado sem Content-Length nem POST com content-length-range | é o exemplo mais copiado de tutorial, e "parece" ter um limite porque pede um tamanho | não impõe teto nenhum de verdade — qualquer tamanho passa | upload muito maior que o esperado aceito sem erro | POST com política assinada quando o teto importa | download, ou upload onde tamanho não é preocupação (ex.: log interno confiável) |
| Confiar na extensão do nome do arquivo enviado pelo cliente | parece suficiente, e "o cliente não vai mentir de propósito" | nome de arquivo é dado do cliente, não fato — abre caminho para tipo forjado | objeto salvo com extensão que não corresponde ao conteúdo real | extensão derivada do tipo MIME aceito pela aplicação, decidida no servidor | nunca, quando o tipo do arquivo importa para segurança ou processamento |
| CORS com `allowed_origins = ["*"]` | resolve o erro de CORS rápido, "e depois eu aperto" | permite que qualquer site consiga usar uma assinatura vazada e leia a resposta do S3 | nada visível até um incidente de uso indevido de assinatura | origem exata do domínio da aplicação | nunca em bucket com upload autenticado; talvez em conteúdo público de leitura |
| Multipart sem regra de ciclo de vida para partes incompletas | ninguém pensa em "o que acontece quando falha" até a fatura aparecer | parte enviada e nunca concluída continua sendo cobrada indefinidamente | crescimento de armazenamento sem correspondência em objetos visíveis | `abort_incomplete_multipart_upload` com prazo curto | nunca — o custo de configurar é uma linha de Terraform |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| Erro genérico de rede no navegador, upload "falha" | bucket sem CORS ou origem errada em `allowed_origins` | confira a aba Network; procure "blocked by CORS policy" | console do navegador; configuração de CORS do bucket | ajustar `allowed_origins` para o domínio exato, sem barra final |
| Objeto aparece no bucket mesmo com erro reportado ao usuário | o upload em si funcionou; o navegador não conseguiu LER a resposta por falta de CORS | confirme se o objeto existe no S3 apesar do erro na tela | listagem do bucket vs. mensagem de erro no app | mesmo ajuste de CORS acima; o upload nunca foi o problema |
| S3 recusa com EntityTooLarge mesmo em arquivo pequeno | faixa de `content-length-range` calculada em unidade errada (bits em vez de bytes) | decodifique o campo `policy` em base64 e confira os números | código que monta a política assinada | usar bytes de forma consistente; considere uma constante nomeada, não literal repetido |
| PUT aceita arquivo muito maior que o esperado | ausência de `Content-Length` nos cabeçalhos assinados, ou uso de PUT em vez de POST | gere a mesma URL e envie um arquivo grande de teste | código que gera a URL — verifique se há `SignedHeaders` além do mínimo | migrar para POST com `content-length-range` quando o teto importar |
| Verificador nunca processa o objeto novo | notificação do S3 para o EventBridge desabilitada, ou padrão de evento errado | confira métricas de invocação do EventBridge para a regra | `aws_s3_bucket_notification` e `event_pattern` da regra | habilitar `eventbridge = true` no bucket; revisar o prefixo do padrão |
| Fila cresce sem parar (backlog) | worker mais lento que a chegada de eventos no pico das 18h | compare `NumberOfMessagesSent` com `NumberOfMessagesDeleted` ao longo do dia | métricas da fila no CloudWatch | aumentar o número de instâncias do verificador; ele é sem estado |
| Objeto aprovado nunca aparece no bucket publicado | exceção durante a cópia foi engolida, ou permissão de PutObject falta no bucket destino | logs do verificador; confira se a mensagem foi deletada mesmo com falha | logs da aplicação e a policy IAM do worker | não deletar a mensagem da fila em caso de falha; corrigir a policy |
| Upload falha com URL expirada antes do fim, em loja com internet lenta | prazo de assinatura menor que o tempo real de upload na banda daquela loja | compare o tempo estimado de upload (tamanho / banda) com a validade pedida | valor de `validade` passado a `AssinarAsync` | aumentar a folga sobre a banda mínima suportada, não sobre a banda média |
A pergunta que resolve metade destes casos
O objeto chegou a existir no S3, mesmo que por um instante? Se sim, o problema está do lado de CORS ou de leitura da resposta, não do upload em si — o byte já passou. Se não, o problema está na assinatura: condição errada, permissão IAM insuficiente, ou prazo expirado antes do envio começar.
Limpeza: o que o destroy não leva
Este laboratório cria pouco recurso que cobra por hora, mas o que sobrevive ao terraform destroy aqui é armazenamento — e ele cresce sozinho se ninguém olhar.
# 1. Derrube o que o Terraform administra.
terraform destroy -auto-approve
# 2. MULTIPART INCOMPLETO: o destroy do bucket falha se houver upload
# multipart em andamento, a menos que force_destroy esteja ligado.
aws s3api list-multipart-uploads --bucket ffv-lab-laudos-quarentena \
--query "Uploads[].{Chave:Key,Iniciado:Initiated}" --output table
for upload in $(aws s3api list-multipart-uploads --bucket ffv-lab-laudos-quarentena \
--query "Uploads[].UploadId" --output text); do
aws s3api abort-multipart-upload --bucket ffv-lab-laudos-quarentena \
--key "$(aws s3api list-multipart-uploads --bucket ffv-lab-laudos-quarentena \
--query "Uploads[?UploadId=='$upload'].Key | [0]" --output text)" \
--upload-id "$upload"
done
# 3. OBJETOS RESIDUAIS nos dois buckets: destroy sem force_destroy nao
# remove bucket com objeto dentro.
aws s3 rm s3://ffv-lab-laudos-quarentena --recursive
aws s3 rm s3://ffv-lab-laudos-publicados --recursive
# 4. FILA E DLQ: nao cobram paradas em valor relevante, mas confirme
# que sumiram — mensagem presa numa fila orfa nao aparece em nenhum
# painel depois que o Terraform sair do estado.
aws sqs list-queues --queue-name-prefix ffv-lab-laudos
# 5. Prova final: nada com o nome do projeto de pe.
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=ffv-lab \
--query "ResourceTagMappingList[].ResourceARN" --output table| Recurso | Sai no destroy? | Cobra parado? | Por que fica |
|---|---|---|---|
| Buckets de laudos | só com `force_destroy` | sim, GB-mês | o destroy falha se houver objeto dentro; interrupção no meio deixa tudo de pé |
| Partes de multipart incompleto | não é removido pelo destroy do bucket sozinho | sim, GB-mês | a regra de ciclo de vida cuida disso em produção; ao encerrar o laboratório, aborte à mão |
| Chave KMS | entra em janela de exclusão agendada | sim, valor pequeno por mês na janela | exclusão de chave KMS nunca é imediata — é proteção contra apagar por engano |
| Fila e DLQ | sim | não | sem custo relevante parada; confirme que sumiram do estado |
| Regra do EventBridge | sim | não | sem custo por regra parada |
| O que veio do L05 | não faz parte deste destroy | sim, por hora e por GB servido | CloudFront e o domínio continuam cobrando — não são deste laboratório |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Task estourando memória com arquivo de 200 MB | URL POST pré-assinada | tira a aplicação do caminho do byte sem tirá-la da decisão de quem pode enviar |
| PUT assinado não impõe teto nenhum | `content-length-range` na política POST | único caminho que o S3 verifica ANTES de gravar o objeto |
| Cliente escolhendo o caminho do objeto | chave montada pela API, com UUID | fecha path traversal e sobrescrita entre lojas, sem depender do cliente se comportar |
| Upload cross-origin sendo bloqueado ou lido errado | CORS restrito ao domínio exato | permite o navegador ler a resposta do S3 sem abrir para qualquer origem |
| Multipart sem controle de tamanho | admissão por soma de partes na aplicação | o S3 não oferece condição nenhuma aqui — o teto só existe se alguém o escrever |
| Parte de multipart nunca concluída cobrando para sempre | `abort_incomplete_multipart_upload` no ciclo de vida | fecha a cobrança sem depender de alguém lembrar |
| Validação que a API não pode mais fazer em tempo real | EventBridge, fila e verificador assíncrono | move a checagem para depois do fato, sem perder o evento |
| Objeto malicioso alcançável antes de aprovado | bucket de quarentena separado do publicado | separação física impede servir o que não foi checado, mesmo com bug de lógica |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Task morrendo por estouro de memória | byte fora do caminho da aplicação | nada — este risco simplesmente deixou de existir neste desenho |
| Upload maior que o esperado | `content-length-range` | multipart, que não tem essa condição — precisa de admissão própria |
| Arquivo malicioso servido ao público | quarentena separada do publicado | arquivo malicioso ainda ocupa espaço em quarentena até o verificador decidir |
| Evento perdido por falha do verificador | fila com redrive para DLQ | objeto ficar preso em quarentena além do prazo, se a DLQ não for drenada |
| Assinatura usada além do prazo pedido | validade curta | assinatura usada dentro do prazo por alguém que não deveria ter recebido a URL |
- O navegador pede autorização à API, na mesma origem do domínio da loja.
- A API confere sessão e cota, escolhe a chave do objeto e assina a política.
- O navegador envia o arquivo direto ao S3, num host diferente — por isso o CORS.
- O S3 aplica `content-length-range` antes de gravar; fora da faixa, recusa com 400.
- O objeto criado dispara uma notificação ao EventBridge, filtrada por prefixo.
- A notificação vira mensagem na fila, entregue ao verificador.
- O verificador lê os primeiros bytes, confere o tipo real e aciona o antivírus.
- Aprovado, o objeto é copiado ao bucket publicado; reprovado, é descartado.
- O veredito é gravado no DynamoDB — é o que a tela da loja consulta.
- A quarentena é limpa: o objeto original é apagado nos dois casos.
Perguntas frequentes
❓ Uma URL S3 pré-assinada tem limite de tamanho de upload por padrão?
❓ Por que minha URL pré-assinada expira antes do prazo que eu configurei?
❓ Preciso de multipart upload para um arquivo de 200 MB?
❓ Por que meu upload aparece bem-sucedido no S3, mas o usuário vê erro no navegador?
❓ Multipart upload tem alguma condição para limitar o tamanho total do arquivo?
❓ O que acontece com um multipart upload que nunca é concluído?
❓ Por que a chave do objeto é escolhida pela aplicação, não pelo nome enviado?
❓ CORS no bucket S3 é suficiente para impedir que qualquer site use minha URL assinada?
Fixando
Um pipeline de upload usa multipart, e cerca de 30% das tentativas são abandonadas pelo cliente antes do `CompleteMultipartUpload` (aba fechada, conexão perdida). Seis meses depois, a fatura de armazenamento do bucket está anormalmente alta, mesmo com o verificador aprovando e apagando corretamente todo objeto completo. Qual é a causa mais provável, e a correção?
A equipe da Cadência migrou o upload de laudos para a URL assinada, mas esqueceu de configurar CORS no bucket de quarentena. O que os usuários relatam, e por que o sintoma engana o diagnóstico?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L05 no ar (domínio, TLS, CloudFront), L01 concluído, noções de IAM e de fila |
| Conhecimentos adquiridos | a diferença real entre PUT e POST assinados quanto a teto de tamanho; por que credencial temporária limita a expiração da assinatura; por que multipart não tem condição de tamanho e como suprir isso na aplicação; por que erro de CORS pode esconder um upload que já funcionou |
| Limitação que fica | sem limite de requisições na rota de assinatura, uma loja pode pedir muitas assinaturas em paralelo antes de a cota ser atualizada |
| Próximo exemplo recomendado | L28 — processar o arquivo que acabou de chegar. Reaproveita a cadeia de EventBridge e fila deste módulo para acionar trabalho de negócio sobre o laudo aprovado |
| Também habilitado por este módulo | L92 (documento → extração → interpretação → revisão humana) depende deste laboratório para ter um bucket publicado confiável de onde ler |
| Data da última validação técnica | 7 de agosto de 2026 |
Documentação oficial consultada: Uploading objects with presigned URLs e Download and upload objects with presigned URLs — geração de URL PUT, a tabela de tipo de credencial contra prazo de expiração, e a FAQ que confirma a rotação de 1 a 6 h da credencial de task do ECS; a referência de `sigv4-HTTPPOSTConstructPolicy` — sintaxe de `content-length-range`; Amazon S3 multipart upload limits — os números de tamanho de objeto, de parte e de contagem de partes. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
Não confirmei se a expiração de uma política POST é avaliada só no início da requisição ou durante toda a transferência — a documentação de download descreve o primeiro comportamento para GET, e o módulo assume algo parecido para POST, sem fonte que o afirme diretamente. Também não testei o nome e a cobertura atuais do GuardDuty Malware Protection for S3, citado como alternativa ao verificador caseiro. E o teto de 220 MB deste módulo vem da hipótese declarada sobre o limite de vídeo do app da loja — se essa hipótese mudar, o número tem de ser revisado, não herdado.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…