← Voltar para o blog

Upload de Arquivos em Go: Multipart, S3 e Segurança

Aprenda upload de arquivos em Go com multipart, limite de tamanho, validação de MIME, streaming para S3, presigned URLs, segurança e testes.

Upload de arquivos em Go parece uma tarefa simples: receber um multipart/form-data, salvar no disco e responder 200. Em produção, porém, essa é uma das superfícies mais fáceis de subestimar. Um endpoint de upload pode virar gargalo de memória, entrada para arquivo malicioso, vazamento de dado pessoal, abuso de banda, custo inesperado em storage ou incidente de segurança por servir conteúdo com Content-Type errado.

Go é uma ótima escolha para esse tipo de backend porque a biblioteca padrão já entrega net/http, mime/multipart, streaming de io.Reader, limites de body, contexto, timeouts e testes rápidos. O desafio é desenhar o fluxo com limites explícitos. Upload não deve depender de “o cliente vai mandar o arquivo certo”. O servidor precisa impor tamanho, validar metadados, ler só o necessário, armazenar com nome seguro, registrar auditoria e isolar o processamento lento.

Este guia mostra como implementar upload de arquivos em Go com multipart, http.MaxBytesReader, validação básica de MIME, streaming para storage compatível com S3, presigned URLs, segurança e testes. Ele complementa Go para APIs REST, autenticação e autorização em Go, webhooks com idempotência, context, timeout e cancelamento e SQS em Go.

O fluxo correto antes do código

Antes de escrever o handler, defina o contrato. Quem pode enviar arquivo? Qual tamanho máximo? Quais tipos são aceitos? O arquivo fica público ou privado? Há dado pessoal? Precisa de antivírus, OCR, thumbnail, compressão ou processamento assíncrono? Qual é a política de retenção?

Um fluxo saudável costuma ser:

  1. Autenticar e autorizar o usuário.
  2. Limitar o tamanho total da requisição.
  3. Ler o multipart sem carregar tudo em memória.
  4. Validar nome, extensão, MIME e tamanho real.
  5. Gerar um identificador interno, não confiar no nome original.
  6. Salvar em storage privado.
  7. Persistir metadados no banco.
  8. Enfileirar processamento assíncrono quando necessário.
  9. Servir o arquivo por URL assinada ou rota controlada.

Essa ordem evita dois problemas comuns. O primeiro é gastar CPU, memória e storage com uma requisição que nem deveria existir. O segundo é expor um arquivo antes de saber se ele pertence ao usuário certo, se o tipo é permitido e se a aplicação consegue rastrear quem enviou.

Handler multipart com limite de tamanho

O limite precisa entrar antes de chamar ParseMultipartForm ou ler o body. Sem isso, um cliente pode mandar um payload enorme e forçar seu processo a consumir memória, disco temporário ou banda.

package upload

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "path/filepath"
    "strings"
)

const maxUploadSize = 10 << 20 // 10 MB

type Storage interface {
    PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
}

type Handler struct {
    Store Storage
}

func (h Handler) Upload(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "método inválido", http.StatusMethodNotAllowed)
        return
    }

    r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)

    file, header, err := r.FormFile("arquivo")
    if err != nil {
        http.Error(w, "arquivo obrigatório", http.StatusBadRequest)
        return
    }
    defer file.Close()

    ext := strings.ToLower(filepath.Ext(header.Filename))
    if !extPermitida(ext) {
        http.Error(w, "extensão não permitida", http.StatusBadRequest)
        return
    }

    limited := io.LimitReader(file, maxUploadSize+1)
    key := "uploads/" + randomHex(16) + ext

    // A validação de MIME aparece na próxima seção.
    if err := h.Store.PutObject(r.Context(), key, limited, header.Size, "application/octet-stream"); err != nil {
        http.Error(w, "falha ao salvar arquivo", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)
    fmt.Fprintf(w, `{"key":%q}`, key)
}

func extPermitida(ext string) bool {
    switch ext {
    case ".jpg", ".jpeg", ".png", ".pdf":
        return true
    default:
        return false
    }
}

func randomHex(n int) string {
    b := make([]byte, n)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    return hex.EncodeToString(b)
}

Esse exemplo usa header.Filename apenas para extrair a extensão. Nunca use o nome original como caminho final. Um nome enviado pelo cliente pode conter espaços estranhos, caracteres Unicode confusos, tentativas de path traversal ou informações pessoais. Guarde o nome original apenas como metadado sanitizado, se houver necessidade de exibir para o usuário.

Também repare no uso de r.Context(). Se o cliente cancelar a requisição, o storage deve poder interromper o upload. O guia de context em Go ajuda a decidir quando o cancelamento do cliente deve abortar o trabalho e quando você precisa continuar em background.

Validação de MIME sem se enganar

Extensão não basta. Content-Type enviado pelo navegador também não basta. O servidor deve inspecionar os primeiros bytes do arquivo com http.DetectContentType. O cuidado é que, ao ler bytes para detecção, você precisa recolocá-los no stream antes de enviar para o storage. Este exemplo usa bytes.NewReader para devolver a amostra ao stream.

func sniff(file io.Reader) (contentType string, body io.Reader, err error) {
    buf := make([]byte, 512)
    n, err := io.ReadFull(file, buf)
    if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
        return "", nil, err
    }

    sample := buf[:n]
    contentType = http.DetectContentType(sample)
    body = io.MultiReader(bytes.NewReader(sample), file)
    return contentType, body, nil
}

func tipoPermitido(ct string) bool {
    switch ct {
    case "image/jpeg", "image/png", "application/pdf":
        return true
    default:
        return false
    }
}

No handler, chame sniff(file), valide tipoPermitido(contentType) e só então envie o body retornado. Se você aceita imagens, considere também decodificar dimensões máximas para evitar imagem pequena em bytes mas absurda em pixels. Se aceita PDF, pense em antivírus, conversão assíncrona ou revisão manual dependendo do risco do produto.

MIME sniffing não é uma prova criptográfica. É uma camada prática. Para superfícies sensíveis, combine extensão permitida, MIME detectado, limite de tamanho, antivírus, sandbox de processamento, política de retenção e bloqueio de execução. Upload público sem essas camadas vira problema rápido.

Streaming para S3 ou storage compatível

Em vez de salvar arquivo em disco local, a maioria dos serviços deve enviar para S3, R2, MinIO ou outro storage de objetos. Isso evita depender do filesystem do pod, facilita replicação, aplica política de acesso e separa upload de deploy.

Com AWS SDK v2, o desenho fica assim:

type S3Storage struct {
    Client *s3.Client
    Bucket string
}

func (s S3Storage) PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
    _, err := s.Client.PutObject(ctx, &s3.PutObjectInput{
        Bucket:      aws.String(s.Bucket),
        Key:         aws.String(key),
        Body:        body,
        ContentType: aws.String(contentType),
    })
    return err
}

Para arquivos pequenos e médios, PutObject direto costuma bastar. Para arquivos grandes, use multipart upload do SDK, política de retry e timeouts mais cuidadosos. Mesmo assim, mantenha limite de tamanho no servidor ou no fluxo de presigned URL. Storage barato não significa banda, processamento e suporte grátis.

A chave do objeto deve ser previsível para a aplicação, mas não para o atacante. Um padrão comum é separar por tenant e data:

uploads/{tenant_id}/2026/06/{uuid}.pdf

Não inclua e-mail, CPF, nome da pessoa ou título sensível na key. Keys aparecem em logs, métricas, eventos e às vezes em URLs. Trate como metadado operacional, não como lugar para dados pessoais.

Presigned URL: quando deixar o cliente subir direto

Para arquivos maiores, uma alternativa é gerar uma URL assinada para o cliente enviar direto ao storage. O backend continua decidindo quem pode enviar, tamanho máximo, tipo permitido e onde o arquivo ficará, mas não recebe os bytes. Isso reduz carga no seu serviço.

O fluxo é:

  1. Cliente pede autorização para upload.
  2. Backend valida usuário, plano, quota e tipo pretendido.
  3. Backend cria registro pending no banco.
  4. Backend gera URL assinada curta para uma key específica.
  5. Cliente faz PUT direto no storage.
  6. Cliente avisa o backend ou o storage dispara evento.
  7. Backend confirma tamanho, tipo e status antes de liberar uso.

Presigned URL não elimina validação. Ela muda onde os bytes passam. Se você libera qualquer key e qualquer tipo, o cliente ainda pode subir lixo caro. Use expiração curta, key gerada pelo servidor, Content-Type esperado, limite de tamanho por política quando o provedor suportar e confirmação posterior antes de tornar o arquivo visível.

Para processamento posterior, envie um evento para fila. O artigo de SQS em Go mostra como separar trabalho lento do fluxo principal. Se o arquivo dispara extração, thumbnail, OCR ou antivírus, trate esse pipeline como assíncrono e idempotente.

Segurança: o que costuma dar errado

O erro mais comum é servir upload pelo mesmo domínio da aplicação com Content-Type controlado pelo usuário. Se alguém envia HTML ou SVG com script e você entrega como text/html, pode criar XSS armazenado. Prefira storage em domínio separado, headers seguros, tipos permitidos restritos e Content-Disposition: attachment quando o arquivo não deve renderizar no navegador.

Outro erro é confiar em caminho local. Salvar em /uploads/ dentro do container pode funcionar no desenvolvimento, mas perder arquivo em restart, escalar mal e misturar deploy com dados. Se for realmente necessário salvar em disco, sanitize o caminho, use diretório fora do código, limite permissões e tenha backup.

Também cuide de privacidade. Uploads frequentemente carregam documentos, currículos, notas fiscais, contratos, exames ou imagens pessoais. Registre quem enviou, quando, IP aproximado se necessário, finalidade, tempo de retenção e quem acessou. Em sistemas brasileiros, pense em LGPD desde o começo: minimização, acesso controlado e deleção precisam existir no produto, não só na política de privacidade.

Por fim, não rode processamento pesado no handler. Se você faz resize, OCR, compressão, antivírus e chamada externa antes de responder, o usuário espera, o provedor de proxy pode cortar a conexão e o servidor segura recursos. Receba, persista, enfileire e mostre status. Esse padrão aparece também em worker pools em Go e Redis Streams em Go.

Testes que pegam bugs reais

Teste upload com multipart.NewWriter em vez de montar strings manualmente:

func TestUploadImagem(t *testing.T) {
    var body bytes.Buffer
    mw := multipart.NewWriter(&body)

    part, err := mw.CreateFormFile("arquivo", "foto.png")
    if err != nil {
        t.Fatal(err)
    }
    part.Write([]byte("\x89PNG\r\n\x1a\n"))
    mw.Close()

    req := httptest.NewRequest(http.MethodPost, "/upload", &body)
    req.Header.Set("Content-Type", mw.FormDataContentType())

    rr := httptest.NewRecorder()
    handler.Upload(rr, req)

    if rr.Code != http.StatusCreated {
        t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
    }
}

Inclua cenários negativos:

  • Campo de arquivo ausente.
  • Método diferente de POST.
  • Arquivo maior que o limite.
  • Extensão permitida com MIME inválido.
  • MIME permitido com extensão suspeita.
  • Storage retornando erro.
  • Cliente cancelando contexto.
  • Nome original com path traversal, acento, espaço e caracteres estranhos.

Para storage, use uma implementação fake da interface Storage. O teste do handler não precisa falar com S3 real. Testes de integração podem cobrir MinIO ou LocalStack em outro nível. Se a aplicação já usa containers em testes, veja Testcontainers em Go.

Checklist de produção

Antes de liberar upload em produção, revise:

  • O endpoint exige autenticação e autorização.
  • http.MaxBytesReader limita o body total.
  • O código não usa nome original como caminho final.
  • Extensão e MIME detectado são validados.
  • Arquivos são salvos em storage privado ou domínio separado.
  • O acesso público usa URL assinada ou rota autorizada.
  • Há política de retenção e deleção.
  • Logs não vazam dados pessoais nem URLs sensíveis.
  • Processamento pesado acontece fora do handler.
  • Antivírus ou análise assíncrona existe quando o risco pede.
  • Testes cobrem arquivo grande, tipo inválido e erro de storage.

Upload é uma fronteira de produto, segurança e operação. O handler pode ter poucas linhas, mas a decisão correta está no contrato: limite explícito, validação proporcional, storage adequado e rastreabilidade. Com Go, dá para manter o código simples sem fingir que arquivo enviado pelo usuário é simples.

Para comparar abordagens em outra stack usada no mercado brasileiro, vale acompanhar também Python Dev Brasil, especialmente quando o upload vira pipeline de dados, automação ou processamento assíncrono. A linguagem muda, mas os riscos de tamanho, MIME, privacidade, storage e processamento fora do request continuam os mesmos.