Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ replay_pid*
build/
.DS_Store
examples/readme/readme.iml
/.serena
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package net.uiqui.embedhttp.server.io;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ProtocolException;
import java.nio.charset.StandardCharsets;

/**
* Byte-oriented reader over a connection's input stream.
* <p>
* A single instance is created per connection and reused across keep-alive requests, so bytes read
* ahead while parsing one request remain buffered for the next. This is what makes pipelining work
* and prevents the buffered-read-ahead loss of the previous per-request reader approach.
* </p>
* <p>
* Lines (request line and headers) are decoded as ISO-8859-1 so that one byte maps to one char,
* keeping size limits byte-accurate. Bodies are returned as raw bytes for the caller to decode.
* </p>
*/
public class HttpConnectionReader {
private static final int CR = '\r';
private static final int LF = '\n';
private static final int MAX_LINE_LENGTH = 8192; // 8KB — request line and header lines

private final InputStream inputStream;

public HttpConnectionReader(InputStream inputStream) {
this.inputStream = new BufferedInputStream(inputStream);
}

/**
* Reads a single line terminated by LF (an optional preceding CR is stripped) and returns it
* without the terminator. Bytes after the terminator stay buffered for the next read.
*
* @return the line, or null if the end of the stream is reached before any byte is read.
*/
public String readLine() throws IOException {
var buffer = new ByteArrayOutputStream();
int read;

while ((read = inputStream.read()) != -1) {
if (read == LF) {
return toLine(buffer);
}

buffer.write(read);

// Bound per-line memory; allow one extra byte for the trailing CR of a max-length line.
if (buffer.size() > MAX_LINE_LENGTH + 1) {
throw new ProtocolException("Line exceeds maximum length of " + MAX_LINE_LENGTH + " bytes");
}
}

if (buffer.size() == 0) {
return null;
}

return buffer.toString(StandardCharsets.ISO_8859_1);
}

/**
* Reads exactly {@code length} bytes from the stream.
*
* @param length the number of bytes to read.
* @return a byte array of exactly {@code length} bytes.
* @throws ProtocolException if the stream ends before {@code length} bytes are read.
*/
public byte[] readBody(int length) throws IOException {
var bytes = new byte[length];
int read = 0;

while (read < length) {
var count = inputStream.read(bytes, read, length - read);
if (count == -1) {
throw new ProtocolException("Unexpected end of stream while reading body");
}

read += count;
}

return bytes;
}

private static String toLine(ByteArrayOutputStream buffer) throws ProtocolException {
var bytes = buffer.toByteArray();
var length = bytes.length;

// HTTP framing requires CRLF: the LF just consumed must be preceded by CR.
if (length == 0 || bytes[length - 1] != CR) {
throw new ProtocolException("Invalid line terminator: expected CRLF");
}

return new String(bytes, 0, length - 1, StandardCharsets.ISO_8859_1);
}
}
4 changes: 3 additions & 1 deletion src/main/java/net/uiqui/embedhttp/server/io/IOServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,12 @@ private void handleRequest(Socket clientSocket, Counter counter, RequestProcesso
try (clientSocket) {
counter.addOne();
clientSocket.setSoTimeout(SO_TIMEOUT);
var reader = new HttpConnectionReader(clientSocket.getInputStream());
var outputStream = clientSocket.getOutputStream();
var keepAlive = true;

while (keepAlive && stateMachine.getCurrentState() == ServerState.RUNNING) {
keepAlive = requestProcessor.process(clientSocket);
keepAlive = requestProcessor.process(reader, outputStream);
}

logger.log(DEBUG, () -> serverLogMessage("Client(%s:%d): Connection closed", clientAddress, clientPort));
Expand Down
130 changes: 86 additions & 44 deletions src/main/java/net/uiqui/embedhttp/server/io/RequestParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
import net.uiqui.embedhttp.server.InsensitiveMap;
import net.uiqui.embedhttp.server.Request;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ProtocolException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
Expand All @@ -22,19 +21,29 @@ public class RequestParser {
private static final int MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
private static final int MAX_CHUNK_SIZE = 1024 * 1024; // 1MB
private static final int MAX_HEADER_COUNT = 100;
private static final int MAX_HEADER_SIZE = 8192; // 8KB

public Request parseRequest(InputStream inputStream) throws IOException {
var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
return parseRequest(new HttpConnectionReader(inputStream));
}

public Request parseRequest(HttpConnectionReader reader) throws IOException {
var requestLine = decodeRequestLine(reader);
var headers = decodeRequestHeaders(reader);
validateHost(requestLine.version(), headers);
var body = decodeRequestBody(reader, headers);
var keepAlive = decodeKeepAlive(headers);
var keepAlive = decodeKeepAlive(requestLine.version(), headers);

return new Request(requestLine.method(), requestLine.url(), headers, body, keepAlive);
}

private RequestLine decodeRequestLine(BufferedReader reader) throws IOException {
private void validateHost(HttpVersion version, InsensitiveMap headers) throws ProtocolException {
// RFC 9112 §3.2: an HTTP/1.1 request MUST carry a Host header (duplicates are rejected during header parsing).
if (version == HttpVersion.VERSION_1_1 && !headers.containsKey(HttpHeader.HOST.getValue())) {
throw new ProtocolException("Missing Host header");
}
}

private RequestLine decodeRequestLine(HttpConnectionReader reader) throws IOException {
var line = readRequestLine(reader);

var parts = line.split(" ", 3);
Expand All @@ -57,7 +66,7 @@ private RequestLine decodeRequestLine(BufferedReader reader) throws IOException
return new RequestLine(method, url, version);
}

private static String readRequestLine(BufferedReader reader) throws IOException {
private static String readRequestLine(HttpConnectionReader reader) throws IOException {
try {
var line = reader.readLine();
if (line == null) {
Expand All @@ -74,16 +83,12 @@ private static String readRequestLine(BufferedReader reader) throws IOException
}
}

private InsensitiveMap decodeRequestHeaders(BufferedReader reader) throws IOException {
private InsensitiveMap decodeRequestHeaders(HttpConnectionReader reader) throws IOException {
var headers = new InsensitiveMap();
String line;
int headerCount = 0;

while ((line = reader.readLine()) != null && !line.isEmpty()) {
if (line.length() > MAX_HEADER_SIZE) {
throw new ProtocolException("Header too large: maximum " + MAX_HEADER_SIZE + " bytes allowed");
}

var colonIndex = line.indexOf(':');
if (colonIndex == -1) {
throw new ProtocolException("Invalid header line: " + line);
Expand All @@ -96,20 +101,34 @@ private InsensitiveMap decodeRequestHeaders(BufferedReader reader) throws IOExce

var headerName = line.substring(0, colonIndex).trim();
var headerValue = line.substring(colonIndex + 1).trim();

// RFC 9112 §3.2: more than one Host header is a request-smuggling signal and must be rejected.
if (HttpHeader.HOST.getValue().equalsIgnoreCase(headerName) && headers.containsKey(headerName)) {
throw new ProtocolException("Duplicate Host header");
}

headers.put(headerName, headerValue);
}

return headers;
}

private String decodeRequestBody(BufferedReader reader, Map<String, String> headers) throws IOException {
if (headers.containsKey(HttpHeader.CONTENT_LENGTH.getValue())) {
var contentLength = Integer.parseInt(headers.get(HttpHeader.CONTENT_LENGTH.getValue()));
private String decodeRequestBody(HttpConnectionReader reader, Map<String, String> headers) throws IOException {
var hasContentLength = headers.containsKey(HttpHeader.CONTENT_LENGTH.getValue());
var hasTransferEncoding = headers.containsKey(HttpHeader.TRANSFER_ENCODING.getValue());

// RFC 9112 §6.1: a message with both framing headers is a request-smuggling vector.
if (hasContentLength && hasTransferEncoding) {
throw new ProtocolException("Content-Length and Transfer-Encoding must not both be present");
}

if (hasContentLength) {
var contentLength = parseContentLength(headers.get(HttpHeader.CONTENT_LENGTH.getValue()));
if (contentLength > MAX_BODY_SIZE) {
throw new ProtocolException("Request body too large: " + contentLength);
}

return readFixedSizeBodyChunk(reader, contentLength);
return new String(reader.readBody(contentLength), StandardCharsets.UTF_8);
}

if (TRANSFER_ENCODING_CHUNKED.equalsIgnoreCase(headers.get(HttpHeader.TRANSFER_ENCODING.getValue()))) {
Expand All @@ -119,21 +138,38 @@ private String decodeRequestBody(BufferedReader reader, Map<String, String> head
return ""; // No body or unsupported format
}

private boolean decodeKeepAlive(InsensitiveMap headers) {
var connectionHeader = headers.get(HttpHeader.CONNECTION.getValue());
if (connectionHeader == null) {
return true; // Default to keep-alive if no connection header is present
private int parseContentLength(String value) throws ProtocolException {
int contentLength;
try {
contentLength = Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
throw new ProtocolException("Invalid Content-Length: " + value);
}

if (contentLength < 0) {
throw new ProtocolException("Invalid Content-Length: " + value);
}

return contentLength;
}

private boolean decodeKeepAlive(HttpVersion version, InsensitiveMap headers) {
var connectionHeader = headers.get(HttpHeader.CONNECTION.getValue());

if (KEEP_ALIVE.getValue().equalsIgnoreCase(connectionHeader)) {
return true;
}

return !CLOSE.getValue().equalsIgnoreCase(connectionHeader);
if (CLOSE.getValue().equalsIgnoreCase(connectionHeader)) {
return false;
}

// No explicit directive: HTTP/1.1 defaults to keep-alive, HTTP/1.0 to close.
return version == HttpVersion.VERSION_1_1;
}

private String readChunkedBody(BufferedReader reader) throws IOException {
var body = new StringBuilder();
private String readChunkedBody(HttpConnectionReader reader) throws IOException {
var body = new ByteArrayOutputStream();

while (true) {
int chunkSize = readChunkSize(reader);
Expand All @@ -142,44 +178,50 @@ private String readChunkedBody(BufferedReader reader) throws IOException {
break;
}

body.append(readFixedSizeBodyChunk(reader, chunkSize));
// Cap the aggregate body size; per-chunk limits alone leave chunked transfers unbounded.
if (body.size() + chunkSize > MAX_BODY_SIZE) {
throw new ProtocolException("Request body too large: exceeds " + MAX_BODY_SIZE + " bytes");
}

body.writeBytes(reader.readBody(chunkSize));
consumeTrailingLine(reader); // Consume trailing \r\n
}

return body.toString();
return body.toString(StandardCharsets.UTF_8);
}

private int readChunkSize(BufferedReader reader) throws IOException {
private int readChunkSize(HttpConnectionReader reader) throws IOException {
var line = reader.readLine();
if (line == null) {
throw new ProtocolException("Unexpected end of stream while reading chunk size");
}

int chunkSize = Integer.parseInt(line.trim(), 16);
if (chunkSize > MAX_CHUNK_SIZE) {
throw new ProtocolException("Chunk size too large: " + chunkSize);
// A chunk-size line may carry extensions after a ';' (RFC 9112 §7.1.1); ignore them.
var sizeToken = line.trim();
var extensionIndex = sizeToken.indexOf(';');
if (extensionIndex != -1) {
sizeToken = sizeToken.substring(0, extensionIndex).trim();
}

return chunkSize;
}

private String readFixedSizeBodyChunk(BufferedReader reader, int chunkSize) throws IOException {
var chunk = new char[chunkSize];
int read = 0;
int chunkSize;
try {
chunkSize = Integer.parseInt(sizeToken, 16);
} catch (NumberFormatException e) {
throw new ProtocolException("Invalid chunk size: " + line);
}

while (read < chunkSize) {
var readCount = reader.read(chunk, read, chunkSize - read);
if (readCount == -1) {
throw new ProtocolException("Unexpected end of stream while reading body");
}
if (chunkSize < 0) {
throw new ProtocolException("Invalid chunk size: " + line);
}

read += readCount;
if (chunkSize > MAX_CHUNK_SIZE) {
throw new ProtocolException("Chunk size too large: " + chunkSize);
}

return new String(chunk);
return chunkSize;
}

private void consumeTrailingLine(BufferedReader reader) throws IOException {
private void consumeTrailingLine(HttpConnectionReader reader) throws IOException {
var line = reader.readLine();
if (line == null) {
throw new ProtocolException("Unexpected end of stream while consuming trailing line");
Expand All @@ -188,4 +230,4 @@ private void consumeTrailingLine(BufferedReader reader) throws IOException {

protected record RequestLine(HttpMethod method, String url, HttpVersion version) {
}
}
}
Loading
Loading