Nesta aula nasce o projeto do Farol: um leitor de requisições que trabalha direto sobre os bytes do socket, com limites e respostas de erro precisas, um escritor de respostas que não aceita injeção de cabeçalhos e um servidor que nenhum cliente consegue derrubar.
"O Projeto Final Começa" terminou com um rascunho de trinta linhas que respondia ao curl, mas caía de três formas: um cliente calado travava todos os outros, um cliente que desistia produzia uma requisição "null", e uma conexão fechada no meio da resposta matava o servidor inteiro. Também mostrou que o BufferedReader do rascunho engole o corpo das requisições junto com os cabeçalhos. Esta aula constrói as duas camadas de baixo da arquitetura do Farol, o protocolo e o começo da rede, e deixa o servidor robusto, embora ainda atendendo uma conexão por vez. A concorrência fica para "A Terceira Pedra"; aqui a prioridade é que cada byte seja lido corretamente e que nenhuma entrada, por pior que seja, cause um estrago maior do que uma resposta de erro.
O projeto
O Farol é um projeto Maven, como os de "Construção Automatizada", com um módulo, como em "Fronteiras Explícitas". A estrutura de pastas segue as camadas desenhadas na aula anterior:
farol/
├── pom.xml
└── src/
├── main/java/
│ ├── module-info.java
│ └── br/com/exemplo/farol/
│ ├── http/ Cabecalhos, Corpo, Requisicao, Resposta, Manipulador
│ ├── protocolo/ Limites, ErroDeProtocolo, EntradaHttp, LeitorDeRequisicao, EscritorDeResposta
│ ├── nucleo/ Servidor, Registro
│ └── app/ Principal
└── test/java/br/com/exemplo/farol/
├── protocolo/ LeitorDeRequisicaoTest
└── nucleo/ ServidorTest
O pom.xml não tem nenhuma dependência de produção. As únicas bibliotecas são as de teste, nas versões usadas em "Provando que Funciona":
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>br.com.exemplo</groupId>
<artifactId>farol</artifactId>
<version>0.1.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<mainClass>br.com.exemplo.farol.app.Principal</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
O module-info.java exporta as camadas que outra aplicação poderia usar e mantém o pacote app fechado:
module br.com.exemplo.farol {
exports br.com.exemplo.farol.http;
exports br.com.exemplo.farol.protocolo;
exports br.com.exemplo.farol.nucleo;
}
Os tipos que as camadas trocam
A camada de protocolo precisa entregar alguma coisa ao resto do servidor. Nesta aula, os tipos do pacote http são propositalmente simples; "A Segunda Pedra" vai refiná-los.
Os cabeçalhos têm duas particularidades que um Map<String, String> comum não atende: os nomes não diferenciam maiúsculas de minúsculas, e o mesmo nome pode aparecer mais de uma vez. A classe Cabecalhos resolve as duas com um TreeMap ordenado por String.CASE_INSENSITIVE_ORDER, o comparador de "Ordem e Critério", e é imutável, com um Construtor para a montagem, no padrão de "Lições de Quem Veio Antes":
package br.com.exemplo.farol.http;
import java.util.*;
/**
* Cabeçalhos HTTP: imutáveis, com nomes que não diferenciam maiúsculas de minúsculas
* e com a possibilidade de o mesmo nome aparecer mais de uma vez.
*/
public final class Cabecalhos {
public static final Cabecalhos VAZIOS = new Cabecalhos(new TreeMap<>(String.CASE_INSENSITIVE_ORDER));
private final SortedMap<String, List<String>> valores;
private Cabecalhos(SortedMap<String, List<String>> valores) {
this.valores = valores; // recebido já copiado e imutável: construtor privado
}
/** Todos os valores de um nome, na ordem em que chegaram (lista vazia se não houver). */
public List<String> todos(String nome) {
return valores.getOrDefault(nome, List.of());
}
/** O valor de um nome; se ele aparecer mais de uma vez, os valores são unidos com ", ". */
public Optional<String> valor(String nome) {
List<String> lista = todos(nome);
return lista.isEmpty() ? Optional.empty() : Optional.of(String.join(", ", lista));
}
public boolean contem(String nome) {
return valores.containsKey(nome);
}
public Set<String> nomes() {
return Collections.unmodifiableSet(valores.keySet());
}
/** Uma cópia com mais um valor para o nome dado. */
public Cabecalhos com(String nome, String valor) {
return construtor().copiar(this).adicionar(nome, valor).criar();
}
/** Uma cópia em que o nome dado tem exatamente este valor (substitui os anteriores). */
public Cabecalhos comUnico(String nome, String valor) {
Construtor c = construtor().copiar(this);
c.valores.remove(nome);
return c.adicionar(nome, valor).criar();
}
public static Construtor construtor() {
return new Construtor();
}
@Override public boolean equals(Object o) {
return o instanceof Cabecalhos outro && valores.equals(outro.valores);
}
@Override public int hashCode() { return valores.hashCode(); }
@Override public String toString() { return valores.toString(); }
public static final class Construtor {
private final SortedMap<String, List<String>> valores = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
private Construtor() {}
public Construtor adicionar(String nome, String valor) {
Objects.requireNonNull(nome, "nome");
Objects.requireNonNull(valor, "valor");
valores.computeIfAbsent(nome, n -> new ArrayList<>()).add(valor);
return this;
}
private Construtor copiar(Cabecalhos origem) {
origem.valores.forEach((n, vs) -> valores.put(n, new ArrayList<>(vs)));
return this;
}
public int quantidade() {
return valores.size();
}
public Cabecalhos criar() {
SortedMap<String, List<String>> copia = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
valores.forEach((n, vs) -> copia.put(n, List.copyOf(vs)));
return new Cabecalhos(Collections.unmodifiableSortedMap(copia));
}
}
}
O corpo é uma sequência de bytes. "Contratos à Vista" mostrou por que um array não deve ser componente de um record: ele é mutável, e o equals gerado compara identidade. A classe Corpo encapsula o array com cópia na entrada e na saída, e oferece duas operações que não copiam, comoFluxo e escreverEm, para que o escritor possa enviar um corpo de 1 MB sem duplicá-lo na memória:
package br.com.exemplo.farol.http;
import java.io.*;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** O corpo de uma mensagem HTTP: uma sequência imutável de bytes. */
public final class Corpo {
public static final Corpo VAZIO = new Corpo(new byte[0]);
private final byte[] bytes;
private Corpo(byte[] bytes) {
this.bytes = bytes;
}
/** Cria um corpo com uma cópia dos bytes recebidos. */
public static Corpo de(byte[] bytes) {
return bytes.length == 0 ? VAZIO : new Corpo(bytes.clone());
}
public static Corpo deTexto(String texto) {
return de(texto.getBytes(StandardCharsets.UTF_8)); // getBytes já cria um array novo
}
public int tamanho() { return bytes.length; }
public boolean vazio() { return bytes.length == 0; }
public byte[] bytes() { return bytes.clone(); }
public String comoTexto() { return comoTexto(StandardCharsets.UTF_8); }
public String comoTexto(Charset codificacao) { return new String(bytes, codificacao); }
/** Lê o corpo sem copiá-lo. */
public InputStream comoFluxo() { return new ByteArrayInputStream(bytes); }
/** Escreve o corpo sem copiá-lo. */
public void escreverEm(OutputStream saida) throws IOException { saida.write(bytes); }
@Override public boolean equals(Object o) {
return o instanceof Corpo outro && Arrays.equals(bytes, outro.bytes);
}
@Override public int hashCode() { return Arrays.hashCode(bytes); }
@Override public String toString() { return "Corpo[" + bytes.length + " bytes]"; }
}
A requisição e a resposta, por enquanto, são records com textos e números:
package br.com.exemplo.farol.http;
import java.util.Objects;
/** Uma requisição HTTP já lida e validada pela camada de protocolo. */
public record Requisicao(String metodo, String alvo, String versao, Cabecalhos cabecalhos, Corpo corpo) {
public Requisicao {
Objects.requireNonNull(metodo, "metodo");
Objects.requireNonNull(alvo, "alvo");
Objects.requireNonNull(versao, "versao");
Objects.requireNonNull(cabecalhos, "cabecalhos");
Objects.requireNonNull(corpo, "corpo");
}
}
package br.com.exemplo.farol.http;
import java.util.Objects;
/** Uma resposta HTTP, pronta para ser escrita pela camada de protocolo. */
public record Resposta(int status, String razao, Cabecalhos cabecalhos, Corpo corpo) {
public Resposta {
if (status < 100 || status > 599) throw new IllegalArgumentException("status inválido: " + status);
Objects.requireNonNull(razao, "razao");
Objects.requireNonNull(cabecalhos, "cabecalhos");
Objects.requireNonNull(corpo, "corpo");
}
public static Resposta texto(int status, String razao, String texto) {
return new Resposta(status, razao,
Cabecalhos.VAZIOS.com("Content-Type", "text/plain; charset=utf-8"),
Corpo.deTexto(texto));
}
public Resposta comCabecalho(String nome, String valor) {
return new Resposta(status, razao, cabecalhos.comUnico(nome, valor), corpo);
}
}
E a interface que a aplicação implementa, apresentada em "O Projeto Final Começa":
package br.com.exemplo.farol.http;
/** O código que atende uma requisição: tudo o que uma aplicação precisa implementar. */
@FunctionalInterface
public interface Manipulador {
Resposta atender(Requisicao requisicao) throws Exception;
}
Repare numa mudança em relação ao desenho da aula anterior: o Manipulador saiu do pacote rotas e foi para o http. O núcleo, na camada de baixo, precisa chamar o manipulador, e se a interface ficasse na camada de rotas, o núcleo dependeria de uma camada acima dele. Colocando a interface junto dos tipos básicos, o núcleo depende só de uma abstração, e as rotas a implementam. É a inversão de dependência, o "D" do SOLID de "Lições de Quem Veio Antes", aplicada para valer.
Limites e erros
A primeira regra de um servidor exposto à internet é que toda entrada tem um tamanho máximo. Os limites ficam num record, com valores padrão parecidos com os de servidores de produção e validação no construtor compacto:
package br.com.exemplo.farol.protocolo;
import java.time.Duration;
/** Os limites que protegem o servidor de clientes mal-comportados. */
public record Limites(
int tamanhoMaximoDaLinhaDePedido, // excedido: 414 URI Too Long
int tamanhoMaximoDosCabecalhos, // soma de todas as linhas; excedido: 431
int maximoDeCabecalhos, // excedido: 431
long tamanhoMaximoDoCorpo, // excedido: 413 Content Too Large
Duration prazoDeLeitura) { // cliente calado por mais tempo: 408
public static final Limites PADRAO = new Limites(8 * 1024, 16 * 1024, 100, 1024 * 1024, Duration.ofSeconds(10));
public Limites {
if (tamanhoMaximoDaLinhaDePedido < 16) throw new IllegalArgumentException("linha de pedido pequena demais");
if (tamanhoMaximoDosCabecalhos < 64) throw new IllegalArgumentException("área de cabeçalhos pequena demais");
if (maximoDeCabecalhos < 1) throw new IllegalArgumentException("é preciso aceitar ao menos 1 cabeçalho");
if (tamanhoMaximoDoCorpo < 0) throw new IllegalArgumentException("tamanho de corpo negativo");
if (prazoDeLeitura.isNegative() || prazoDeLeitura.isZero())
throw new IllegalArgumentException("o prazo de leitura precisa ser positivo");
}
public Limites comPrazoDeLeitura(Duration prazo) {
return new Limites(tamanhoMaximoDaLinhaDePedido, tamanhoMaximoDosCabecalhos, maximoDeCabecalhos,
tamanhoMaximoDoCorpo, prazo);
}
public Limites comTamanhoMaximoDoCorpo(long bytes) {
return new Limites(tamanhoMaximoDaLinhaDePedido, tamanhoMaximoDosCabecalhos, maximoDeCabecalhos,
bytes, prazoDeLeitura);
}
}
Quando uma requisição viola o protocolo ou um limite, o leitor lança uma ErroDeProtocolo. Ela é uma exceção verificada, pelo critério de "Quando o Contrato se Quebra": uma requisição inválida não é um defeito do Farol, e sim uma situação esperada que quem chama o leitor é obrigado a tratar. E ela carrega o código de estado a devolver, para que o servidor não precise adivinhar:
package br.com.exemplo.farol.protocolo;
/**
* Uma requisição que não pode ser atendida por culpa do cliente.
* Carrega o código de estado que o servidor deve devolver.
*/
public final class ErroDeProtocolo extends Exception {
private static final long serialVersionUID = 1L;
private final int status;
private final String razao;
public ErroDeProtocolo(int status, String razao, String detalhe) {
super(status + " " + razao + ": " + detalhe);
this.status = status;
this.razao = razao;
}
public int status() { return status; }
public String razao() { return razao; }
// Fábricas com nome para os erros usados pelo leitor
static ErroDeProtocolo requisicaoInvalida(String detalhe) { return new ErroDeProtocolo(400, "Bad Request", detalhe); }
static ErroDeProtocolo alvoLongoDemais(String detalhe) { return new ErroDeProtocolo(414, "URI Too Long", detalhe); }
static ErroDeProtocolo cabecalhosGrandesDemais(String detalhe) { return new ErroDeProtocolo(431, "Request Header Fields Too Large", detalhe); }
static ErroDeProtocolo corpoGrandeDemais(String detalhe) { return new ErroDeProtocolo(413, "Content Too Large", detalhe); }
static ErroDeProtocolo tamanhoObrigatorio(String detalhe) { return new ErroDeProtocolo(411, "Length Required", detalhe); }
static ErroDeProtocolo versaoNaoSuportada(String detalhe) { return new ErroDeProtocolo(505, "HTTP Version Not Supported", detalhe); }
}
O serialVersionUID existe porque toda exceção é Serializable, e sem ele o compilador, com -Xlint:all, emite um aviso.
Lendo bytes, não caracteres
A peça central da aula é a classe que lê do socket. Ela resolve o problema do BufferedReader: há um único buffer, um BufferedInputStream, e as duas operações, ler uma linha e ler um bloco de bytes, tiram dados dele. O que uma leitura não usou fica no buffer para a próxima, e nada se perde entre os cabeçalhos e o corpo:
package br.com.exemplo.farol.protocolo;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.function.Supplier;
/**
* Lê linhas terminadas em CRLF e blocos de bytes de um mesmo fluxo,
* sem nunca ler além do que foi pedido.
*/
final class EntradaHttp {
private static final int CR = '\r', LF = '\n';
private final InputStream entrada;
EntradaHttp(InputStream entrada) {
// Um único buffer para linhas e corpo: nada se perde entre as duas leituras
this.entrada = entrada instanceof BufferedInputStream ? entrada : new BufferedInputStream(entrada);
}
/**
* Lê uma linha e devolve o texto sem o CRLF final, ou null se a conexão
* terminou antes do primeiro byte da linha.
*
* @param limite número máximo de bytes da linha, sem contar o CRLF
* @param excedente fabrica o erro a lançar se o limite for ultrapassado
*/
String lerLinha(int limite, Supplier<ErroDeProtocolo> excedente) throws IOException, ErroDeProtocolo {
ByteArrayOutputStream linha = new ByteArrayOutputStream(Math.min(limite, 256));
int b = entrada.read();
if (b == -1) return null; // fim limpo: nenhum byte desta linha chegou
while (true) {
if (b == -1) throw ErroDeProtocolo.requisicaoInvalida("conexão encerrada no meio de uma linha");
if (b == LF) break; // aceita LF sozinho, como a RFC 9112 permite
if (b == CR) {
int seguinte = entrada.read();
if (seguinte == LF) break;
throw ErroDeProtocolo.requisicaoInvalida("CR sem LF dentro de uma linha");
}
if (linha.size() == limite) throw excedente.get();
linha.write(b);
b = entrada.read();
}
// ISO-8859-1: um byte, um caractere; nenhum byte se perde na conversão
return linha.toString(StandardCharsets.ISO_8859_1);
}
/** Lê exatamente n bytes, ou falha se a conexão terminar antes. */
byte[] lerExatamente(int n) throws IOException, ErroDeProtocolo {
byte[] bytes = entrada.readNBytes(n);
if (bytes.length < n)
throw ErroDeProtocolo.requisicaoInvalida("corpo incompleto: esperados " + n + " bytes, chegaram " + bytes.length);
return bytes;
}
}
Ler um byte por vez parece lento, mas não é: o read() de um BufferedInputStream só vai ao sistema operacional quando o buffer interno, de 8 KB, se esvazia. As outras chamadas são uma leitura de array e um incremento de índice, que o JIT reduz a quase nada.
Quatro decisões de desenho merecem destaque. A primeira é a distinção entre fim limpo e fim sujo: se a conexão termina antes de qualquer byte da linha, o método devolve null, porque o cliente simplesmente foi embora, e isso não é um erro; se termina no meio de uma linha, é uma requisição quebrada, e merece um 400. É essa distinção que elimina a requisição "null" do rascunho. A segunda é o limite verificado a cada byte: uma linha de dois gigabytes é recusada assim que passa do limite, sem que o servidor guarde mais do que o limite na memória. A terceira é a tolerância a LF sozinho, que a RFC 9112 permite aos servidores aceitarem, com a recusa de um CR solto no meio da linha, um truque usado em alguns ataques. A quarta é o Supplier<ErroDeProtocolo>: em vez de receber o erro pronto, o método recebe uma fábrica, e a exceção só é criada se o limite for de fato ultrapassado. Criar uma exceção custa caro, porque a JVM preenche a pilha de chamadas, e o exercício 4 mede quanto.
O leitor de requisições
Com a entrada pronta, o leitor segue a gramática da RFC 9112, parte por parte:
package br.com.exemplo.farol.protocolo;
import br.com.exemplo.farol.http.*;
import java.io.*;
import java.util.*;
import java.util.regex.Pattern;
/**
* Transforma os bytes que chegam de uma conexão em objetos Requisicao,
* seguindo a RFC 9112 e os limites configurados.
*/
public final class LeitorDeRequisicao {
// tchar da RFC 9110: os caracteres permitidos em métodos e nomes de cabeçalho
private static final Pattern TOKEN = Pattern.compile("[!#$%&'*+.^_`|~0-9A-Za-z-]+");
private static final Pattern VERSAO = Pattern.compile("HTTP/\\d\\.\\d");
private static final int LINHAS_VAZIAS_TOLERADAS = 2;
private final EntradaHttp entrada;
private final Limites limites;
public LeitorDeRequisicao(InputStream entrada, Limites limites) {
this.entrada = new EntradaHttp(entrada);
this.limites = Objects.requireNonNull(limites);
}
/**
* Lê a próxima requisição da conexão.
*
* @return a requisição, ou vazio se o cliente encerrou a conexão antes de começar outra
* @throws ErroDeProtocolo se a requisição violar o protocolo ou os limites
* @throws IOException se a conexão falhar (inclusive por prazo esgotado)
*/
public Optional<Requisicao> ler() throws IOException, ErroDeProtocolo {
String linhaDePedido = lerLinhaDePedido();
if (linhaDePedido == null) return Optional.empty();
// 1. A linha de pedido: exatamente três campos separados por um espaço
String[] partes = linhaDePedido.split(" ", -1);
if (partes.length != 3)
throw ErroDeProtocolo.requisicaoInvalida("linha de pedido malformada: '" + linhaDePedido + "'");
String metodo = partes[0], alvo = partes[1], versao = partes[2];
if (!TOKEN.matcher(metodo).matches())
throw ErroDeProtocolo.requisicaoInvalida("método inválido: '" + metodo + "'");
if (!alvo.startsWith("/"))
throw ErroDeProtocolo.requisicaoInvalida("alvo deve começar com '/': '" + alvo + "'");
validarVersao(versao);
// 2. Os cabeçalhos, até a linha vazia
Cabecalhos cabecalhos = lerCabecalhos();
if (versao.equals("HTTP/1.1")) {
int hosts = cabecalhos.todos("Host").size();
if (hosts != 1)
throw ErroDeProtocolo.requisicaoInvalida("HTTP/1.1 exige exatamente um cabeçalho Host, veio " + hosts);
}
// 3. O corpo, se houver
Corpo corpo = lerCorpo(cabecalhos);
return Optional.of(new Requisicao(metodo, alvo, versao, cabecalhos, corpo));
}
private String lerLinhaDePedido() throws IOException, ErroDeProtocolo {
int limite = limites.tamanhoMaximoDaLinhaDePedido();
// A RFC 9112 recomenda ignorar linhas vazias antes da linha de pedido
for (int i = 0; i <= LINHAS_VAZIAS_TOLERADAS; i++) {
String linha = entrada.lerLinha(limite,
() -> ErroDeProtocolo.alvoLongoDemais("linha de pedido passou de " + limite + " bytes"));
if (linha == null) return null;
if (!linha.isEmpty()) return linha;
}
throw ErroDeProtocolo.requisicaoInvalida("linhas vazias demais antes da linha de pedido");
}
private void validarVersao(String versao) throws ErroDeProtocolo {
if (!VERSAO.matcher(versao).matches())
throw ErroDeProtocolo.requisicaoInvalida("versão malformada: '" + versao + "'");
if (!versao.equals("HTTP/1.1") && !versao.equals("HTTP/1.0"))
throw ErroDeProtocolo.versaoNaoSuportada(versao);
}
private Cabecalhos lerCabecalhos() throws IOException, ErroDeProtocolo {
Cabecalhos.Construtor construtor = Cabecalhos.construtor();
int restante = limites.tamanhoMaximoDosCabecalhos();
int linhas = 0;
while (true) {
int limiteDestaLinha = Math.max(0, restante);
String linha = entrada.lerLinha(limiteDestaLinha,
() -> ErroDeProtocolo.cabecalhosGrandesDemais("cabeçalhos passaram de "
+ limites.tamanhoMaximoDosCabecalhos() + " bytes"));
if (linha == null) throw ErroDeProtocolo.requisicaoInvalida("conexão encerrada nos cabeçalhos");
if (linha.isEmpty()) return construtor.criar(); // a linha vazia encerra os cabeçalhos
restante -= linha.length() + 2;
if (++linhas > limites.maximoDeCabecalhos())
throw ErroDeProtocolo.cabecalhosGrandesDemais("mais de " + limites.maximoDeCabecalhos() + " cabeçalhos");
if (linha.charAt(0) == ' ' || linha.charAt(0) == '\t')
throw ErroDeProtocolo.requisicaoInvalida("cabeçalho dobrado em várias linhas (obsoleto)");
int doisPontos = linha.indexOf(':');
if (doisPontos <= 0)
throw ErroDeProtocolo.requisicaoInvalida("cabeçalho sem nome ou sem ':': '" + linha + "'");
String nome = linha.substring(0, doisPontos);
if (!TOKEN.matcher(nome).matches()) // inclui o caso de espaço antes dos dois-pontos
throw ErroDeProtocolo.requisicaoInvalida("nome de cabeçalho inválido: '" + nome + "'");
String valor = linha.substring(doisPontos + 1).strip();
construtor.adicionar(nome, valor);
}
}
private Corpo lerCorpo(Cabecalhos cabecalhos) throws IOException, ErroDeProtocolo {
boolean temTransferEncoding = cabecalhos.contem("Transfer-Encoding");
List<String> tamanhos = cabecalhos.todos("Content-Length");
if (temTransferEncoding && !tamanhos.isEmpty())
// Os dois juntos são a base do ataque de "contrabando de requisições"
throw ErroDeProtocolo.requisicaoInvalida("Transfer-Encoding e Content-Length na mesma requisição");
if (temTransferEncoding)
// Simplificação do Farol: corpos em partes (chunked) não são aceitos
throw ErroDeProtocolo.tamanhoObrigatorio("envie o corpo com Content-Length");
if (tamanhos.isEmpty()) return Corpo.VAZIO; // sem os dois cabeçalhos, não há corpo
long tamanho = tamanhoDeclarado(tamanhos);
if (tamanho > limites.tamanhoMaximoDoCorpo())
throw ErroDeProtocolo.corpoGrandeDemais(tamanho + " bytes; o máximo é " + limites.tamanhoMaximoDoCorpo());
return tamanho == 0 ? Corpo.VAZIO : Corpo.de(entrada.lerExatamente((int) tamanho));
}
/** Content-Length pode vir repetido ou como lista ("5, 5"), mas todos os valores precisam ser iguais. */
private static long tamanhoDeclarado(List<String> valores) throws ErroDeProtocolo {
Set<String> distintos = new HashSet<>();
for (String valor : valores)
for (String parte : valor.split(",", -1)) distintos.add(parte.strip());
if (distintos.size() != 1)
throw ErroDeProtocolo.requisicaoInvalida("Content-Length com valores diferentes: " + valores);
String texto = distintos.iterator().next();
if (texto.isEmpty() || texto.length() > 18 || !texto.chars().allMatch(c -> c >= '0' && c <= '9'))
throw ErroDeProtocolo.requisicaoInvalida("Content-Length inválido: '" + texto + "'");
return Long.parseLong(texto);
}
}
A linha de pedido é dividida com split(" ", -1): o -1 preserva os campos vazios, então GET / HTTP/1.1, com dois espaços, produz quatro partes e é recusado, como a gramática exige. O método precisa ser um token, o conjunto de caracteres que a RFC 9110 chama de tchar, expresso aqui numa expressão regular de "Tempo, Dinheiro e Padrões". Uma versão que tem o formato certo mas não é 1.0 nem 1.1 recebe 505; uma versão malformada, 400.
Os cabeçalhos têm três regras que parecem detalhes e são, na verdade, defesas. Um espaço entre o nome e os dois-pontos (Host : x) é recusado, porque a RFC 9112 exige isso: servidores e proxies diferentes interpretavam esse caso de formas diferentes, e a divergência era explorada em ataques. Linhas que começam com espaço, a antiga "dobra" de cabeçalhos em várias linhas, também são recusadas, pelo mesmo motivo. E o tamanho é verificado de duas formas: pela soma de bytes de todas as linhas, através da variável restante, que vira o limite da linha seguinte, e pelo número de linhas. O HTTP/1.1 exige exatamente um Host, e o leitor verifica isso depois de ler todos.
O corpo é onde mora o ataque mais sério contra servidores HTTP, o contrabando de requisições (request smuggling). Uma requisição pode indicar o tamanho do corpo de duas formas, Content-Length e Transfer-Encoding: chunked, e se um proxy usa uma e o servidor usa a outra, o atacante consegue esconder uma segunda requisição dentro do corpo da primeira, que o proxy nunca inspecionou. A defesa é recusar qualquer requisição com os dois cabeçalhos, e recusar Content-Length com valores diferentes. O tamanhoDeclarado aceita a forma repetida (Content-Length: 5 duas vezes) e a forma de lista (5, 5), desde que todos os valores sejam iguais, e só aceita dígitos: Long.parseLong sozinho aceitaria +5 e -1.
Há aqui uma simplificação honesta: a RFC 9112 exige que todo servidor HTTP/1.1 entenda corpos em partes (chunked), e o Farol não entende. Em vez de fingir, ele responde 411, "Length Required", pedindo ao cliente que reenvie com Content-Length. Clientes como o curl e os navegadores sempre enviam o tamanho quando o conhecem, e na prática o caso raramente aparece. Implementar o formato em partes é um bom exercício depois do curso.
Sem nenhum dos dois cabeçalhos, a requisição não tem corpo: essa é a regra do protocolo para requisições. E o corpo é lido com lerExatamente, os Content-Length bytes, nem um a mais. Os bytes seguintes, se houver, pertencem à próxima requisição da mesma conexão, o que vai importar muito em "A Terceira Pedra".
O escritor de respostas
O caminho de volta é mais simples, mas tem as suas armadilhas:
package br.com.exemplo.farol.protocolo;
import br.com.exemplo.farol.http.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.time.format.DateTimeFormatter;
/** Transforma um objeto Resposta nos bytes que o cliente vai receber. */
public final class EscritorDeResposta {
private final Clock relogio;
public EscritorDeResposta(Clock relogio) {
this.relogio = relogio;
}
public EscritorDeResposta() {
this(Clock.systemUTC());
}
/**
* Escreve a resposta completa.
*
* @param semCorpo true para respostas a HEAD: cabeçalhos iguais, corpo omitido
* @param fechar true para avisar o cliente de que a conexão será encerrada
*/
public void escrever(Resposta resposta, OutputStream saida, boolean semCorpo, boolean fechar) throws IOException {
StringBuilder cabeca = new StringBuilder(256);
cabeca.append("HTTP/1.1 ").append(resposta.status()).append(' ');
linha(cabeca, resposta.razao());
Cabecalhos cabecalhos = resposta.cabecalhos();
for (String nome : cabecalhos.nomes()) {
if (nome.equalsIgnoreCase("Content-Length") || nome.equalsIgnoreCase("Connection")
|| nome.equalsIgnoreCase("Date") || nome.equalsIgnoreCase("Server")) continue; // o escritor controla estes
for (String valor : cabecalhos.todos(nome)) {
cabeca.append(nome).append(": ");
linha(cabeca, valor);
}
}
cabeca.append("Content-Length: ").append(resposta.corpo().tamanho()).append("\r\n");
cabeca.append("Date: ").append(DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(relogio))).append("\r\n");
cabeca.append("Server: Farol\r\n");
if (fechar) cabeca.append("Connection: close\r\n");
cabeca.append("\r\n");
saida.write(cabeca.toString().getBytes(StandardCharsets.ISO_8859_1));
if (!semCorpo) resposta.corpo().escreverEm(saida);
saida.flush();
}
/** Acrescenta um texto seguido de CRLF, recusando quebras de linha dentro dele. */
private static void linha(StringBuilder destino, String texto) {
for (int i = 0; i < texto.length(); i++) {
char c = texto.charAt(i);
if (c == '\r' || c == '\n')
// Sem esta verificação, um valor vindo do usuário poderia injetar cabeçalhos
throw new IllegalArgumentException("quebra de linha em cabeçalho: " + texto.replace("\r", "\\r").replace("\n", "\\n"));
if (c > 0xFF)
throw new IllegalArgumentException("caractere fora do ISO-8859-1 em cabeçalho: " + texto);
}
destino.append(texto).append("\r\n");
}
}
O escritor controla quatro cabeçalhos, que a aplicação não pode definir. O Content-Length é calculado a partir dos bytes do corpo, e nunca informado à mão, o que elimina o defeito do exercício 2 da aula anterior. O Date, que a RFC 9110 exige de servidores com relógio, usa o formato RFC 1123 do java.time e um Clock injetado, como em "Dublês e Disciplina", para que os testes possam fixar a hora. O Server identifica o Farol. E o Connection: close depende da decisão de fechar a conexão.
O método linha fecha uma brecha antiga chamada divisão de resposta (HTTP response splitting). Imagine uma rota que redireciona para um endereço vindo da própria requisição, colocando-o no cabeçalho Location. Se o endereço contiver \r\n, o atacante consegue terminar o cabeçalho e escrever outros, como Set-Cookie, na resposta que o navegador de outra pessoa vai receber. Recusar quebras de linha em qualquer valor de cabeçalho elimina o problema na raiz, num único lugar, em vez de depender de cada rota lembrar de validar.
O servidor que não cai
Com leitor e escritor, o núcleo desta aula é um laço que aceita uma conexão por vez e trata cada uma dentro de uma rede de proteção:
package br.com.exemplo.farol.nucleo;
import br.com.exemplo.farol.http.*;
import br.com.exemplo.farol.protocolo.*;
import java.io.*;
import java.net.*;
import java.util.Objects;
import java.util.Optional;
/**
* Primeira versão do núcleo do Farol: uma conexão por vez, uma requisição por conexão,
* mas sem que nenhum cliente consiga derrubar o servidor.
*/
public final class Servidor implements AutoCloseable {
private final ServerSocket soqueteDoServidor;
private final Limites limites;
private final Manipulador manipulador;
private final EscritorDeResposta escritor = new EscritorDeResposta();
public Servidor(int porta, Limites limites, Manipulador manipulador) throws IOException {
this.limites = Objects.requireNonNull(limites);
this.manipulador = Objects.requireNonNull(manipulador);
this.soqueteDoServidor = new ServerSocket(porta); // porta 0: o sistema escolhe uma livre
}
public int porta() {
return soqueteDoServidor.getLocalPort();
}
/** Aceita e atende conexões até o servidor ser fechado. */
public void atenderParaSempre() {
Registro.info("Farol ouvindo em http://localhost:" + porta());
while (!soqueteDoServidor.isClosed()) {
try {
Socket conexao = soqueteDoServidor.accept();
atender(conexao);
} catch (IOException e) {
if (!soqueteDoServidor.isClosed()) Registro.erro("falha ao aceitar conexão", e);
}
}
Registro.info("Farol desligado");
}
private void atender(Socket soquete) {
try (soquete) {
soquete.setSoTimeout((int) limites.prazoDeLeitura().toMillis());
var leitor = new LeitorDeRequisicao(soquete.getInputStream(), limites);
var saida = new BufferedOutputStream(soquete.getOutputStream());
Optional<Requisicao> lida;
try {
lida = leitor.ler();
} catch (ErroDeProtocolo e) {
Registro.info("recusada: " + e.getMessage());
responder(Resposta.texto(e.status(), e.razao(), e.getMessage() + "\n"), saida, false);
return;
} catch (SocketTimeoutException e) {
Registro.info("cliente calado por " + limites.prazoDeLeitura().toMillis() + " ms");
responder(Resposta.texto(408, "Request Timeout", "tempo esgotado\n"), saida, false);
return;
}
if (lida.isEmpty()) return; // o cliente desistiu sem pedir nada
Requisicao requisicao = lida.get();
Resposta resposta = executar(requisicao);
responder(resposta, saida, requisicao.metodo().equals("HEAD"));
Registro.info(requisicao.metodo() + " " + requisicao.alvo() + " → " + resposta.status());
} catch (IOException e) {
Registro.info("conexão perdida: " + e.getMessage()); // o cliente foi embora; o servidor segue
} catch (RuntimeException e) {
Registro.erro("falha inesperada numa conexão", e); // um defeito do Farol, não do cliente
}
}
private Resposta executar(Requisicao requisicao) {
try {
return manipulador.atender(requisicao);
} catch (Exception e) {
Registro.erro("o manipulador falhou em " + requisicao.metodo() + " " + requisicao.alvo(), e);
return Resposta.texto(500, "Internal Server Error", "erro interno\n"); // sem detalhes para o cliente
}
}
private void responder(Resposta resposta, OutputStream saida, boolean semCorpo) throws IOException {
escritor.escrever(resposta, saida, semCorpo, true); // nesta versão, toda conexão é fechada
}
@Override
public void close() throws IOException {
soqueteDoServidor.close(); // o accept() bloqueado lança SocketException e o laço termina
}
}
Cada uma das três formas de derrubar o rascunho tem agora uma resposta. O cliente calado esbarra no setSoTimeout: depois do prazo, a leitura lança SocketTimeoutException, o cliente recebe 408 e a conexão é fechada. O cliente que desiste produz um Optional vazio, e o servidor simplesmente segue para a próxima conexão. E qualquer falha de entrada e saída fica presa no catch (IOException e) de atender, sem subir até o laço principal. O catch (RuntimeException e) é a última rede: se houver um defeito no próprio Farol, ele é registrado, a conexão é fechada, e o servidor continua.
As falhas do manipulador têm tratamento separado, em executar. O catch (Exception e) amplo, que "Quando o Contrato se Quebra" desaconselha na maior parte do código, é aqui exatamente o certo: o servidor é a fronteira entre o código da aplicação e a rede, e nenhum erro da aplicação pode escapar dessa fronteira. O cliente recebe um 500 genérico, e os detalhes vão só para o registro do servidor.
O close() mostra uma técnica comum com sockets: para interromper um accept() bloqueado, fecha-se o ServerSocket, e o accept() lança SocketException. O laço percebe que o soquete está fechado e termina sem registrar erro.
A classe Registro é um registro mínimo, com hora e nome da thread, que será útil quando houver milhares delas:
package br.com.exemplo.farol.nucleo;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/** Um registro mínimo: hora, thread e mensagem, numa linha. */
final class Registro {
private static final DateTimeFormatter HORA = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
private Registro() {}
static void info(String mensagem) {
System.out.println(HORA.format(LocalTime.now()) + " [" + nomeDaThread() + "] " + mensagem);
}
static void erro(String mensagem, Throwable causa) {
System.out.println(HORA.format(LocalTime.now()) + " [" + nomeDaThread() + "] ERRO " + mensagem + ": " + causa);
}
private static String nomeDaThread() {
Thread t = Thread.currentThread();
return t.getName().isEmpty() ? (t.isVirtual() ? "virtual-" : "thread-") + t.threadId() : t.getName();
}
}
Pondo para funcionar
Por enquanto, a aplicação tem um único manipulador, que descreve o que recebeu:
package br.com.exemplo.farol.app;
import br.com.exemplo.farol.http.*;
import br.com.exemplo.farol.nucleo.Servidor;
import br.com.exemplo.farol.protocolo.Limites;
public class Principal {
public static void main(String[] args) throws Exception {
int porta = args.length > 0 ? Integer.parseInt(args[0]) : 8080;
// Por enquanto, um único manipulador que descreve o que recebeu
Manipulador eco = req -> Resposta.texto(200, "OK",
"Farol recebeu " + req.metodo() + " " + req.alvo() + " (" + req.versao() + ")\n"
+ "cabeçalhos: " + req.cabecalhos().nomes() + "\n"
+ "corpo: " + req.corpo().tamanho() + " bytes: " + req.corpo().comoTexto() + "\n");
try (Servidor farol = new Servidor(porta, Limites.PADRAO, eco)) {
farol.atenderParaSempre();
}
}
}
Compilado com mvn package e executado como módulo:
$ java -p target/farol-0.1.0.jar -m br.com.exemplo.farol/br.com.exemplo.farol.app.Principal
14:59:40.146 [main] Farol ouvindo em http://localhost:8080
Três requisições válidas, com o curl:
$ curl -i "http://localhost:8080/tarefas?lista=casa"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 106
Date: Tue, 29 Sep 2026 17:59:41 GMT
Server: Farol
Connection: close
Farol recebeu GET /tarefas?lista=casa (HTTP/1.1)
cabeçalhos: [Accept, Host, User-Agent]
corpo: 0 bytes:
$ curl -X POST -H "Content-Type: text/plain" -d "Comprar pão" http://localhost:8080/tarefas
Farol recebeu POST /tarefas (HTTP/1.1)
cabeçalhos: [Accept, Content-Length, Content-Type, Host, User-Agent]
corpo: 12 bytes: Comprar pão
$ curl -I http://localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 89
Date: Tue, 29 Sep 2026 17:59:41 GMT
Server: Farol
Connection: close
O corpo "Comprar pão" chegou com seus 12 bytes intactos, e o HEAD recebeu os mesmos cabeçalhos de um GET, com o Content-Length de 89, mas sem o corpo, como a RFC 9110 pede.
Para as requisições inválidas, o curl é educado demais: ele sempre monta requisições corretas. O próprio bash consegue abrir uma conexão TCP pelo caminho especial /dev/tcp, e isso permite enviar bytes arbitrários:
enviar() { exec 3<>/dev/tcp/127.0.0.1/8080; printf "$1" >&3; timeout 3 cat <&3; exec 3<&-; }
enviar 'GET /tarefas\r\nHost: x\r\n\r\n'
enviar 'GET / HTTP/1.1\r\n\r\n'
enviar 'GET / HTTP/2.0\r\nHost: x\r\n\r\n'
enviar 'POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5000000000\r\n\r\n'
enviar 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n'
enviar 'GET / HTTP/1.1\r\nHost : x\r\n\r\n'
Cada uma recebe a resposta certa, e o registro do servidor conta a história:
14:59:41.276 [main] recusada: 400 Bad Request: linha de pedido malformada: 'GET /tarefas'
14:59:41.283 [main] recusada: 400 Bad Request: HTTP/1.1 exige exatamente um cabeçalho Host, veio 0
14:59:41.286 [main] recusada: 505 HTTP Version Not Supported: HTTP/2.0
14:59:41.297 [main] recusada: 413 Content Too Large: 5000000000 bytes; o máximo é 1048576
14:59:41.299 [main] recusada: 411 Length Required: envie o corpo com Content-Length
14:59:41.302 [main] recusada: 400 Bad Request: nome de cabeçalho inválido: 'Host '
O corpo de cinco gigabytes foi recusado só pelo cabeçalho, sem que um único byte dele fosse lido.
As três formas de derrubar, outra vez
O teste decisivo é repetir os ataques da aula anterior. Um cliente que conecta e fica calado, lendo o que o servidor mandar, e, enquanto ele espera, um curl normal:
$ curl -s -o /dev/null -w "curl: %{http_code} em %{time_total}s\n" http://localhost:8080/fila
curl: 200 em 9.379886s
cliente lento recebeu, após 10020 ms: HTTP/1.1 408 Request Timeout
15:00:20.451 [main] cliente calado por 10000 ms
15:00:20.453 [main] GET /fila → 200
O cliente calado recebeu seu 408 depois de dez segundos, o curl foi atendido logo em seguida, e o servidor continuou de pé. Um cliente que conecta e desiste, e outro que envia o pedido e fecha a conexão antes da resposta, também não causam nada além de uma linha no registro, e a requisição seguinte é atendida normalmente.
Mas a saída mostra, com honestidade, o que ainda falta: o curl esperou 9,4 segundos por uma resposta que leva milissegundos, porque o Farol atende uma conexão por vez. O prazo limitou o estrago de um cliente calado, mas não o eliminou. Com mil clientes calados, o servidor ficaria parado por quase três horas. É o problema de "A Terceira Pedra".
Os testes
Cada regra do leitor vira um teste, e os testes não precisam de rede nenhuma: um ByteArrayInputStream com os bytes da requisição faz o papel do socket. Os limites do teste são minúsculos, para que os casos de excesso sejam fáceis de escrever:
package br.com.exemplo.farol.protocolo;
import br.com.exemplo.farol.http.Requisicao;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LeitorDeRequisicaoTest {
// Limites pequenos, para que os testes de limite sejam fáceis de escrever
private static final Limites LIMITES = new Limites(64, 128, 5, 16, java.time.Duration.ofSeconds(1));
private static Optional<Requisicao> ler(String bytes) throws Exception {
var entrada = new ByteArrayInputStream(bytes.getBytes(StandardCharsets.UTF_8));
return new LeitorDeRequisicao(entrada, LIMITES).ler();
}
private static int statusDoErro(String bytes) {
return assertThrows(ErroDeProtocolo.class, () -> ler(bytes)).status();
}
@Nested
class RequisicoesValidas {
@Test
void leLinhaDePedidoECabecalhos() throws Exception {
Requisicao r = ler("GET /tarefas?lista=casa HTTP/1.1\r\nHost: localhost\r\nAccept: */*\r\n\r\n").orElseThrow();
assertThat(r.metodo()).isEqualTo("GET");
assertThat(r.alvo()).isEqualTo("/tarefas?lista=casa");
assertThat(r.versao()).isEqualTo("HTTP/1.1");
assertThat(r.cabecalhos().valor("accept")).hasValue("*/*"); // nome sem diferenciar caixa
assertThat(r.corpo().vazio()).isTrue();
}
@Test
void leCorpoPeloContentLengthEmBytes() throws Exception {
// "pão" tem 3 caracteres e 4 bytes em UTF-8
Requisicao r = ler("POST /t HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\n\r\npão").orElseThrow();
assertThat(r.corpo().tamanho()).isEqualTo(4);
assertThat(r.corpo().comoTexto()).isEqualTo("pão");
}
@Test
void naoLeAlemDoCorpo() throws Exception {
// Duas requisições seguidas na mesma conexão: cada leitura pega só a sua
var entrada = new ByteArrayInputStream(("POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 2\r\n\r\nok"
+ "GET /b HTTP/1.1\r\nHost: x\r\n\r\n").getBytes(StandardCharsets.ISO_8859_1));
var leitor = new LeitorDeRequisicao(entrada, LIMITES);
assertThat(leitor.ler().orElseThrow().corpo().comoTexto()).isEqualTo("ok");
assertThat(leitor.ler().orElseThrow().alvo()).isEqualTo("/b");
assertThat(leitor.ler()).isEmpty();
}
@Test
void conexaoFechadaSemNadaEhFimLimpo() throws Exception {
assertThat(ler("")).isEmpty();
}
}
@Nested
class RequisicoesRecusadas {
@Test
void linhaDePedidoSemVersao() {
assertThat(statusDoErro("GET /tarefas\r\nHost: x\r\n\r\n")).isEqualTo(400);
}
@Test
void alvoLongoDemais() {
assertThat(statusDoErro("GET /" + "a".repeat(100) + " HTTP/1.1\r\nHost: x\r\n\r\n")).isEqualTo(414);
}
@Test
void cabecalhosGrandesDemais() {
assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\nX-Grande: " + "b".repeat(200) + "\r\n\r\n")).isEqualTo(431);
}
@Test
void corpoGrandeDemais() {
assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 17\r\n\r\n")).isEqualTo(413);
}
@Test
void transferEncodingEContentLengthJuntos() {
assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nContent-Length: 3\r\n\r\n")).isEqualTo(400);
}
@Test
void corpoIncompleto() {
assertThat(statusDoErro("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nabc")).isEqualTo(400);
}
}
}
O arquivo completo tem 21 testes, no mesmo formato: além dos acima, a tolerância a LF sozinho e a linhas vazias antes do pedido, o HTTP/1.0 sem Host, espaços duplos na linha de pedido, versão 2.0, HTTP/1.1 sem Host, espaço antes dos dois-pontos, cabeçalhos demais, Content-Length com valores diferentes ou negativo, chunked e conexão fechada no meio dos cabeçalhos. O teste naoLeAlemDoCorpo é o mais importante de todos: ele prova que o leitor consegue ler duas requisições seguidas do mesmo fluxo, o que o BufferedReader do rascunho não conseguia, e é a base das conexões persistentes.
O servidor também tem testes, que sobem um Farol de verdade na porta 0, onde o sistema operacional escolhe uma porta livre, com um prazo de leitura curto:
package br.com.exemplo.farol.nucleo;
import br.com.exemplo.farol.http.Resposta;
import br.com.exemplo.farol.protocolo.Limites;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class ServidorTest {
private Servidor servidor;
private Thread threadDoServidor;
@BeforeEach
void subir() throws IOException {
Limites limites = Limites.PADRAO.comPrazoDeLeitura(Duration.ofMillis(300));
servidor = new Servidor(0, limites, req -> {
if (req.alvo().equals("/quebra")) throw new IllegalStateException("defeito no manipulador");
return Resposta.texto(200, "OK", "oi");
});
threadDoServidor = Thread.ofPlatform().name("farol-teste").start(servidor::atenderParaSempre);
}
@AfterEach
void descer() throws Exception {
servidor.close();
threadDoServidor.join(2000);
}
/** Envia bytes crus e devolve tudo o que o servidor responder até fechar a conexão. */
private String trocar(String pedido) throws IOException {
try (Socket s = new Socket("localhost", servidor.porta())) {
s.getOutputStream().write(pedido.getBytes(StandardCharsets.ISO_8859_1));
return new String(s.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
}
}
@Test
void clienteCaladoRecebe408EOServidorSegue() throws IOException {
try (Socket calado = new Socket("localhost", servidor.porta())) {
String resposta = new String(calado.getInputStream().readAllBytes(), StandardCharsets.ISO_8859_1);
assertThat(resposta).startsWith("HTTP/1.1 408 Request Timeout");
}
assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
}
@Test
void clienteQueDesisteNaoDerrubaOServidor() throws IOException {
new Socket("localhost", servidor.porta()).close(); // conecta e vai embora
try (Socket apressado = new Socket("localhost", servidor.porta())) { // pede e vai embora
apressado.getOutputStream().write("GET / HTTP/1.1\r\nHost: x\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1));
}
assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
}
@Test
void falhaNoManipuladorViraErro500SemDetalhes() throws IOException {
String resposta = trocar("GET /quebra HTTP/1.1\r\nHost: x\r\n\r\n");
assertThat(resposta).startsWith("HTTP/1.1 500 Internal Server Error");
assertThat(resposta).doesNotContain("defeito no manipulador");
assertThat(trocar("GET / HTTP/1.1\r\nHost: x\r\n\r\n")).startsWith("HTTP/1.1 200");
}
}
Com mais dois testes, um para uma requisição simples e outro para o HEAD, o total é:
Tests run: 26, Failures: 0, Skipped: 0
As três formas de derrubar o rascunho viraram testes, e qualquer mudança futura que as reintroduza vai falhar no mvn test antes de chegar a um servidor.
O Farol agora lê o protocolo corretamente, mas os tipos que ele entrega ainda são "stringly typed": o método é uma String comparada com equals("HEAD"), o código de estado e a frase da resposta são dois valores soltos que podem discordar, e o alvo é um texto cru, com a consulta e os escapes %XX ainda por decodificar. E o servidor só tem um manipulador para tudo.
Fontes e leituras recomendadas
- RFC 9112, "HTTP/1.1", seções 2 a 6: o formato da mensagem, a linha de pedido, os campos de cabeçalho, as regras de tolerância (como o LF sozinho e as linhas vazias iniciais) e a determinação do tamanho do corpo, incluindo a regra contra
Transfer-EncodingcomContent-Length. https://www.rfc-editor.org/rfc/rfc9112 - RFC 9110, "HTTP Semantics", seção 15: os códigos de estado usados nesta aula, com o significado exato de cada um. https://www.rfc-editor.org/rfc/rfc9110#section-15
- PortSwigger Web Security Academy, "HTTP request smuggling": a explicação mais clara e prática do contrabando de requisições, com diagramas e laboratórios. https://portswigger.net/web-security/request-smuggling
- OWASP, "HTTP Response Splitting": a descrição do ataque de divisão de resposta e das defesas. https://owasp.org/www-community/attacks/HTTP_Response_Splitting
- Documentação da classe
BufferedInputStream(Java SE 25): o funcionamento do buffer,readNBytes,markereset, que a próxima aula e "A Terceira Pedra" vão usar. https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/io/BufferedInputStream.html
Exercícios
Exercício 1
O EntradaHttp lê a linha byte a byte de um BufferedInputStream. Explique por que ler byte a byte diretamente do InputStream do socket seria muito mais lento, e por que o leitor não pode criar um BufferedInputStream novo a cada chamada de lerLinha.
Ver resposta
✓ Resposta: O InputStream de um socket não tem buffer próprio no lado Java: cada read() sem buffer vira uma chamada ao sistema operacional, que atravessa a fronteira entre o programa e o núcleo do sistema. Para uma requisição de 500 bytes, seriam 500 chamadas ao sistema, cada uma custando da ordem de microssegundos, contra uma ou duas chamadas que o BufferedInputStream faz para encher o seu buffer de 8 KB; os demais read() são uma leitura de array. Criar um buffer novo a cada chamada seria pior que lento, seria errado: o primeiro BufferedInputStream puxaria do socket um bloco inteiro, com a linha de pedido, os cabeçalhos e o começo do corpo, entregaria só a primeira linha e seria descartado com o resto dentro dele. A chamada seguinte criaria outro buffer e leria do socket a partir de onde o anterior parou, perdendo os bytes que ficaram no buffer abandonado. É o mesmo defeito do BufferedReader do rascunho. Por isso o EntradaHttp cria o buffer uma vez, no construtor, e todas as leituras, de linhas e do corpo, passam por ele.
Exercício 2
A ErroDeProtocolo é uma exceção verificada, enquanto o construtor de Limites lança IllegalArgumentException, que não é verificada. Justifique cada escolha com o critério de "Quando o Contrato se Quebra".
Ver resposta
✓ Resposta: O critério é se a situação é uma condição esperada, da qual quem chama pode e deve se recuperar, ou um erro de programação. Uma requisição inválida vinda da rede é esperada: acontece com clientes defeituosos, ataques e conexões ruins, e o servidor precisa reagir, respondendo com o código certo e seguindo em frente. Tornar a exceção verificada obriga todo código que chama LeitorDeRequisicao.ler() a tratá-la, e o compilador impede que alguém esqueça, o que é exatamente o que se quer numa fronteira com o mundo externo. Já Limites com um prazo negativo ou um tamanho de linha de 3 bytes é um erro de quem escreveu a configuração, um defeito no código ou no arquivo de configuração. Não há recuperação sensata em tempo de execução: o certo é falhar imediatamente na inicialização, com uma mensagem clara, como "Contratos à Vista" recomendou. Uma exceção verificada ali obrigaria todo código que cria um Limites a escrever um try que nunca teria nada de útil a fazer no catch.
Exercício 3
Escreva dois testes para o LeitorDeRequisicaoTest que verifiquem que um cabeçalho sem dois-pontos e um cabeçalho com nome vazio (: valor) são recusados com 400.
Ver resposta
✓ Resposta:
@Test
void cabecalhoSemDoisPontos() {
assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\nSou um cabeçalho\r\n\r\n")).isEqualTo(400);
}
@Test
void cabecalhoComNomeVazio() {
assertThat(statusDoErro("GET / HTTP/1.1\r\nHost: x\r\n: sem nome\r\n\r\n")).isEqualTo(400);
}
Os dois passam, e os dois exercitam a mesma linha do leitor: if (doisPontos <= 0). No primeiro caso, indexOf(':') devolve -1, porque não há dois-pontos; no segundo, devolve 0, porque o dois-pontos é o primeiro caractere e o nome ficaria vazio. A condição <= 0 cobre os dois de uma vez, e os testes documentam que isso é intencional. Sem o segundo teste, alguém poderia "simplificar" a condição para < 0 no futuro, e um cabeçalho de nome vazio passaria a ser aceito, sem que nenhum teste percebesse. Um teste para cada caso de fronteira, mesmo que exercitem a mesma linha, é o que protege a regra.
Exercício 4
O lerLinha recebe um Supplier<ErroDeProtocolo> em vez de um ErroDeProtocolo pronto. Estime o custo da alternativa ansiosa num servidor que atende 20 mil requisições por segundo, cada uma com uma linha de pedido e dez cabeçalhos.
Ver resposta
✓ Resposta: Com a versão ansiosa, cada chamada de lerLinha criaria uma exceção, mesmo que ela nunca fosse lançada: onze por requisição, 220 mil por segundo. Criar uma exceção é caro porque o construtor de Throwable chama fillInStackTrace, que percorre a pilha de chamadas da thread e registra cada quadro. Uma medição simples, criando um milhão de exceções numa máquina de dois núcleos, levou cerca de 900 ms, algo como 0,9 µs por exceção, enquanto a versão com Supplier, que só cria a exceção quando necessário, levou menos de 10 ms para o mesmo milhão de chamadas. Com 220 mil exceções por segundo, a versão ansiosa gastaria cerca de 200 ms de CPU por segundo só criando objetos que nunca seriam usados, algo como 20% de um núcleo, além de produzir lixo para o coletor. A lambda () -> ErroDeProtocolo.alvoLongoDemais(...) custa quase nada para ser criada, e o JIT frequentemente elimina até essa alocação. É o mesmo princípio do orElseThrow(Supplier) do Optional e das mensagens de log preguiçosas: não pague pelo caso raro no caminho comum.
Exercício 5
Explique, com um exemplo de bytes, como um atacante poderia explorar uma requisição com Content-Length: 4 e Transfer-Encoding: chunked se um proxy na frente do Farol usasse um dos cabeçalhos e o Farol usasse o outro.
Ver resposta
✓ Resposta: Suponha um proxy que protege o Farol e só deixa passar requisições para /publico, e que use o Transfer-Encoding, enquanto o servidor de trás usa o Content-Length. O atacante envia:
POST /publico HTTP/1.1\r\n
Host: x\r\n
Content-Length: 4\r\n
Transfer-Encoding: chunked\r\n
\r\n
5c\r\n
GET /admin HTTP/1.1\r\n
Host: x\r\n
...\r\n
0\r\n
\r\n
O proxy lê o corpo no formato em partes: uma parte de 0x5c bytes, que contém o texto GET /admin ..., e a parte final de tamanho 0. Para ele, é uma única requisição a /publico, permitida, e ele a repassa inteira. O servidor de trás usa o Content-Length: 4 e lê como corpo só os quatro bytes 5c\r\n. O resto, GET /admin HTTP/1.1..., fica na conexão e é interpretado como a próxima requisição, uma requisição a /admin que o proxy nunca viu e, portanto, nunca bloqueou. Pior: como o proxy costuma reaproveitar a mesma conexão com o servidor para vários clientes, a resposta de /admin pode ser entregue a outro usuário. O Farol se protege recusando com 400 qualquer requisição com os dois cabeçalhos, uma das duas reações que a RFC 9112 permite (a outra seria usar só o Transfer-Encoding), e recusando Content-Length ambíguo. Com isso, não existe uma interpretação dos bytes diferente da que o proxy fez que o Farol aceite.