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-targetdiz onde, ehx-swapdiz 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 umGET /trilhas?q=.... O htmx inclui oname/valuedo próprio campo. Oth:attrgera 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. Osearchcobre o "×" que limpa o campo nos navegadores.hx-target="#resultados"ehx-swap="outerHTML": a resposta substitui adiv#resultadosinteira. O fragmentoth:fragment="resultados"inclui a própriadiv, e com ohx-swappadrão,innerHTML, ela entraria dentro da antiga: no teste da próxima seção, a página ficou com duasdiv#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/trilhassem login é mandado a/entrare, 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
- htmx — documentação: atributos, gatilhos, trocas e o capítulo sobre segurança.
- htmx — referência de atributos:
hx-trigger,hx-swap,hx-push-url,hx-confirme os cabeçalhos que ele envia e entende. - Hypermedia Systems, de Carson Gross e colegas: o livro dos criadores do htmx sobre a abordagem de hipermídia, gratuito on-line.
- OWASP — File Upload Cheat Sheet: a lista completa de defesas para upload.
- Spring Security — Form Login: página de login própria, parâmetros e redirecionamentos.
- Spring Security — Session Management: fixação de sessão, tempo de expiração e controle de sessões simultâneas.
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.