diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 1fcc29fb..b6a45582 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -2,6 +2,7 @@ import org.example.config.AppConfig; import org.example.filter.IpFilter; +import org.example.filter.MaxRequestBodySizeFilter; import org.example.httpparser.HttpParser; import org.example.httpparser.HttpRequest; import java.util.ArrayList; @@ -40,6 +41,10 @@ private List buildFilters() { if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { list.add(createIpFilterFromConfig(ipFilterConfig)); } + AppConfig.MaxRequestBodyConfig maxBodyConfig = config.maxRequestBody(); + if (Boolean.TRUE.equals(maxBodyConfig.enabled())) { + list.add(new MaxRequestBodySizeFilter(maxBodyConfig.maxBytes())); + } // Add more filters here... return list; } @@ -63,7 +68,7 @@ public void runConnectionHandler() throws IOException { parser.getUri(), parser.getVersion(), parser.getHeadersMap(), - "" + null ); String clientIp = client.getInetAddress().getHostAddress(); @@ -73,7 +78,8 @@ public void runConnectionHandler() throws IOException { int statusCode = response.getStatusCode(); if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || - statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + statusCode == HttpResponseBuilder.SC_BAD_REQUEST || + statusCode == HttpResponseBuilder.SC_PAYLOAD_TOO_LARGE) { byte[] responseBytes = response.build(); client.getOutputStream().write(responseBytes); client.getOutputStream().flush(); diff --git a/src/main/java/org/example/config/AppConfig.java b/src/main/java/org/example/config/AppConfig.java index db689ed5..7e345416 100644 --- a/src/main/java/org/example/config/AppConfig.java +++ b/src/main/java/org/example/config/AppConfig.java @@ -7,21 +7,27 @@ public record AppConfig( @JsonProperty("server") ServerConfig server, @JsonProperty("logging") LoggingConfig logging, - @JsonProperty("ipFilter") IpFilterConfig ipFilter + @JsonProperty("ipFilter") IpFilterConfig ipFilter, + @JsonProperty("maxRequestBody") MaxRequestBodyConfig maxRequestBody + ) { public static AppConfig defaults() { return new AppConfig( ServerConfig.defaults(), LoggingConfig.defaults(), - IpFilterConfig.defaults() + IpFilterConfig.defaults(), + MaxRequestBodyConfig.defaults() ); } public AppConfig withDefaultsApplied() { ServerConfig serverConfig = (server == null ? ServerConfig.defaults() : server.withDefaultsApplied()); LoggingConfig loggingConfig = (logging == null ? LoggingConfig.defaults() : logging.withDefaultsApplied()); - IpFilterConfig ipFilterConfig = (ipFilter == null ? IpFilterConfig.defaults() : ipFilter.withDefaultsApplied()); // ← LÄGG TILL - return new AppConfig(serverConfig, loggingConfig, ipFilterConfig); // ← UPPDATERA DENNA RAD + IpFilterConfig ipFilterConfig = (ipFilter == null ? IpFilterConfig.defaults() : ipFilter.withDefaultsApplied()); + MaxRequestBodyConfig maxRequestBodyConfig = + (maxRequestBody == null ? MaxRequestBodyConfig.defaults() : maxRequestBody.withDefaultsApplied()); + + return new AppConfig(serverConfig, loggingConfig, ipFilterConfig, maxRequestBodyConfig); } @JsonIgnoreProperties(ignoreUnknown = true) @@ -76,4 +82,25 @@ public IpFilterConfig withDefaultsApplied() { return new IpFilterConfig(e, m, blocked, allowed); } } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record MaxRequestBodyConfig( + @JsonProperty("enabled") Boolean enabled, + @JsonProperty("maxBytes") Long maxBytes + ) { + public static MaxRequestBodyConfig defaults() { + return new MaxRequestBodyConfig(false, 1_048_576L); + } + + public MaxRequestBodyConfig withDefaultsApplied() { + Boolean enabledFlag = (enabled == null) ? false : enabled; + long limit = (maxBytes == null) ? 1_048_576L : maxBytes; + + if (limit < 0) { + throw new IllegalArgumentException("maxRequestBody.maxBytes must be >= 0"); + } + + return new MaxRequestBodyConfig(enabledFlag, limit); + } + } } diff --git a/src/main/java/org/example/filter/MaxRequestBodySizeFilter.java b/src/main/java/org/example/filter/MaxRequestBodySizeFilter.java new file mode 100644 index 00000000..8a05ffcc --- /dev/null +++ b/src/main/java/org/example/filter/MaxRequestBodySizeFilter.java @@ -0,0 +1,97 @@ +package org.example.filter; + + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * A filter that rejects requests with bodies larger than a configured byte limit. + * It primarily uses the Content-Length header (when present), and can also validate + * a parsed request body if available. + */ +public class MaxRequestBodySizeFilter implements Filter { + + private final long maxBytes; + + public MaxRequestBodySizeFilter(long maxBytes) { + if (maxBytes < 0) { + throw new IllegalArgumentException("maxBytes must be >= 0"); + } + this.maxBytes = maxBytes; + } + @Override + public void init() { + + } + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + if (!mayHaveBody(request.getMethod())) { + chain.doFilter(request, response); + return; + } + + Long contentLength = getHeaderAsLong(request.getHeaders(), "Content-Length"); + + if (contentLength != null && contentLength > maxBytes) { + reject(response, contentLength); + return; + } + + // fallback: if a body has already been parsed, validate it as well + String body = request.getBody(); + if (body != null && !body.isEmpty()) { + int bodySizeInBytes = body.getBytes(StandardCharsets.UTF_8).length; + if (bodySizeInBytes > maxBytes) { + reject(response, (long) bodySizeInBytes); + return; + } + } + chain.doFilter(request, response); + } + + @Override + public void destroy() { + + } + + private boolean mayHaveBody(String method) { + if (method == null) { + return false; + } + String normalizedMethod = method.trim().toUpperCase(); + return normalizedMethod.equals("POST") || normalizedMethod.equals("PUT") || normalizedMethod.equals("PATCH"); + } + + private Long getHeaderAsLong(Map headers, String headerName) { + if (headers == null || headerName == null) { + return null; + } + String rawHeaderValue = null; + for (Map.Entry headerEntry : headers.entrySet()) { + String currentHeaderName = headerEntry.getKey(); + if (currentHeaderName.equalsIgnoreCase(headerName)) { + rawHeaderValue = headerEntry.getValue(); + break; + } + } + if (rawHeaderValue == null) { + return null; + } + try { + long parsedContentLength = Long.parseLong(rawHeaderValue.trim()); + return parsedContentLength < 0 ? null : parsedContentLength; + } catch (NumberFormatException ex) { + return null; + } + } + + private void reject(HttpResponseBuilder response, Long contentLength) { + response.setStatusCode(HttpResponseBuilder.SC_PAYLOAD_TOO_LARGE); + response.setBody("Payload too large: " + contentLength + " bytes (max " + maxBytes + ")"); + } +} diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index bd4026af..5bf921ee 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -26,6 +26,7 @@ public class HttpResponseBuilder { public static final int SC_UNAUTHORIZED = 401; public static final int SC_FORBIDDEN = 403; public static final int SC_NOT_FOUND = 404; + public static final int SC_PAYLOAD_TOO_LARGE = 413; // SERVER ERROR public static final int SC_INTERNAL_SERVER_ERROR = 500; @@ -60,7 +61,8 @@ public class HttpResponseBuilder { Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), - Map.entry(SC_GATEWAY_TIMEOUT, "Gateway Timeout") + Map.entry(SC_GATEWAY_TIMEOUT, "Gateway Timeout"), + Map.entry(SC_PAYLOAD_TOO_LARGE, "Payload Too Large") ); public void setStatusCode(int statusCode) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a57443be..1b997744 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,3 +10,7 @@ ipFilter: mode: "BLOCKLIST" blockedIps: [ ] allowedIps: [ ] + +maxRequestBody: + enabled: true + maxBytes: 1048576 diff --git a/src/test/java/org/example/filter/MaxRequestBodySizeTest.java b/src/test/java/org/example/filter/MaxRequestBodySizeTest.java new file mode 100644 index 00000000..67a6f933 --- /dev/null +++ b/src/test/java/org/example/filter/MaxRequestBodySizeTest.java @@ -0,0 +1,128 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class MaxRequestBodySizeFilterTest { + + private static FilterChain spyChain(AtomicBoolean called) { + return (req, resp) -> called.set(true); + } + + @Test + void getRequest_shouldPassThrough() { + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(10); + + HttpRequest request = new HttpRequest("GET", "/", "HTTP/1.1", Map.of(), null); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertTrue(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_OK, response.getStatusCode()); + } + + @Test + void postWithTooLargeContentLength_shouldRejectWith413() { + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(10); + + HttpRequest request = new HttpRequest( + "POST", "/upload", "HTTP/1.1", + Map.of("Content-Length", "11"), + null + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertFalse(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_PAYLOAD_TOO_LARGE, response.getStatusCode()); + assertTrue(response.getBody().startsWith("Payload too large:"), "body should explain rejection"); + } + + @Test + void postWithContentLengthEqualToMax_shouldPassThrough() { + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(10); + + HttpRequest request = new HttpRequest( + "POST", "/upload", "HTTP/1.1", + Map.of("Content-Length", "10"), + null + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertTrue(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_OK, response.getStatusCode()); + } + + @Test + void invalidContentLength_shouldBeIgnoredAndPassThrough() { + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(10); + + HttpRequest request = new HttpRequest( + "POST", "/upload", "HTTP/1.1", + Map.of("Content-Length", "abc"), + null + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertTrue(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_OK, response.getStatusCode()); + } + + @Test + void contentLengthHeaderName_shouldBeCaseInsensitive() { + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(10); + + HttpRequest request = new HttpRequest( + "POST", "/upload", "HTTP/1.1", + Map.of("content-length", "11"), // lowercase key + null + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertFalse(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_PAYLOAD_TOO_LARGE, response.getStatusCode()); + } + + @Test + void bodyFallback_shouldCountUtf8Bytes() { + // "€" is 3 bytes in UTF-8 + MaxRequestBodySizeFilter filter = new MaxRequestBodySizeFilter(2); + + HttpRequest request = new HttpRequest( + "POST", "/upload", "HTTP/1.1", + Map.of(), // no Content-Length + "€" + ); + HttpResponseBuilder response = new HttpResponseBuilder(); + AtomicBoolean chainCalled = new AtomicBoolean(false); + + filter.doFilter(request, response, spyChain(chainCalled)); + + assertFalse(chainCalled.get()); + assertEquals(HttpResponseBuilder.SC_PAYLOAD_TOO_LARGE, response.getStatusCode()); + } + + @Test + void negativeMaxBytes_shouldThrow() { + assertThrows(IllegalArgumentException.class, () -> new MaxRequestBodySizeFilter(-1)); + } +}