Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
843b34c
initial commit
JohanHiths Feb 16, 2026
2844d6e
Add `setNoCache` method to configure no-cache headers
JohanHiths Feb 17, 2026
0048a05
Improve Javadocs for `HttpCachingHeaders` and `HttpCachingHeadersTest`
JohanHiths Feb 18, 2026
e422463
Fix `addETagHeader` with better formatting after coderabbits suggesti…
JohanHiths Feb 18, 2026
4b3267d
Refactor `addETagHeader` for RFC compliance; update Javadocs and simp…
JohanHiths Feb 18, 2026
907e588
Add `CachingFilter` to enable HTTP caching with ETag and Last-Modifie…
JohanHiths Feb 21, 2026
ec9b987
Handle invalid `If-Modified-Since` parsing in `CachingFilter`; add ET…
JohanHiths Feb 22, 2026
b2ad6ee
Fixing
JohanHiths Feb 23, 2026
5ea159e
Fixing
JohanHiths Feb 23, 2026
af7fcdb
Fix
JohanHiths Feb 23, 2026
c27a17d
restore deleted files
JohanHiths Feb 23, 2026
7bbcdd4
Coderabbit review
JohanHiths Feb 23, 2026
5bd97d0
Update `CachingFilter` to include ETag in response headers; add `addH…
JohanHiths Feb 23, 2026
90b6fc4
Add unit tests for `CachingFilter` functionality; improve path normal…
JohanHiths Feb 24, 2026
5962ecb
Add unit tests for `CachingFilter` functionality; improve path normal…
JohanHiths Feb 24, 2026
456cda3
Add unit tests for CachingFilter functionality; improve path normaliz…
JohanHiths Feb 24, 2026
c1321dd
Add unit tests for `CachingFilter` functionality; improve path normal…
JohanHiths Feb 24, 2026
5552e85
Add unit tests for CachingFilter functionality; improve path normaliz…
JohanHiths Feb 24, 2026
a9cffb6
Merge branch 'main' into http-caching-headers
JohanHiths Feb 24, 2026
cbb0743
Remove getStatusCode method from HttpResponseBuilder
JohanHiths Feb 24, 2026
2991058
Refactor `CachingFilter` to use configurable root directory, improve …
JohanHiths Feb 26, 2026
522ee98
Update `CachingFilter` to enhance caching logic, centralize header ha…
JohanHiths Feb 26, 2026
d319eb1
Introduce `FileResolver` to centralize path resolution, update `HttpR…
JohanHiths Feb 28, 2026
2b3ed4e
Enhance `FileResolver` to append root directory to resolved paths, im…
JohanHiths Feb 28, 2026
6107ef2
Handle null/empty paths in `FileResolver`, improve ETag formatting, a…
JohanHiths Feb 28, 2026
8f0f324
Remove unnecessary `setBody` call for 404 response in `StaticFileHand…
JohanHiths Feb 28, 2026
41eeeb2
Prevent directory traversal in `FileResolver` by blocking paths with …
JohanHiths Mar 1, 2026
17b914a
Improve ETag formatting in `CachingFilter` and tests, ensure canonica…
JohanHiths Mar 1, 2026
737ded2
Update src/main/java/org/example/StaticFileHandler.java
JohanHiths Mar 1, 2026
d02a330
Merge branch 'main' into refactor-file-resolving
JohanHiths Mar 1, 2026
c65b5cc
Remove unnecessary blank lines in StaticFileHandler
JohanHiths Mar 1, 2026
2123ba6
Update `CompressionFilterTest` to incorporate `FileResolver` for cons…
JohanHiths Mar 1, 2026
12b48b3
Integrate `FileResolver` into filter tests for consistent path resolu…
JohanHiths Mar 2, 2026
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
12 changes: 11 additions & 1 deletion src/main/java/org/example/ConnectionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
import java.io.IOException;
import java.net.Socket;


public class ConnectionHandler implements AutoCloseable {

Socket client;
String uri;
private final List<Filter> filters;
String webRoot;



public ConnectionHandler(Socket client) {
this.client = client;
this.filters = buildFilters();
Expand All @@ -45,6 +48,7 @@ private List<Filter> buildFilters() {
}

public void runConnectionHandler() throws IOException {

StaticFileHandler sfh;

if (webRoot != null) {
Expand All @@ -53,17 +57,23 @@ public void runConnectionHandler() throws IOException {
sfh = new StaticFileHandler();
}


HttpParser parser = new HttpParser();

parser.setReader(client.getInputStream());
parser.parseRequest();
parser.parseHttp();

String resolvedPath = FileResolver.resolvePath(parser.getUri());

Comment on lines +67 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Block request processing when resolvedPath is null.

FileResolver.resolvePath(...) can return null for forbidden paths, but execution continues and the unsanitized URI is still used for file serving. This can bypass the intended traversal guard.

Suggested fix
 String resolvedPath = FileResolver.resolvePath(parser.getUri());
+if (resolvedPath == null) {
+    HttpResponseBuilder forbidden = new HttpResponseBuilder();
+    forbidden.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN);
+    client.getOutputStream().write(forbidden.build());
+    client.getOutputStream().flush();
+    return;
+}

Also applies to: 93-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/ConnectionHandler.java` around lines 67 - 68, Check
whether FileResolver.resolvePath(parser.getUri()) returned null and, if so, stop
further request processing and return an appropriate error/forbidden response;
specifically, after the call that assigns resolvedPath replace continuing logic
with a null-check that rejects the request (e.g., send 403/404 or close the
connection) instead of falling back to the unsanitized parser.getUri(). Apply
the same null-check and early-return behavior for the other occurrence that uses
resolvedPath (the block around lines referencing resolvedPath at 93-95) so no
file-serving logic runs with a null resolvedPath.

HttpRequest request = new HttpRequest(
parser.getMethod(),
parser.getUri(),
parser.getVersion(),
parser.getHeadersMap(),
""
"",
resolvedPath

);

String clientIp = client.getInetAddress().getHostAddress();
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/org/example/FileResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.example;

import org.example.config.ConfigLoader;
import org.example.filter.FilterChain;
import org.example.http.HttpCachingHeaders;
import org.example.http.HttpResponseBuilder;
import org.example.httpparser.HttpRequest;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;

public class FileResolver {


/**
* Helper class to resolve HTTP request paths to actual file system paths.
* This method processes incoming HTTP request paths and converts them into
* absolute file paths that can be used to locate and serve static files.
* @param requestPath the HTTP request path (e.g., "/", "/index.html"
* @return the complete file system path combining the root directory with the processed request path
*/

public static String resolvePath(String requestPath) {
HttpResponseBuilder response = new HttpResponseBuilder();
File errorFile = new File("src/main/resources/error.html");

if (requestPath == null || requestPath.isEmpty()) {
requestPath = "/";
}
String path = requestPath.equals("/") ? "index.html" : requestPath.substring(1);
String rootDir = ConfigLoader.get().server().rootDir();

if (path.contains("..")) {
try {
response.setBody(Files.readAllBytes(errorFile.toPath()));
} catch (IOException e) {
response.setBody("Forbidden".getBytes(StandardCharsets.UTF_8));
}
response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN);
return null;
}
Comment on lines +27 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

resolvePath mixes concerns and signals errors via null.

On Line 27-28, response/error-page handling is created inside a path resolver, but that response object is never returned. On Line 43, returning null as the error signal makes downstream handling fragile. Keep this method strictly about resolution and use an explicit error contract (e.g., exception or dedicated result type) instead of a nullable path string.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/FileResolver.java` around lines 27 - 44, The
resolvePath method in FileResolver mixes HTTP response construction
(HttpResponseBuilder/error.html) with path resolution and signals errors by
returning null; remove any creation or modification of HttpResponseBuilder and
file-based error handling from resolvePath and instead make it return a strict
resolution result (e.g., non-null String path, Optional<String>, or throw a
dedicated checked/unchecked exception such as PathResolutionException) when a
forbidden or invalid path is detected (e.g., path.contains("..")). Update
resolvePath to only compute and validate the filesystem path using
ConfigLoader.get().server().rootDir() and index logic (index.html), and replace
the null return with the chosen explicit error mechanism; ensure all callers of
resolvePath (and any tests) are updated to handle the new exception or Optional
result and perform response construction (including reading error.html and
setting HttpResponseBuilder status) at the call site.


return rootDir + "/" + path;

}



}


41 changes: 41 additions & 0 deletions src/main/java/org/example/StaticFileHandler.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package org.example;

import org.example.config.ConfigLoader;
import org.example.filter.FilterChain;
import org.example.http.HttpResponseBuilder;
import org.example.httpparser.HttpRequest;

import static org.example.http.HttpResponseBuilder.*;

import java.io.File;
Expand Down Expand Up @@ -35,6 +39,7 @@ private void handleGetRequest(String uri) throws IOException {
// Path traversal check
File root = new File(WEB_ROOT).getCanonicalFile();
File file = new File(root, uri).getCanonicalFile();

if (!file.toPath().startsWith(root.toPath())) {
fileBytes = "403 Forbidden".getBytes(java.nio.charset.StandardCharsets.UTF_8);
statusCode = SC_FORBIDDEN;
Expand All @@ -56,6 +61,42 @@ private void handleGetRequest(String uri) throws IOException {
}
}

public void handle(HttpRequest request, HttpResponseBuilder response, FilterChain chain) throws IOException {

String rootDir = ConfigLoader.get().server().rootDir();
File root = new File(rootDir).getCanonicalFile();
File file = new File(request.getResolvedPath()).getCanonicalFile();

if (!file.toPath().startsWith(root.toPath())) {
response.setStatusCode(HttpResponseBuilder.SC_FORBIDDEN);
response.setBody("403 Forbidden");
return;
}


if (file.isFile()) {
byte[] fileBytes = Files.readAllBytes(file.toPath());
response.setBody(fileBytes);
response.setStatusCode(SC_OK);
response.setContentTypeFromFilename(file.getName());
statusCode = SC_OK;
}
else {
response.setStatusCode(HttpResponseBuilder.SC_NOT_FOUND);

File errorFile = new File(root, "pageNotFound.html");
if (errorFile.isFile()) {
response.setBody(Files.readAllBytes(errorFile.toPath()));
response.setContentTypeFromFilename(errorFile.getName());
} else {
response.setBody("404 Not Found".getBytes());
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
}
}


}

public void sendGetRequest(OutputStream outputStream, String uri) throws IOException {
handleGetRequest(uri);
HttpResponseBuilder response = new HttpResponseBuilder();
Expand Down
100 changes: 100 additions & 0 deletions src/main/java/org/example/http/CachingFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package org.example.http;


import org.example.config.AppConfig;
import org.example.config.ConfigLoader;
import org.example.filter.Filter;
import org.example.filter.FilterChain;
import org.example.httpparser.HttpRequest;

import java.io.File;
import java.io.ObjectInputFilter;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Map;


public class CachingFilter implements Filter {


@Override
public void init() {

}

@Override
public void destroy() {

}

@Override
public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) {


HttpCachingHeaders cachingHeaders = new HttpCachingHeaders();

String resolvedPath = request.getResolvedPath();
File file = new File(resolvedPath);
if (!file.isFile()) {
chain.doFilter(request, response);
return;
}


Map<String, String> headers = request.getHeaders();

String modifiedSince = headers.get("If-Modified-Since");
String eTag = generateEtag(file);
String quotedEtag = "\"" + eTag + "\"";
Instant lastModified = Instant.ofEpochMilli(file.lastModified());

String ifNoneMatch = headers.get("If-None-Match");

cachingHeaders.addETagHeader(eTag);
cachingHeaders.setLastModified(Instant.ofEpochMilli(file.lastModified()));
cachingHeaders.setDefaultCacheControlStatic();


if (ifNoneMatch != null && ifNoneMatch.equals(quotedEtag)) {
response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED);
cachingHeaders.getHeaders().forEach(response::addHeader);
return;
}

if (modifiedSince != null) {
try {
Instant ifModifiedSinceInstant =
Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(modifiedSince));

if (!lastModified.isAfter(ifModifiedSinceInstant)) {
response.setStatusCode(HttpResponseBuilder.SC_NOT_MODIFIED);
cachingHeaders.getHeaders().forEach(response::addHeader);
return;
}

} catch (Exception e) {

// Ignore malformed If-Modified-Since header; proceed without cache validation
// Consider logging: log.debug("Invalid If-Modified-Since header: {}", modifiedSince, e);
}
Comment on lines +75 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty catch block silently swallows parsing errors.

If If-Modified-Since header parsing fails, the exception is ignored without any logging. This makes debugging difficult. At minimum, log the exception or the malformed header value.

Proposed fix
             } catch (Exception e) {
-
+                // Ignore malformed If-Modified-Since header; proceed without cache validation
+                // Consider logging: log.debug("Invalid If-Modified-Since header: {}", modifiedSince, e);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (Exception e) {
}
} catch (Exception e) {
// Ignore malformed If-Modified-Since header; proceed without cache validation
// Consider logging: log.debug("Invalid If-Modified-Since header: {}", modifiedSince, e);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/http/CachingFilter.java` around lines 68 - 70, In
CachingFilter replace the empty catch in the If-Modified-Since parsing block
with logging: catch the exception in the same try around parsing (the block
containing request.getHeader("If-Modified-Since")) and call the class logger (or
create one if missing) to log a warning or debug message that includes the
malformed header value and the exception (e.g., log the header string and
e.toString()); keep the original control flow after logging so we still handle
missing/invalid dates gracefully.


}






chain.doFilter(request, response);
cachingHeaders.getHeaders().forEach(response::addHeader);


}

private String generateEtag(File file) {
return file.lastModified() + "-" + file.length();

}
}


108 changes: 108 additions & 0 deletions src/main/java/org/example/http/HttpCachingHeaders.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.example.http;

import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
//
/**
* Helper class for building HTTP response headers
* Lets the client reuse cached responses
* Reduces bandwidth
* Reduces latency
* Reduces load on your server
* Ensures webserver and proxies understand caching instructions
*/
public class HttpCachingHeaders {

/**
* Cache Control helps manage servers and browsers by settings rules
* ETag helps cache be more efficient and not needing to send a full resend assuming the content has not changed
* Last-Modified
*/

private static final String CACHE_CONTROL = "Cache-Control";
private static final String LAST_MODIFIED = "Last-Modified";
private static final String ETAG = "ETag";

private static final DateTimeFormatter HTTP_DATE_FORMATTER =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);


private final Map<String, String> headers = new LinkedHashMap<>();


/**
* Sets a header
* @param name Header name eg. Cache-Control
* @param value Header value eg. public, max-age=3600
*/
public void setHeader(String name, String value) {
headers.put(name, value);
}

/**
* Helper method for setting ETag header value
* ETag values must be enclosed in double quotes "123" not 123
* @param etag Raw, unquoted ETag token (e.g. {`@code` abc123}); double quotes
* are added automatically to comply with RFC 7232.
*/
public void addETagHeader(String etag) {
setHeader(ETAG, "\"" + etag + "\"");
}
Comment on lines +45 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ETag quoting: Javadoc says quotes are added but implementation doesn't add them.

The Javadoc states "double quotes are added automatically to comply with RFC 7232", but the implementation just calls setHeader(ETAG, etag) without adding quotes.

🐛 Fix to match documented behavior
 public void addETagHeader(String etag) {
-    setHeader(ETAG, etag);
+    setHeader(ETAG, "\"" + etag + "\"");
 }

Alternatively, update the Javadoc if callers are expected to include quotes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/org/example/http/HttpCachingHeaders.java` around lines 45 - 53,
The Javadoc for addETagHeader says it should add double quotes but the method
currently calls setHeader(ETAG, etag) directly; update addETagHeader to ensure
the header value is properly quoted per RFC 7232 by wrapping the raw token with
double quotes before calling setHeader(ETAG, ...), but avoid double-quoting if
the passed etag already starts and ends with quotes and handle null/empty input
gracefully; locate addETagHeader and the ETAG constant and adjust the logic
there (and callers only if behavior change requires it).


/**
* Sets Cache-Control header value
* @param cacheControl sets rules eg. public, max-age=3600
*/
public void setCacheControl(String cacheControl) {
setHeader(CACHE_CONTROL, cacheControl);
}

/**
* Helper method for setting Last-Modified header value
* Formates and sets Last modified based on an instant
* @param instant Timestamp of the last modification
*/
public void setLastModified(Instant instant){
setHeader(LAST_MODIFIED, HTTP_DATE_FORMATTER.format(instant));
}


/**
* In case of errors or unexpected behaviour, the cache should be disabled and no data should be saved
*/
public void setNoCache() {
setCacheControl("no-store, no-cache");
}


/**
* Copies all configured caching headers into the provided target map eg. HttpReponseBuilder.
* @param target Map should return generated headers
*/
public void applyTo(Map<String,String> target){
target.putAll(headers);
}

/**
* Maps all configured caching headers into a new map
* @return A map which includes all caching headers
*/
public Map<String,String> getHeaders() {
return new LinkedHashMap<>(headers);
}



/**
* Standard settings for caching, 1 hour
*/
public void setDefaultCacheControlStatic(){
setCacheControl("public, max-age=3600");
}



}
8 changes: 8 additions & 0 deletions src/main/java/org/example/http/HttpResponseBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ public void setContentTypeFromFilename(String filename) {
setHeader("Content-Type", mimeType);
}


public void addHeader(String key, String value){
this.headers.put(key, value);
}



/*
* Builds the complete HTTP response as a byte array and preserves binary content without corruption.
* @return Complete HTTP response (headers + body) as byte[]
Expand Down Expand Up @@ -147,6 +154,7 @@ public byte[] build() {
System.arraycopy(contentBody, 0, response, headerBytes.length, contentBody.length);

return response;

}

public Map<String, String> getHeaders() {
Expand Down
Loading