From 343f199e37080324e50135117bbe0ca2bb0d50d5 Mon Sep 17 00:00:00 2001 From: Linda Eskilsson Date: Wed, 18 Feb 2026 08:55:54 +0100 Subject: [PATCH 1/6] fix(httpresponse): add safe defaults, null-safe headers and defensive body handling --- src/main/java/org/juv25d/http/HttpResponse.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/juv25d/http/HttpResponse.java b/src/main/java/org/juv25d/http/HttpResponse.java index 76285602..29f7ed4b 100644 --- a/src/main/java/org/juv25d/http/HttpResponse.java +++ b/src/main/java/org/juv25d/http/HttpResponse.java @@ -11,15 +11,20 @@ public class HttpResponse { private int statusCode; private String statusText; - private Map headers; + private final Map headers; private byte[] body; - public HttpResponse(){} + public HttpResponse() { + this.statusCode = 200; + this.statusText = "OK"; + this.headers = new LinkedHashMap<>(); + this.body = new byte[0]; + } public HttpResponse(int statusCode, String statusText, Map headers, byte[] body) { this.statusCode = statusCode; this.statusText = statusText; - this.headers = new LinkedHashMap<>(headers); + this.headers = new LinkedHashMap<>(headers != null ? headers : Map.of()); this.body = body != null ? body.clone() : new byte[0]; } From bf3ae7cd2818901f199fc433e691e1cd4a61f9d1 Mon Sep 17 00:00:00 2001 From: Linda Eskilsson Date: Wed, 18 Feb 2026 09:05:54 +0100 Subject: [PATCH 2/6] test(httpresponse): verify default values and null-safe behaviour --- .../org/juv25d/http/HttpResponseTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/test/java/org/juv25d/http/HttpResponseTest.java diff --git a/src/test/java/org/juv25d/http/HttpResponseTest.java b/src/test/java/org/juv25d/http/HttpResponseTest.java new file mode 100644 index 00000000..3ca2e7d6 --- /dev/null +++ b/src/test/java/org/juv25d/http/HttpResponseTest.java @@ -0,0 +1,25 @@ +package org.juv25d.http; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class HttpResponseTest { + + @Test + void defaultConstructor_hasSafeDefaults_andSetHeaderDoesNotThrow() { + HttpResponse response = new HttpResponse(); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.statusText()).isEqualTo("OK"); + assertThat(response.headers()).isNotNull(); + assertThat(response.body()).isNotNull(); + assertThat(response.body()).isEmpty(); + + assertThatCode(() -> response.setHeader("Content-Type", "text/plain")) + .doesNotThrowAnyException(); + + assertThat(response.headers()).containsEntry("Content-Type", "text/plain"); + } +} From 34a9ba104a6ec9b05a5e162ace81c952ff1dbbf1 Mon Sep 17 00:00:00 2001 From: Linda Eskilsson Date: Wed, 18 Feb 2026 09:13:56 +0100 Subject: [PATCH 3/6] refactor(httpresponse): enforce non-null statusText in constructor --- src/main/java/org/juv25d/http/HttpResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/juv25d/http/HttpResponse.java b/src/main/java/org/juv25d/http/HttpResponse.java index 29f7ed4b..004b3c1a 100644 --- a/src/main/java/org/juv25d/http/HttpResponse.java +++ b/src/main/java/org/juv25d/http/HttpResponse.java @@ -23,7 +23,7 @@ public HttpResponse() { public HttpResponse(int statusCode, String statusText, Map headers, byte[] body) { this.statusCode = statusCode; - this.statusText = statusText; + this.statusText = Objects.requireNonNull(statusText, "statusText must not be null"); this.headers = new LinkedHashMap<>(headers != null ? headers : Map.of()); this.body = body != null ? body.clone() : new byte[0]; } From 1d42e4738ef37054fcb29f7f7130c7f164a7e898 Mon Sep 17 00:00:00 2001 From: johanbriger Date: Thu, 19 Feb 2026 10:45:56 +0100 Subject: [PATCH 4/6] implement securityheadersfilter to harden http responses (#91) * Add SecurityHeadersFilter for hardened HTTP responses * Add SecurityHeadersFilter for hardened HTTP responses * Changed X-XSS-Protection value to recommended 0, * address code review feedback from CodeRabbit * Add @Global annotation to SecurityHeadersFilter for automatic registration * Removed line of code in App.java --- src/main/java/org/juv25d/App.java | 11 +++---- .../juv25d/filter/SecurityHeadersFilter.java | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/juv25d/filter/SecurityHeadersFilter.java diff --git a/src/main/java/org/juv25d/App.java b/src/main/java/org/juv25d/App.java index 82ee34f1..5aa62d4f 100644 --- a/src/main/java/org/juv25d/App.java +++ b/src/main/java/org/juv25d/App.java @@ -1,16 +1,13 @@ package org.juv25d; -import org.juv25d.filter.IpFilter; -import org.juv25d.filter.LoggingFilter; -import org.juv25d.filter.RateLimitingFilter; +import org.juv25d.filter.*; import org.juv25d.logging.ServerLogging; import org.juv25d.http.HttpParser; import org.juv25d.plugin.NotFoundPlugin; // New import import org.juv25d.plugin.StaticFilesPlugin; import org.juv25d.router.SimpleRouter; // New import import org.juv25d.util.ConfigLoader; -import org.juv25d.filter.RedirectFilter; -import org.juv25d.filter.RedirectRule; + import java.util.List; import java.util.Set; @@ -23,6 +20,9 @@ public static void main(String[] args) { HttpParser httpParser = new HttpParser(); Pipeline pipeline = new Pipeline(); + + pipeline.addGlobalFilter(new SecurityHeadersFilter(), 0); + // Configure redirect rules List redirectRules = List.of( new RedirectRule("/old-page", "/new-page", 301), @@ -31,7 +31,6 @@ public static void main(String[] args) { ); pipeline.addGlobalFilter(new RedirectFilter(redirectRules), 0); - // IP filter is enabled but configured with open access during development // White/blacklist can be tightened when specific IP restrictions are decided pipeline.addGlobalFilter(new IpFilter( diff --git a/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java b/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java new file mode 100644 index 00000000..49bd8390 --- /dev/null +++ b/src/main/java/org/juv25d/filter/SecurityHeadersFilter.java @@ -0,0 +1,31 @@ + +package org.juv25d.filter; + +import org.juv25d.filter.annotation.Global; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import java.io.IOException; + +/** + * Filter that adds security headers to every HTTP response. + * This helps protect against attacks such as Clickjacking and MIME sniffing. + */ +@Global(order = 0) +public class SecurityHeadersFilter implements Filter { + + @Override + public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException { + try { + chain.doFilter(req, res); + } finally { + + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "0"); + res.setHeader("Referrer-Policy", "no-referrer"); + + } + } + } + + From a9deefd9c3b54828d49234bbb284ec3a4f58b641 Mon Sep 17 00:00:00 2001 From: SandraNelj <229708855+SandraNelj@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:04:14 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Added=20IpFilterTest=20class=20with=20unit?= =?UTF-8?q?=20test=20verifying=20IpFilter=20allows=20whi=E2=80=A6=20(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added IpFilterTest class with unit test verifying IpFilter allows whitelisted IPs. * Fix IpFilterTest to verify response interaction instead of mock state * Added unit test for blocking IP that is not in the whitelist, results in 403 Forbidden response. Fixed HttpResponse construtors to always initialize headers and body to prevent NPE when filters call setHeader or setBody. * Update IpFilter whitelist allow test to use real HttpResponse * Assert expected status code in IpFilter whitelist allow test --- .../java/org/juv25d/filter/IpFilterTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/test/java/org/juv25d/filter/IpFilterTest.java diff --git a/src/test/java/org/juv25d/filter/IpFilterTest.java b/src/test/java/org/juv25d/filter/IpFilterTest.java new file mode 100644 index 00000000..b0b92d2f --- /dev/null +++ b/src/test/java/org/juv25d/filter/IpFilterTest.java @@ -0,0 +1,45 @@ +package org.juv25d.filter; +import org.junit.jupiter.api.Test; +import org.juv25d.http.HttpRequest; +import org.juv25d.http.HttpResponse; +import java.io.IOException; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class IpFilterTest { + + @Test + void whitelist_allowsIp() throws IOException { + IpFilter filter = new IpFilter(Set.of("127.0.0.1"), null); + + HttpRequest req = mock(HttpRequest.class); + when(req.remoteIp()).thenReturn("127.0.0.1"); + + HttpResponse res = new HttpResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(chain).doFilter(req, res); + assertEquals(200, res.statusCode()); + } + + @Test + void whitelist_blocksIpNotInList() throws IOException { + IpFilter filter = new IpFilter(Set.of("10.0.0.1"), null); + + HttpRequest req = mock(HttpRequest.class); + when(req.remoteIp()).thenReturn("127.0.0.1"); + + HttpResponse res = new HttpResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + verify(chain, never()).doFilter(req, res); + + assertEquals(403, res.statusCode()); + assertEquals("Forbidden", res.statusText()); + + } +} From d0d75ce3f13944f89ec96ce89c35024359a8b0e2 Mon Sep 17 00:00:00 2001 From: Linda Eskilsson <101198755+lindaeskilsson@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:07:56 +0100 Subject: [PATCH 6/6] Refactor ConfigLoader to accept InputStream and add unit tests (#72) * test(config-loader): add test skeleton for ConfigLoader * test(config-loader): add initial test for loading config * refactor(config-loader): extract configuration loading to InputStream constructor * test(config-loader): verify values are loaded from yaml input * test(config-loader): add test for default values when server keys missing * test(config-loader): add null-input error handling test * refactor(config-loader): add safe map casting and robust value parsing * fix: handle missing server config and keep original exception cause * fix(config-loader): handle empty yaml config safely * fix (config-loader): add default log level for consistent config values * Add missing curly bracket. * fix(config-loader): address review rabbit comments and improve tests --------- Co-authored-by: Simon Forsberg Co-authored-by: mattknatt --- .../java/org/juv25d/util/ConfigLoader.java | 48 +++++++++---- .../org/juv25d/util/ConfigLoaderTest.java | 71 +++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 src/test/java/org/juv25d/util/ConfigLoaderTest.java diff --git a/src/main/java/org/juv25d/util/ConfigLoader.java b/src/main/java/org/juv25d/util/ConfigLoader.java index ffd49e4b..dd0a2da9 100644 --- a/src/main/java/org/juv25d/util/ConfigLoader.java +++ b/src/main/java/org/juv25d/util/ConfigLoader.java @@ -15,8 +15,13 @@ public class ConfigLoader { private boolean rateLimitingEnabled; private ConfigLoader() { - loadConfiguration(); - } + loadConfiguration(getClass().getClassLoader() + .getResourceAsStream("application-properties.yml")); } + + // new constructor for testing + ConfigLoader(InputStream input) { + loadConfiguration(input); } + public static synchronized ConfigLoader getInstance() { if (instance == null) { @@ -25,27 +30,37 @@ public static synchronized ConfigLoader getInstance() { return instance; } - private void loadConfiguration() { + private void loadConfiguration(InputStream input) { Yaml yaml = new Yaml(); - try (InputStream input = getClass().getClassLoader().getResourceAsStream("application-properties.yml")) { - if (input == null) { - throw new IllegalArgumentException("Did not find application-properties.yml"); - } + if (input == null) { + throw new IllegalArgumentException("Did not find application-properties.yml"); + } + try (input) { Map config = yaml.load(input); + if (config == null) config = Map.of(); + + // defaults always + this.port = 8080; + this.rootDirectory = "static"; + this.logLevel = "INFO"; // server - Map serverConfig = (Map) config.get("server"); + Map serverConfig = asStringObjectMap(config.get("server")); if (serverConfig != null) { - this.port = (Integer) serverConfig.getOrDefault("port", 8080); - this.rootDirectory = (String) serverConfig.getOrDefault("root-dir", "static"); + Object portValue = serverConfig.get("port"); + if (portValue instanceof Number n) this.port = n.intValue(); + + Object root = serverConfig.get("root-dir"); + if (root != null) this.rootDirectory = String.valueOf(root); } // logging - Map loggingConfig = (Map) config.get("logging"); + Map loggingConfig = asStringObjectMap(config.get("logging")); if (loggingConfig != null) { - this.logLevel = (String) loggingConfig.get("level"); + Object level = loggingConfig.get("level"); + if (level != null) this.logLevel = String.valueOf(level); } // rate-limiting @@ -60,7 +75,7 @@ private void loadConfiguration() { } } catch (Exception e) { - throw new RuntimeException("Failed to load application config"); + throw new RuntimeException("Failed to load application config", e); } } @@ -76,6 +91,13 @@ public String getRootDirectory() { return rootDirectory; } + @SuppressWarnings("unchecked") + private static Map asStringObjectMap(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return null; + } public long getRequestsPerMinute() { return requestsPerMinute; } diff --git a/src/test/java/org/juv25d/util/ConfigLoaderTest.java b/src/test/java/org/juv25d/util/ConfigLoaderTest.java new file mode 100644 index 00000000..eb00340c --- /dev/null +++ b/src/test/java/org/juv25d/util/ConfigLoaderTest.java @@ -0,0 +1,71 @@ +package org.juv25d.util; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ConfigLoaderTest { + + + /** + * Verifies that ConfigLoader correctly reads and assigns configuration values when provided + * with a valid YAML input stream. Ensures that all expected fields—server port, root directory, + * and logging level—are populated with the values defined in the YAML content. + */ + + @Test + void loadsValuesFromYaml() { + String yaml = """ + server: + port: 9090 + root-dir: "public" + logging: + level: "DEBUG" + """; + + ConfigLoader loader = new ConfigLoader( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals(9090, loader.getPort()); + assertEquals("public", loader.getRootDirectory()); + assertEquals("DEBUG", loader.getLogLevel()); + } + + /** + * Ensures that ConfigLoader falls back to its documented default values when the YAML input + * omits server configuration keys. This test confirms that missing fields do not cause errors + * and that default port and root directory values are applied as intended. + */ + + @Test + void usesDefaultsWhenServerKeysMissing() { + String yaml = """ + server: {} + logging: {} + """; + + ConfigLoader loader = new ConfigLoader( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)) + ); + + assertEquals(8080, loader.getPort()); + assertEquals("static", loader.getRootDirectory()); + assertEquals("INFO", loader.getLogLevel()); + } + + + /** + * Confirms that ConfigLoader fails predictably when no YAML configuration is provided. + * Passing a null InputStream should trigger a RuntimeException, indicating that the loader + * cannot operate without configuration data. + */ + + @Test void throwsWhenYamlMissing() { + assertThrows(RuntimeException.class, () -> + new ConfigLoader(null) ); } +} +