Extensão — O Site Completo: htmx, Login, Área Administrativa e Upload

Extensão — O Site Completo: htmx, Login, Área Administrativa e Upload

Nesta aula o site fica completo. A busca responde enquanto se digita e a exclusão some com a linha sem recarregar a página, tudo com htmx e fragmentos de Thymeleaf. O login usa sessão, a área administrativa é protegida por papéis e o upload de fotos passa por uma validação que não confia em nada do que o navegador diz.
Java

• • 30 min de leitura

Nesta aula o site fica completo. A busca responde enquanto se digita e a exclusão some com a linha sem recarregar a página, tudo com htmx e fragmentos de Thymeleaf. O login usa sessão, a área administrativa é protegida por papéis e o upload de fotos passa por uma validação que não confia em nada do que o navegador diz.

O site da aula anterior funciona, mas cada interação recarrega a página inteira. Para buscar uma trilha, você digita, clica e espera a página voltar. Para apagar dez trilhas, são dez recargas. A resposta mais comum para isso é um aplicativo JavaScript no navegador, com uma API JSON por trás e o HTML montado no cliente. Há um caminho mais simples, e ele combina com tudo o que construímos: o servidor continua gerando HTML, mas pedaços de HTML, trocados na página sem recarregá-la. Esse é o htmx.

htmx: HTML que faz requisições

O htmx é uma biblioteca JavaScript de uns 16 KB compactados, que você não programa: você declara comportamento com atributos HTML. A premissa é que só <a> e <form> podem fazer requisições, só em clique e envio, e só trocando a página inteira. O htmx remove essas três restrições:

  • Qualquer elemento faz requisições: hx-get, hx-post, hx-put, hx-delete.
  • Qualquer evento dispara a requisição: hx-trigger (digitação, rolagem, intervalo de tempo...).
  • Qualquer parte da página recebe a resposta: hx-target diz onde, e hx-swap diz como.

A resposta do servidor é HTML, e não JSON. O servidor manda o pedaço pronto, e o htmx o encaixa na página. Isso tem uma consequência importante: toda a lógica de apresentação continua no servidor, nos templates Thymeleaf, testada como o resto do código Java.

Baixe o arquivo uma vez para src/main/resources/static/js/htmx.min.js, na versão 2.0.11, a estável atual, e sirva-o do próprio site. Uma dependência servida por um CDN de terceiros é um ponto de falha e de ataque a mais. O layout ganha a tag:

<!-- em fragmentos.html, dentro do <head> -->
<script th:src="@{/js/htmx.min.js}" defer></script>

Busca enquanto se digita

O campo de busca da lista ganha cinco atributos:

<!-- templates/trilhas/lista.html -->
<input id="q" type="search" name="q" th:value="${q}" placeholder="ex.: lagoinha"
       hx-get="/trilhas" th:attr="hx-get=@{/trilhas}"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#resultados"
       hx-swap="outerHTML"
       hx-push-url="true">

<div id="resultados" th:fragment="resultados">
    <p th:if="${pagina.itens.isEmpty()}">Nenhuma trilha encontrada.</p>
    <table th:unless="${pagina.itens.isEmpty()}">
        <!-- ...o mesmo <thead>, <tbody> e a paginação da aula anterior... -->
    </table>
</div>

Atributo por atributo:

  • hx-get: faz um GET /trilhas?q=.... O htmx inclui o name/value do próprio campo. O th:attr gera a URL com @{...}, para respeitar o context path.
  • hx-trigger="input changed delay:300ms, search": dispara quando o texto muda (input changed), mas espera 300 ms sem nova tecla antes de enviar (delay). Cada tecla reinicia a contagem, e quem digita "lago" rápido gera uma requisição, e não quatro. O search cobre o "×" que limpa o campo nos navegadores.
  • hx-target="#resultados" e hx-swap="outerHTML": a resposta substitui a div#resultados inteira. O fragmento th:fragment="resultados" inclui a própria div, e com o hx-swap padrão, innerHTML, ela entraria dentro da antiga: no teste da próxima seção, a página ficou com duas div#resultados, uma dentro da outra.
  • hx-push-url="true": atualiza a URL da barra para /trilhas?q=lago. O botão Voltar e o link compartilhado funcionam como numa página comum.

No servidor, o controller devolve a página inteira ou só o fragmento, conforme quem pediu. O htmx marca suas requisições com o cabeçalho HX-Request: true:

@GetMapping("/trilhas")
String lista(@RequestParam(defaultValue = "") String q,
             @RequestParam(defaultValue = "0") int pagina,
             @RequestHeader(value = "HX-Request", defaultValue = "false") boolean htmx,
             @RequestHeader(value = "HX-History-Restore-Request", defaultValue = "false") boolean restauracao,
             Model model, HttpServletResponse resposta) {
    var p = PageRequest.of(Math.max(pagina, 0), POR_PAGINA, Sort.by("nome"));
    model.addAttribute("pagina", servico.buscar(q.strip(), p));
    model.addAttribute("q", q);
    resposta.addHeader("Vary", "HX-Request");        // a MESMA URL tem duas respostas possíveis
    return htmx && !restauracao
            ? "trilhas/lista :: resultados"           // só o fragmento th:fragment="resultados"
            : "trilhas/lista";                        // a página completa
}

A sintaxe "template :: fragmento" é entendida pelo Thymeleaf: ele processa só aquele pedaço do arquivo. O template continua um só, e a primeira carga e as atualizações usam o mesmo HTML, sem duplicação.

O cabeçalho Vary: HX-Request é o detalhe que separa um exemplo de um sistema em produção. A URL /trilhas?q=lago agora tem duas respostas diferentes: a página e o fragmento. Sem o Vary, um cache (do navegador, do nginx ou de uma CDN) pode guardar o fragmento e entregá-lo a quem abrir o link diretamente, e essa pessoa verá uma tabela solta, sem cabeçalho nem estilo. O Vary diz a qualquer cache que a resposta depende desse cabeçalho.

O restauracao cobre um caso que só aparece depois de algum uso. O htmx guarda as últimas páginas visitadas para o botão Voltar (dez, por padrão). Quando a página pedida já saiu dessa memória, ele a busca no servidor com HX-History-Restore-Request: true e, no htmx 2, também com HX-Request: true, e põe a resposta no lugar do <body> inteiro. Se o controller devolvesse o fragmento, o Voltar mostraria uma tabela solta.

E se o JavaScript não carregar? O campo continua dentro do <form method="get"> da aula anterior, e o Enter faz a busca à moda antiga. Esse é o princípio do aprimoramento progressivo: o htmx melhora uma página que já funcionava sem ele.

O que a biblioteca realmente faz: um teste no navegador

Atributos declarativos escondem detalhes, e alguns deles surpreendem. Para ver o comportamento real, vale montar um servidor mínimo que registra cada requisição, servindo uma página com esses mesmos atributos, e dirigi-la com o htmx de verdade (a versão 2.0.11, num navegador simulado, o jsdom do Node): digitar "lago" com uma letra a cada 80 ms, limpar o campo pelo "×" e apagar as trilhas 1 e 4. Isto é o que o servidor recebeu:

GET /trilhas?q=lago       HX-Request=true X-CSRF-TOKEN=tok-123
GET /trilhas?q=           HX-Request=true X-CSRF-TOKEN=tok-123
DELETE /admin/trilhas/1   HX-Request=true X-CSRF-TOKEN=tok-123
DELETE /admin/trilhas/4   HX-Request=true X-CSRF-TOKEN=tok-123

Três comportamentos ficam confirmados. As quatro teclas geraram uma busca (q=lago), graças ao delay; a segunda linha é o "×", que dispara o evento search na hora, com o campo vazio. Toda requisição do htmx leva HX-Request: true. E o cabeçalho de CSRF, declarado uma vez no <body> como veremos adiante, foi herdado por todas elas, inclusive pelo GET. O quarto comportamento, sobre as duas exclusões, é a próxima seção.

Apagar sem recarregar, e a pegadinha do 204

Na área administrativa, cada linha da tabela de trilhas tem um botão:

<tr th:each="t : ${pagina.itens}" th:id="|trilha-${t.id}|">
    <td th:text="${t.nome}">Lagoinha do Leste</td>
    <td th:text="${t.regiao}">Sul</td>
    <td>
        <a th:href="@{/admin/trilhas/{id}(id=${t.id})}">Editar</a>
        <button th:attr="hx-delete=@{/admin/trilhas/{id}(id=${t.id})},
                         hx-confirm=|Apagar a trilha ${t.nome}? Os relatos dela também serão apagados.|"
                hx-target="closest tr"
                hx-swap="outerHTML">Apagar</button>
    </td>
</tr>

O hx-confirm abre o diálogo de confirmação do navegador antes de enviar. O hx-target="closest tr" aponta para a linha que contém o botão. E o hx-swap="outerHTML" troca a linha inteira pela resposta. Se a resposta for vazia, a linha simplesmente some. Repare também que o nome da trilha entra no texto do hx-confirm pelo Thymeleaf, escapado para atributo, e não por concatenação: um nome malicioso não quebra o atributo.

No controller:

@DeleteMapping("/admin/trilhas/{id}")
@ResponseBody
String apagar(@PathVariable long id) {
    servico.remover(id);
    return "";                  // 200 com corpo vazio: o htmx troca a linha por "nada"
}

Por que não @ResponseStatus(HttpStatus.NO_CONTENT), que seria o mais "correto" em REST? Porque o htmx não faz troca nenhuma em respostas 204. No teste, a trilha 1 respondeu 200 vazio e sua linha desapareceu; a trilha 4 respondeu 204, foi apagada no servidor e continuou na tela. O usuário veria uma trilha que não existe mais. Esse comportamento está documentado (o 204 significa "nada a mudar"), e é exatamente o tipo de detalhe que só se descobre testando.

CSRF com htmx

Os formulários do Thymeleaf recebem o token CSRF sozinhos, mas as requisições do htmx não são formulários. A solução é entregar o token no cabeçalho que o Spring Security aceita (X-CSRF-TOKEN) em toda requisição. Duas etiquetas no layout e um ouvinte de eventos resolvem isso:

<!-- em fragmentos.html, no <head> -->
<meta name="csrf-cabecalho" th:content="${_csrf.headerName}">
<meta name="csrf-token" th:content="${_csrf.token}">
<script>
  // Antes de cada requisição do htmx, acrescenta o token CSRF da página
  document.addEventListener("htmx:configRequest", evento => {
    const nome = document.querySelector('meta[name="csrf-cabecalho"]').content;
    const valor = document.querySelector('meta[name="csrf-token"]').content;
    evento.detail.headers[nome] = valor;
  });
</script>

Uma alternativa, a usada no teste acima, é o atributo hx-headers no <body>, que o htmx herda para todos os elementos filhos: <body hx-headers='{"X-CSRF-TOKEN": "..."}'>. As duas funcionam. O ouvinte tem a vantagem de não exigir que você monte um JSON dentro de um atributo no template.

Login com sessão

A API usa tokens. O site usa o mecanismo mais antigo e, para navegadores, o mais seguro: sessão com cookie. O cookie de sessão é HttpOnly, então nenhum JavaScript, nem o de um eventual XSS, consegue lê-lo. Ele é enviado só ao nosso domínio e pode ser invalidado no servidor a qualquer momento, que é o que o JWT não sabia fazer.

A cadeia do site ganha login, logout e as regras da área administrativa:

// Em ConfiguracaoDeSeguranca: a cadeia do site, completa
@Bean
@Order(2)
SecurityFilterChain site(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(regras -> regras
            .requestMatchers("/", "/trilhas", "/trilhas/*", "/sugestoes/**", "/fotos/*").permitAll()
            .requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico", "/error").permitAll()
            .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
            .requestMatchers("/entrar").permitAll()
            .requestMatchers(HttpMethod.DELETE, "/admin/**").hasRole("ADMIN")
            .requestMatchers("/admin/**").hasAnyRole("EDITOR", "ADMIN")
            .anyRequest().denyAll())
        .formLogin(login -> login
            .loginPage("/entrar")                    // a NOSSA página de login (GET)
            .loginProcessingUrl("/entrar")           // o POST que o Spring Security atende
            .defaultSuccessUrl("/admin/trilhas")
            .failureUrl("/entrar?erro"))
        .logout(logout -> logout
            .logoutUrl("/sair")                      // só aceita POST, com CSRF
            .logoutSuccessUrl("/trilhas"));
    return http.build();
}

Quem confere login e senha? As peças da aula de segurança: o UserDetailsService que lê a tabela usuario e o PasswordEncoder com bcrypt. O formLogin não usa o bean AuthenticationManager publicado ali para o endpoint de token: como há exatamente um UserDetailsService e um PasswordEncoder, o Spring Security monta o seu próprio com esses dois. O efeito é o mesmo, e o usuário admin que entra na API com token entra no site com a mesma senha.

A página de login, templates/entrar.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      th:replace="~{fragmentos :: pagina(~{::title}, ~{::main})}">
<head><title>Entrar — Trilhas da Ilha</title></head>
<body>
<main>
    <h1>Entrar</h1>
    <p class="erro" th:if="${param.erro}">Usuário ou senha incorretos.</p>

    <form th:action="@{/entrar}" method="post">
        <label for="username">Usuário</label>
        <input id="username" name="username" autocomplete="username" required autofocus>

        <label for="password">Senha</label>
        <input id="password" name="password" type="password" autocomplete="current-password" required>

        <button type="submit">Entrar</button>
    </form>
</main>
</body>
</html>
@GetMapping("/entrar")
String entrar() {
    return "entrar";
}

Os nomes username e password são os que o filtro de login espera por padrão. O th:action inclui o token CSRF, e sim, o login precisa de CSRF. Sem ele, um site malicioso poderia logar a vítima na conta do atacante, e tudo o que ela fizesse ficaria registrado ali (o chamado login CSRF). A mensagem de erro é a mesma para usuário inexistente e para senha errada, pelo motivo visto na aula de segurança.

O Spring Security também faz duas coisas sem que você peça.

  • Proteção contra fixação de sessão. No login, o identificador da sessão muda. Se um atacante conseguiu plantar um identificador de sessão no navegador da vítima antes do login, esse identificador não vale nada depois.
  • Redirecionamento ao destino original. Quem tenta abrir /admin/trilhas sem login é mandado a /entrar e, depois de entrar, volta para onde queria ir.

Para os cookies, três propriedades de produção:

server.servlet.session.cookie.secure=true        # só trafega em HTTPS
server.servlet.session.cookie.same-site=lax      # não vai em POSTs vindos de outros sites
server.servlet.session.timeout=30m

O HttpOnly já é o padrão. O same-site=lax é uma segunda camada contra CSRF, somada ao token, e não uma substituta dele.

Quem está logado, no template

O menu precisa mostrar "Entrar" para visitantes, e o nome do usuário mais "Sair" para quem está logado. Um @ControllerAdvice põe essas informações no modelo de todas as páginas, sem dependência extra:

package br.com.exemplo.trilhas.site;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;

@ControllerAdvice(basePackages = "br.com.exemplo.trilhas.site")
public class UsuarioNoModelo {

    public record UsuarioLogado(String nome, boolean editor, boolean admin) {}

    @ModelAttribute("usuario")
    UsuarioLogado usuario() {
        Authentication a = SecurityContextHolder.getContext().getAuthentication();
        if (a == null || !a.isAuthenticated() || a instanceof AnonymousAuthenticationToken) return null;
        var papeis = a.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
        boolean admin = papeis.contains("ROLE_ADMIN");
        return new UsuarioLogado(a.getName(), admin || papeis.contains("ROLE_EDITOR"), admin);
    }
}
<!-- no <nav> de fragmentos.html -->
<a th:if="${usuario == null}" th:href="@{/entrar}">Entrar</a>
<a th:if="${usuario != null and usuario.editor}" th:href="@{/admin/trilhas}">Administração</a>
<form th:if="${usuario != null}" th:action="@{/sair}" method="post" class="em-linha">
    <span th:text="${usuario.nome}">ana</span>
    <button type="submit">Sair</button>
</form>

O logout é um formulário POST, e não um link. Um link de logout com GET pode ser disparado por uma simples <img src="https://trilhas.../sair"> em qualquer página da internet. Parece inofensivo, mas é um CSRF, e o Spring Security, por padrão, só aceita logout por POST.

Esconder o link "Administração" é conforto, não segurança. Quem digitar /admin/trilhas sem permissão é barrado pela cadeia de filtros, e quem chamar remover por qualquer caminho é barrado pelo @PreAuthorize do serviço. As três camadas existem porque cada uma protege contra um erro diferente.

Upload de fotos: desconfiança total

A área administrativa permite anexar fotos às trilhas. Upload é, com folga, a funcionalidade mais perigosa de um site comum. O arquivo vem de fora, tem o tamanho que o remetente quiser, e o nome, a extensão e o Content-Type são declarações do cliente, que qualquer pessoa pode falsificar. Estes são os riscos concretos:

Ataque Como funciona Defesa
Esgotar disco ou memória enviar 10 GB limite de tamanho antes de ler
"Bomba de pixels" PNG de 69 bytes que diz ter 20000×20000 pixels: decodificar pede 1,2 GB ler dimensões do cabeçalho antes de decodificar
Arquivo disfarçado HTML ou script com nome foto.jpg, servido depois como página verificar a assinatura real, reencodar, nosniff
Path traversal nome ../../etc/cron.d/x nunca usar o nome enviado
Vazamento de privacidade EXIF com as coordenadas GPS da casa de quem fotografou reencodar só os pixels

A classe abaixo aplica todas essas defesas, e roda só com o JDK:

import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.UUID;
import java.util.zip.CRC32;

/**
 * Tudo que uma foto enviada pelo usuário atravessa antes de ser guardada.
 * Nada aqui confia no nome do arquivo nem no Content-Type que o navegador mandou.
 */
static final class Fotos {
    static final int MAX_BYTES = 5 * 1024 * 1024;       // 5 MB
    static final int MAX_PIXELS = 40_000_000;           // ~40 megapixels: acima disso, recusa antes de decodificar
    static final int LADO_MAXIMO = 1600;                // a foto guardada nunca passa disso

    sealed interface Resultado permits Aceita, Recusada {}
    record Aceita(String nome, int largura, int altura, int bytes) implements Resultado {}
    record Recusada(String motivo) implements Resultado {}

    private final Path pasta;

    Fotos(Path pasta) { this.pasta = pasta; }

    Resultado receber(byte[] conteudo) throws IOException {
        // 1. Tamanho: o limite do servidor já cortou o absurdo; este é o limite da regra de negócio
        if (conteudo.length > MAX_BYTES) return new Recusada("maior que 5 MB");

        // 2. Assinatura (magic bytes): o que o arquivo É, não o que ele diz ser
        String tipo = tipoPelaAssinatura(conteudo);
        if (tipo == null) return new Recusada("não é JPEG nem PNG");

        // 3 e 4. Um leitor só: primeiro o cabeçalho, depois os pixels, com os avisos anotados
        BufferedImage original;
        var avisos = new java.util.ArrayList<String>();
        try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(conteudo))) {
            ImageReader leitor = ImageIO.getImageReadersByFormatName(tipo).next();
            try {
                leitor.setInput(in);
                // 3. Dimensões do CABEÇALHO, sem decodificar nada: bloqueia a "bomba de pixels"
                long pixels = (long) leitor.getWidth(0) * leitor.getHeight(0);
                if (pixels > MAX_PIXELS) {
                    return new Recusada("imagem de %dx%d: grande demais".formatted(leitor.getWidth(0), leitor.getHeight(0)));
                }
                // 4. Decodificar de verdade. Um JPEG truncado NÃO lança exceção: vira um aviso
                leitor.addIIOReadWarningListener((_, aviso) -> avisos.add(aviso));
                original = leitor.read(0);
            } finally {
                leitor.dispose();
            }
        } catch (IOException | RuntimeException e) {
            return new Recusada("imagem corrompida");
        }
        if (!avisos.isEmpty()) return new Recusada("imagem incompleta: " + avisos.getFirst());

        // 5. Reencodar: só os PIXELS atravessam. EXIF (com GPS!), comentários e bytes escondidos ficam para trás
        BufferedImage final_ = reduzir(original, LADO_MAXIMO);
        var saida = new ByteArrayOutputStream();
        ImageIO.write(final_, "jpg", saida);

        // 6. Nome gerado pelo servidor: nada do nome original chega ao disco
        String nome = UUID.randomUUID() + ".jpg";
        Files.write(pasta.resolve(nome), saida.toByteArray());
        return new Aceita(nome, final_.getWidth(), final_.getHeight(), saida.size());
    }

    static String tipoPelaAssinatura(byte[] b) {
        if (b.length >= 3 && (b[0] & 0xFF) == 0xFF && (b[1] & 0xFF) == 0xD8 && (b[2] & 0xFF) == 0xFF) return "jpeg";
        byte[] png = {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
        if (b.length >= 8 && Arrays.equals(Arrays.copyOf(b, 8), png)) return "png";
        return null;
    }

    static BufferedImage reduzir(BufferedImage img, int ladoMaximo) {
        double escala = Math.min(1.0, (double) ladoMaximo / Math.max(img.getWidth(), img.getHeight()));
        int w = (int) Math.round(img.getWidth() * escala), h = (int) Math.round(img.getHeight() * escala);
        var destino = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);   // RGB: sem alfa, que o JPEG não tem
        Graphics2D g = destino.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(img, 0, 0, w, h, java.awt.Color.WHITE, null);
        g.dispose();
        return destino;
    }
}

// ---------- Arquivos de teste, fabricados aqui mesmo ----------

byte[] jpeg(int w, int h) throws IOException {
    var img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
    var g = img.createGraphics();
    g.setColor(new java.awt.Color(30, 120, 160)); g.fillRect(0, 0, w, h / 2);    // "céu"
    g.setColor(new java.awt.Color(40, 140, 60));  g.fillRect(0, h / 2, w, h);    // "mata"
    g.dispose();
    var out = new ByteArrayOutputStream();
    ImageIO.write(img, "jpg", out);
    return out.toByteArray();
}

/** Enfia um bloco EXIF falso, com "coordenadas", logo depois do marcador inicial do JPEG. */
byte[] comExif(byte[] jpeg) {
    byte[] carga = "Exif\0\0GPS -27.7797,-48.5096 (casa da Ana)".getBytes(StandardCharsets.ISO_8859_1);
    int tamanho = carga.length + 2;
    var out = new ByteArrayOutputStream();
    out.write(0xFF); out.write(0xD8);                             // SOI
    out.write(0xFF); out.write(0xE1);                             // APP1 (onde mora o EXIF)
    out.write(tamanho >> 8); out.write(tamanho & 0xFF);
    out.writeBytes(carga);
    out.write(jpeg, 2, jpeg.length - 2);                          // o resto do JPEG original
    return out.toByteArray();
}

/** Um PNG de 1x1 com o cabeçalho reescrito para dizer 20000x20000 (400 milhões de pixels). */
byte[] bombaDePixels() throws IOException {
    var out = new ByteArrayOutputStream();
    ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), "png", out);
    byte[] b = out.toByteArray();
    // IHDR começa no byte 8: [tamanho 4][tipo 4][largura 4][altura 4]...[CRC 4 no byte 29]
    escreverInt(b, 16, 20_000);
    escreverInt(b, 20, 20_000);
    var crc = new CRC32();
    crc.update(b, 12, 17);                                        // tipo + 13 bytes de dados
    escreverInt(b, 29, (int) crc.getValue());
    return b;
}

void escreverInt(byte[] b, int pos, int v) {
    b[pos] = (byte) (v >>> 24); b[pos + 1] = (byte) (v >>> 16); b[pos + 2] = (byte) (v >>> 8); b[pos + 3] = (byte) v;
}

byte[] cortado(byte[] b) { return Arrays.copyOf(b, b.length * 6 / 10); }

boolean contem(byte[] b, String texto) {
    return new String(b, StandardCharsets.ISO_8859_1).contains(texto);
}

void main() throws Exception {
    Path pasta = Files.createTempDirectory("fotos-");              // em produção: FORA da pasta pública
    var fotos = new Fotos(pasta);

    byte[] grande = comExif(jpeg(4000, 3000));
    IO.println("original tem EXIF com GPS? " + contem(grande, "GPS -27.77"));

    record Caso(String descricao, byte[] conteudo) {}
    var casos = java.util.List.of(
            new Caso("foto 4000x3000 com EXIF", grande),
            new Caso("\"foto.jpg\" que é HTML", "<html><script>alert(1)</script></html>".getBytes()),
            new Caso("JPEG cortado no cabeçalho", Arrays.copyOf(jpeg(800, 600), 300)),
            new Caso("JPEG cortado nos pixels", cortado(jpeg(800, 600))),
            new Caso("PNG que diz ter 20000x20000", bombaDePixels()),
            new Caso("arquivo de 6 MB", new byte[6 * 1024 * 1024]));

    for (Caso c : casos) {
        IO.println("%-30s → %s".formatted(c.descricao(), fotos.receber(c.conteudo())));
    }

    try (var arquivos = Files.list(pasta)) {
        for (Path p : arquivos.toList()) {
            byte[] salvo = Files.readAllBytes(p);
            IO.println("salvo: %s (%d KB); ainda tem o GPS? %s".formatted(
                    p.getFileName(), salvo.length / 1024, contem(salvo, "GPS -27.77")));
        }
    }
}
$ java -Djava.awt.headless=true Fotos.java
original tem EXIF com GPS? true
foto 4000x3000 com EXIF        → Aceita[nome=27a1315e-ae88-4cc1-a001-2129ba26bf04.jpg, largura=1600, altura=1200, bytes=31531]
"foto.jpg" que é HTML          → Recusada[motivo=não é JPEG nem PNG]
JPEG cortado no cabeçalho      → Recusada[motivo=imagem corrompida]
JPEG cortado nos pixels        → Recusada[motivo=imagem incompleta: Truncated File - Missing EOI marker]
PNG que diz ter 20000x20000    → Recusada[motivo=imagem de 20000x20000: grande demais]
arquivo de 6 MB                → Recusada[motivo=maior que 5 MB]
salvo: 27a1315e-ae88-4cc1-a001-2129ba26bf04.jpg (30 KB); ainda tem o GPS? false

A saída conta uma história em seis linhas. A foto grande foi aceita, reduzida para 1600×1200 e salva sem o EXIF: o GPS da casa da Ana não chegou ao disco, porque só os pixels atravessaram o reencode. O HTML disfarçado foi barrado pela assinatura: os primeiros bytes de um JPEG são sempre FF D8 FF, e os de um PNG são 89 50 4E 47..., não importa o nome do arquivo. A bomba de pixels foi recusada pelo cabeçalho, antes de a JVM tentar alocar 1,2 GB para decodificá-la.

A linha do JPEG cortado nos pixels tem uma história própria. Na primeira versão deste código, a decodificação usava ImageIO.read(...), e esse caso foi aceito: diante de um JPEG truncado, o leitor não lança exceção, preenche o que falta com cinza e segue em frente. Só o ouvinte de avisos (addIIOReadWarningListener) revela o problema ("Missing EOI marker"). Um upload interrompido pela metade viraria uma foto meio cinza no site, e a gente nem saberia. É mais uma razão para testar com arquivos ruins, e não só com os bons.

Um limite honesto: o ImageIO do JDK lê JPEG, PNG, GIF, BMP, WBMP e TIFF, mas não lê WebP, AVIF nem HEIC, o formato padrão dos iPhones. Um site real que aceite fotos de celular precisará de uma biblioteca extra (como o TwelveMonkeys para o ImageIO) ou de uma ferramenta externa como o libvips. O princípio de validar e reencodar continua o mesmo.

O upload no Spring

O limite de tamanho mais importante é o do servidor, que corta a requisição antes de o arquivo inteiro ser lido:

spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
trilhas.fotos.pasta=/var/lib/trilhas/fotos

A classe Fotos vira um @Service (ArmazemDeFotos) com a pasta vinda da propriedade, e cada foto aceita é registrada numa tabela nova, criada por V8__fotos.sql:

CREATE TABLE foto (
    id          BIGINT      GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    trilha_id   BIGINT      NOT NULL REFERENCES trilha (id) ON DELETE CASCADE,
    arquivo     TEXT        NOT NULL UNIQUE CHECK (arquivo ~ '^[0-9a-f-]{36}\.jpg$'),
    largura     INTEGER     NOT NULL,
    altura      INTEGER     NOT NULL,
    enviada_por TEXT        NOT NULL,
    enviada_em  TIMESTAMPTZ NOT NULL DEFAULT now()
);

O CHECK no nome do arquivo é defesa em profundidade: mesmo que um bug futuro tente gravar um nome estranho, o banco recusa. O controller recebe o arquivo:

@PostMapping(value = "/admin/trilhas/{id}/fotos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String enviarFoto(@PathVariable long id, @RequestParam("foto") MultipartFile foto,
                  Principal quem, RedirectAttributes redirecionar) throws IOException {
    servico.buscar(id);                                            // 404 se a trilha não existe
    if (foto.isEmpty()) {
        redirecionar.addFlashAttribute("erro", "Escolha um arquivo.");
    } else switch (armazem.receber(foto.getBytes())) {
        case ArmazemDeFotos.Aceita a -> {
            fotos.registrar(id, a.nome(), a.largura(), a.altura(), quem.getName());
            redirecionar.addFlashAttribute("mensagem", "Foto adicionada.");
        }
        case ArmazemDeFotos.Recusada r ->
            redirecionar.addFlashAttribute("erro", "Foto recusada: " + r.motivo() + ".");
    }
    return "redirect:/admin/trilhas/" + id;                        // PRG, como qualquer formulário
}

O switch sobre o Resultado selado é o de "Hierarquias Fechadas — Sealed Classes e Pattern Matching": o compilador garante que os dois casos foram tratados. O nome do arquivo enviado (foto.getOriginalFilename()) não aparece em lugar nenhum, nem no disco, nem no banco, nem na mensagem. Ele é a parte mais controlada pelo atacante, e a mais fácil de simplesmente ignorar.

O formulário na página de edição da trilha:

<form th:action="@{/admin/trilhas/{id}/fotos(id=${trilha.id})}" method="post" enctype="multipart/form-data">
    <label for="foto">Nova foto (JPEG ou PNG, até 5 MB)</label>
    <input id="foto" type="file" name="foto" accept="image/jpeg,image/png" required>
    <button type="submit">Enviar</button>
</form>

O enctype="multipart/form-data" é obrigatório para enviar arquivos. Sem ele, o navegador manda só o nome. O accept filtra a janela de seleção de arquivos, mas é cortesia: o servidor não confia nele.

Se alguém enviar um arquivo acima do limite, o Spring lança MaxUploadSizeExceededException ao ler a requisição, antes de escolher o controller. Por isso o tratador não pode ficar no ErrosDoSite da aula anterior: um @ControllerAdvice com basePackages só atende exceções de controllers daqueles pacotes, e aqui ainda não há controller nenhum. Ele fica num advice próprio, sem filtro:

@ControllerAdvice                       // SEM basePackages: a exceção chega antes de haver controller
public class LimiteDeUpload {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    String arquivoGrandeDemais(HttpServletRequest requisicao, RedirectAttributes redirecionar) {
        redirecionar.addFlashAttribute("erro", "O arquivo passa do limite de 5 MB.");
        // getServletPath, e não getRequestURI: o "redirect:/..." já acrescenta o context path
        String volta = requisicao.getServletPath().replaceFirst("/fotos$", "");
        return "redirect:" + volta;
    }
}

Servindo as fotos

As fotos ficam fora da pasta static/, em /var/lib/trilhas/fotos. Se estivessem dentro, o servidor as entregaria com o tipo deduzido pela extensão, e o controle deixaria de ser nosso. Um controller próprio as entrega com as regras fixadas no código:

@GetMapping("/fotos/{arquivo}")
ResponseEntity<Resource> foto(@PathVariable String arquivo) {
    if (!arquivo.matches("[0-9a-f-]{36}\\.jpg")) {                // formato exato: nada de ../
        return ResponseEntity.notFound().build();
    }
    Path caminho = pastaDeFotos.resolve(arquivo);
    if (!Files.isRegularFile(caminho)) return ResponseEntity.notFound().build();

    return ResponseEntity.ok()
            .contentType(MediaType.IMAGE_JPEG)                     // sempre JPEG: nós o geramos
            .header("X-Content-Type-Options", "nosniff")           // o navegador não "adivinha" outro tipo
            .cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable())
            .body(new FileSystemResource(caminho));
}

Cada linha é uma lição do Farol aplicada. A expressão regular é a defesa contra path traversal de "A Pedra de Acabamento — Arquivos Estáticos, Testes e Empacotamento", e, como o nome é um UUID gerado por nós, um formato exato é possível e basta. O nosniff impede o navegador de tratar o conteúdo como HTML, mesmo que alguém consiga, de alguma forma, esconder marcação ali. E o cache "imutável por um ano" é seguro justamente porque o nome é único: uma foto nova sempre tem um nome novo, e nenhuma versão antiga fica presa no cache de ninguém.

Testando a área administrativa

@WebMvcTest(PaginasAdministrativas.class)
@Import(ConfiguracaoDeSeguranca.class)
@TestPropertySource(properties = "trilhas.jwt.segredo=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
class PaginasAdministrativasTest {

    @Autowired MockMvcTester mvc;
    @MockitoBean ServicoDeTrilhas servico;
    @MockitoBean ArmazemDeFotos armazem;
    @MockitoBean RegistroDeFotos fotos;

    @Test
    void visitanteEMandadoParaOLogin() {
        assertThat(mvc.get().uri("/admin/trilhas"))
                .hasStatus3xxRedirection()
                .redirectedUrl().endsWith("/entrar");
    }

    @Test
    void editorNaoApagaAdminApagaEHtmxRecebe200Vazio() {
        assertThat(mvc.delete().uri("/admin/trilhas/1").with(user("edu").roles("EDITOR")).with(csrf()))
                .hasStatus(HttpStatus.FORBIDDEN);
        assertThat(mvc.delete().uri("/admin/trilhas/1").with(user("ana").roles("ADMIN")).with(csrf()))
                .hasStatusOk()
                .bodyText().isEmpty();            // 200 vazio, e NÃO 204: senão a linha fica na tela
    }

    @Test
    void deleteSemCsrfERecusadoMesmoParaAdmin() {
        assertThat(mvc.delete().uri("/admin/trilhas/1").with(user("ana").roles("ADMIN")))
                .hasStatus(HttpStatus.FORBIDDEN);
    }

    @Test
    void uploadRecusadoVoltaComMensagem() throws Exception {
        given(armazem.receber(any())).willReturn(new ArmazemDeFotos.Recusada("não é JPEG nem PNG"));
        var arquivo = new MockMultipartFile("foto", "gato.jpg", "image/jpeg", "<html>".getBytes());

        assertThat(mvc.post().uri("/admin/trilhas/1/fotos").multipart().file(arquivo)
                .with(user("edu").roles("EDITOR")).with(csrf()))
                .hasStatus3xxRedirection()
                .flash().containsEntry("erro", "Foto recusada: não é JPEG nem PNG.");
        verifyNoInteractions(fotos);
    }
}

O throws Exception do último teste existe porque receber declara IOException, e o given(...) chama o método no dublê: sem ele, o teste não compila. Os imports seguem os das aulas anteriores. As novidades vêm de SecurityMockMvcRequestPostProcessors (user e csrf) e de org.springframework.mock.web.MockMultipartFile. O teste que mais importa é o de sempre: cada linha da tabela de permissões tem um teste, inclusive a regra do 200 contra o 204, que agora está documentada em código e não só neste texto.

Com isso, as Trilhas da Ilha estão completas:

  • Dados: um catálogo em PostgreSQL e relatos em MongoDB.
  • API: REST, com JWT e documentação OpenAPI.
  • Site: busca instantânea, área administrativa com login, e fotos validadas e limpas.
  • Testes: contra bancos de verdade.

Tudo isso ainda roda em localhost, mas já com as defesas que um site público exige.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O botão "Apagar" funciona, mas o contador "Página 1 de 3 (47 trilhas)" no rodapé da tabela continua mostrando 47 depois de uma exclusão. Proponha uma solução com htmx que não recarregue a página inteira.

Ver resposta

✓ Resposta: Há duas boas opções. A primeira é que a resposta do DELETE, além do vazio que remove a linha, traga um fragmento fora de banda: <span id="total" hx-swap-oob="true">46 trilhas</span>. O htmx procura na página o elemento com o mesmo id e o substitui, independentemente do hx-target. A segunda é o servidor responder com o cabeçalho HX-Trigger: trilhaApagada, e o contador escutar esse evento com hx-get="/admin/trilhas/total" hx-trigger="trilhaApagada from:body". A primeira faz uma requisição a menos. A segunda desacopla o botão do contador.

Exercício 2

Um colega trocou o Vary: HX-Request por nada, "porque ninguém usa cache aqui". Descreva um cenário concreto, com o nginx da próxima aula, em que isso quebra o site.

Ver resposta

✓ Resposta: Configuramos o nginx para guardar por 60 segundos as respostas de /trilhas. Um visitante digita "lago" na busca: o htmx pede /trilhas?q=lago com HX-Request: true, e o nginx guarda o fragmento sob essa URL. Outro visitante clica, num grupo de WhatsApp, no link .../trilhas?q=lago. O nginx entrega do cache o fragmento: uma tabela solta, sem layout, sem CSS e sem menu. Com o Vary: HX-Request, o nginx guarda as duas versões separadamente, conforme o cabeçalho, e cada visitante recebe a sua.

Exercício 3

Na classe Fotos, por que as dimensões são conferidas antes do leitor.read(0), e não depois, olhando o BufferedImage resultante?

Ver resposta

✓ Resposta: Porque o problema é a própria decodificação. Para devolver o BufferedImage de um PNG de 20000×20000, o leitor aloca 20000 × 20000 × 3 bytes, 1,2 GB, antes de ler o primeiro pixel. Numa JVM com 1 GB de heap, o arquivo de 69 bytes do exemplo provoca um OutOfMemoryError em cerca de 40 ms. Com mais memória, ela é tomada das requisições em andamento, que podem falhar junto: um ataque de negação de serviço com um arquivo minúsculo. (Com 60000×60000, o próprio leitor de PNG do JDK recusa a imagem, porque ela não cabe num array: a bomba perigosa é a que cabe.) getWidth(0) e getHeight(0) leem só o cabeçalho, alguns bytes, e permitem recusar antes de gastar qualquer memória significativa.

Exercício 4

Um administrador pede: "quero que a foto mantenha a data em que foi tirada, que vem no EXIF". Como atender o pedido sem reabrir o vazamento de GPS?

Ver resposta

✓ Resposta: Leia só o campo desejado antes do reencode (a data original, DateTimeOriginal) e guarde-o numa coluna própria da tabela foto (tirada_em TIMESTAMPTZ), validado e convertido. O arquivo final continua reencodado, sem EXIF nenhum. A regra geral é uma lista de permissões, não de proibições: em vez de tentar remover o GPS e esquecer outro campo sensível (número de série da câmera, miniatura antiga com o recorte original), extraia o que você quer, de forma explícita, e descarte todo o resto.

Exercício 5

O logout é um formulário POST. Explique o ataque que um logout por GET permitiria, e por que ele é mais sério do que parece.

Ver resposta

✓ Resposta: Com logout por GET /sair, qualquer página da internet poderia conter <img src="https://trilhas.example.com/sair">, e o navegador da vítima faria a requisição com o cookie de sessão, deslogando-a sem aviso. Isso parece só um incômodo, mas é o primeiro passo de ataques maiores. Um exemplo é deslogar a vítima e em seguida levá-la a uma página de login falsa ("sua sessão expirou, entre de novo"), que é o golpe clássico de phishing. Outro é combinar com um login CSRF para colocá-la na conta do atacante. Exigir POST com token CSRF fecha as duas portas, e é por isso que o Spring Security adota esse padrão.

Comentários

Mais em Java

Dividir para Resolver — Métodos, Parâmetros e Recursão
Dividir para Resolver — Métodos, Parâmetros e Recursão

Como escrever métodos em Java: tipo de retorno, parâmetros, return, passagem…

Fazendo Duas Coisas ao Mesmo Tempo — Threads
Fazendo Duas Coisas ao Mesmo Tempo — Threads

O que é uma thread e como criar, esperar e interromper várias delas: start e…

Recursos que se Fecham Sozinhos — try-with-resources, Arquivos e NIO.2
Recursos que se Fecham Sozinhos — try-with-resources, Arquivos e NIO.2

Como ler e gravar arquivos com a API NIO.2 do Java (Path e Files), tratar a…