From e3c86039d8b7bfc68e094fbd833f04061e36ec48 Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 20:41:21 +0200 Subject: [PATCH 1/8] feat(http): scaffold StaticFileHandler with 404 path and unit build --- .gitignore | 1 + Makefile | 13 +++++++- includes/handler.hpp | 27 ++++++++++++++++ src/http/static_handler.cpp | 45 +++++++++++++++++++++++++++ tests/unit/test_static_handler.cpp | 50 ++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 includes/handler.hpp create mode 100644 src/http/static_handler.cpp create mode 100644 tests/unit/test_static_handler.cpp diff --git a/.gitignore b/.gitignore index a95836f..5857a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ docs/superpowers/ # unit test binaries (one per suite -- request, response, ...) tests/unit/run_*_tests +tests/unit/run_static_tests diff --git a/Makefile b/Makefile index 7eac734..2988bd9 100644 --- a/Makefile +++ b/Makefile @@ -69,11 +69,18 @@ REQ_BIN = tests/unit/run_request_tests RESP_SRC = tests/unit/test_response.cpp src/http/response.cpp RESP_BIN = tests/unit/run_response_tests -UNIT_BINS = $(REQ_BIN) $(RESP_BIN) +STATIC_SRC = tests/unit/test_static_handler.cpp src/http/static_handler.cpp \ + src/http/request.cpp src/http/response.cpp src/config/config.cpp \ + src/config/lexer.cpp src/config/parser.cpp \ + src/logger/exceptions.cpp +STATIC_BIN = tests/unit/run_static_tests + +UNIT_BINS = $(REQ_BIN) $(RESP_BIN) $(STATIC_BIN) unit: $(UNIT_BINS) @./$(REQ_BIN) @./$(RESP_BIN) + @./$(STATIC_BIN) $(REQ_BIN): $(REQ_SRC) $(INCLUDE_DIR)/http.hpp $(INCLUDE_DIR)/string_utils.hpp @echo "$(CYAN)🧪 Building request unit tests...$(RESET)" @@ -83,6 +90,10 @@ $(RESP_BIN): $(RESP_SRC) $(INCLUDE_DIR)/http.hpp $(INCLUDE_DIR)/string_utils.hpp @echo "$(CYAN)🧪 Building response unit tests...$(RESET)" @$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(RESP_BIN) $(RESP_SRC) +$(STATIC_BIN): $(STATIC_SRC) $(INCLUDE_DIR)/handler.hpp $(INCLUDE_DIR)/http.hpp $(INCLUDE_DIR)/config.hpp + @echo "$(CYAN)🧪 Building static-handler unit tests...$(RESET)" + @$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(STATIC_BIN) $(STATIC_SRC) + # Show help help: @echo "$(CYAN)📚 WebServ Build Targets:$(RESET)" diff --git a/includes/handler.hpp b/includes/handler.hpp new file mode 100644 index 0000000..3c43057 --- /dev/null +++ b/includes/handler.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include "http.hpp" +#include "config.hpp" + +// The static-file unit for task 2.3. Request decodes bytes, Response builds +// bytes; this is the piece in between that turns "GET /style.css" into a file +// off the disk. I keep it deliberately dumb about routing: which location a +// URI belongs to is task 2.4's job, so handleGet() is HANDED the already-picked +// location plus the effective root and just trusts them. That keeps it a pure +// filesystem unit -- no sockets, no Config walking -- and unit-testable against +// a temp dir, the same way Request/Response are testable on their own. +// +// It lives in its own header on purpose: http.hpp stays the pure wire-type +// header, and only the files that actually serve files pull in config.hpp. +class StaticFileHandler { + public: + // `root` is the effective root the caller resolved (the location's + // root, or the server root when the location leaves it empty -- 2.4/2.5 + // owns that fallback). `loc` supplies the index file (and, in 5.3, the + // autoindex flag). Returns a Response ready to serialize -- 200 with the + // file, or 403/404 with a small built-in error page. + static Response handleGet(const Request& req, + const std::string& root, + const LocationConfig& loc); +}; diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp new file mode 100644 index 0000000..a689572 --- /dev/null +++ b/src/http/static_handler.cpp @@ -0,0 +1,45 @@ +#include "../../includes/handler.hpp" +#include "../../includes/string_utils.hpp" +#include "../../includes/utils.hpp" +#include +#include + +// Static file serving for task 2.3. The why-it-takes-a-resolved-location and +// why-it's-a-separate-header reasoning lives in handler.hpp. This file is the +// filesystem half: normalize the path, classify it, hand back a Response. +// +// Subject note: every syscall here (stat, access) is on the authorized list, +// and regular disk files are explicitly exempt from the poll() rule -- so +// reading one straight off the disk is allowed. I never read errno to decide +// 403 vs 404; access()/stat() return values carry everything I need, which +// also dodges the "no errno after read/write" landmine entirely. + +// A small built-in error page. Custom error_page files are task 3.5; until +// then a 404/403 still needs a body a browser can show. +static Response errorResponse(int code) { + Response r; + r.setStatusCode(code); + std::string phrase = Response::reasonPhrase(code); + std::string body = "" + toString(code) + " " + phrase + + "

" + toString(code) + " " + + phrase + "

\n"; + r.setHeader("Content-Type", "text/html"); + r.setBody(body); + return r; +} + +Response StaticFileHandler::handleGet(const Request& req, + const std::string& root, + const LocationConfig& loc) { + (void)loc; + // root has no trailing slash by convention; getPath() always starts "/". + std::string base = root; + if (!base.empty() && base[base.size() - 1] == '/') + base.erase(base.size() - 1); + std::string fsPath = base + req.getPath(); + + if (::access(fsPath.c_str(), F_OK) != 0) + return errorResponse(404); // doesn't exist -- return value, not errno + + return errorResponse(404); // placeholder until Task 2 adds serving +} diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp new file mode 100644 index 0000000..d7228b7 --- /dev/null +++ b/tests/unit/test_static_handler.cpp @@ -0,0 +1,50 @@ +#include "../../includes/handler.hpp" +#include +#include +#include +#include +#include + +// Framework-free, like test_response.cpp: a counter and a check(). Unlike the +// Request/Response tests this one touches the disk, so it builds a throwaway +// fixture tree under /tmp first and tears it down at the end. +static int g_failed = 0; +static int g_passed = 0; + +static void check(bool cond, const std::string& name) { + if (cond) { ++g_passed; return; } + ++g_failed; + std::cerr << "FAIL: " << name << std::endl; +} + +// A tiny helper request: we only ever need getPath() to return a chosen path, +// and parseFromBuffer() is the only way to set it, so feed it a minimal GET. +static Request makeGet(const std::string& path) { + Request r; + std::string raw = "GET " + path + " HTTP/1.1\r\nHost: x\r\n\r\n"; + r.parseFromBuffer(raw); + return r; +} + +// --- fixture root, filled in by main() before the tests run --- +static std::string g_root; + +static void test_missing_file_is_404() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet(makeGet("/nope.html"), g_root, loc); + std::string out = r.serialize(); + check(out.compare(0, 13, "HTTP/1.1 404 ") == 0, "missing file -> 404"); +} + +int main() { + // Build the fixture tree under a unique-ish temp dir. + char tmpl[] = "/tmp/webserv_static_XXXXXX"; + char* dir = mkdtemp(tmpl); + if (dir == NULL) { std::cerr << "mkdtemp failed" << std::endl; return 1; } + g_root = dir; + + test_missing_file_is_404(); + + std::cout << g_passed << " passed, " << g_failed << " failed" << std::endl; + return g_failed == 0 ? 0 : 1; +} From efe50cd23725f9467f1aa9be1001758aac85fd73 Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 20:50:58 +0200 Subject: [PATCH 2/8] feat(http): serve regular files with extension-based Content-Type --- src/http/static_handler.cpp | 54 +++++++++++++++++++++++++++++- tests/unit/test_static_handler.cpp | 42 +++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp index a689572..11cf0c4 100644 --- a/src/http/static_handler.cpp +++ b/src/http/static_handler.cpp @@ -28,6 +28,51 @@ static Response errorResponse(int code) { return r; } +// Content-Type from the file extension: the substring after the last '.' that +// comes AFTER the last '/'. That guard means a dot in a directory name +// ("/v1.2/page") and a dotfile with no real extension both fall through to the +// safe default rather than being mis-typed. Matched case-insensitively because +// "INDEX.HTML" is still HTML. Only the handful of types a static site actually +// ships; anything else is octet-stream and the browser downloads it. +static std::string contentTypeFor(const std::string& path) { + std::size_t slash = path.find_last_of('/'); + std::size_t dot = path.find_last_of('.'); + if (dot == std::string::npos + || (slash != std::string::npos && dot < slash) + || dot + 1 == path.size()) + return "application/octet-stream"; + std::string ext = lowerCopy(path.substr(dot + 1)); + if (ext == "html" || ext == "htm") return "text/html"; + if (ext == "css") return "text/css"; + if (ext == "js") return "text/javascript"; + if (ext == "txt") return "text/plain"; + if (ext == "json") return "application/json"; + if (ext == "png") return "image/png"; + if (ext == "jpg" || ext == "jpeg") return "image/jpeg"; + if (ext == "gif") return "image/gif"; + if (ext == "ico") return "image/x-icon"; + if (ext == "svg") return "image/svg+xml"; + if (ext == "pdf") return "application/pdf"; + return "application/octet-stream"; +} + +// Read a known-regular file into a 200. R_OK first so an unreadable file is a +// clean 403 (DoD) rather than an empty 200; the read can still fail on a race +// after the check, and readFileToString reports that as a plain bool -- no +// errno -- which we also turn into 403. +static Response serveFile(const std::string& fsPath) { + if (::access(fsPath.c_str(), R_OK) != 0) + return errorResponse(403); + std::string body; + if (!readFileToString(fsPath, body)) + return errorResponse(403); + Response r; + r.setStatusCode(200); + r.setHeader("Content-Type", contentTypeFor(fsPath)); + r.setBody(body); + return r; +} + Response StaticFileHandler::handleGet(const Request& req, const std::string& root, const LocationConfig& loc) { @@ -41,5 +86,12 @@ Response StaticFileHandler::handleGet(const Request& req, if (::access(fsPath.c_str(), F_OK) != 0) return errorResponse(404); // doesn't exist -- return value, not errno - return errorResponse(404); // placeholder until Task 2 adds serving + struct stat st; + if (::stat(fsPath.c_str(), &st) != 0) + return errorResponse(404); // vanished between access and stat + + if (S_ISREG(st.st_mode)) + return serveFile(fsPath); + + return errorResponse(403); // dir/FIFO/etc -- Task 4 handles dirs } diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp index d7228b7..e44240d 100644 --- a/tests/unit/test_static_handler.cpp +++ b/tests/unit/test_static_handler.cpp @@ -1,5 +1,6 @@ #include "../../includes/handler.hpp" #include +#include #include #include #include @@ -26,6 +27,15 @@ static Request makeGet(const std::string& path) { return r; } +static bool contains(const std::string& hay, const std::string& needle) { + return hay.find(needle) != std::string::npos; +} + +static void writeFile(const std::string& path, const std::string& body) { + std::ofstream out(path.c_str()); + out << body; +} + // --- fixture root, filled in by main() before the tests run --- static std::string g_root; @@ -36,6 +46,30 @@ static void test_missing_file_is_404() { check(out.compare(0, 13, "HTTP/1.1 404 ") == 0, "missing file -> 404"); } +static void test_serves_html_200() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet(makeGet("/index.html"), g_root, loc); + std::string out = r.serialize(); + check(out.compare(0, 13, "HTTP/1.1 200 ") == 0, "existing file -> 200"); + check(contains(out, "Content-Type: text/html\r\n"), ".html -> text/html"); + check(contains(out, "

hello

"), "body is the file content"); +} + +static void test_content_type_css_js() { + LocationConfig loc; + check(contains(StaticFileHandler::handleGet(makeGet("/a.css"), g_root, loc).serialize(), + "Content-Type: text/css\r\n"), ".css -> text/css"); + check(contains(StaticFileHandler::handleGet(makeGet("/a.js"), g_root, loc).serialize(), + "Content-Type: text/javascript\r\n"), ".js -> text/javascript"); +} + +static void test_unknown_ext_is_octet_stream() { + LocationConfig loc; + check(contains(StaticFileHandler::handleGet(makeGet("/blob.bin"), g_root, loc).serialize(), + "Content-Type: application/octet-stream\r\n"), + "unknown extension -> octet-stream"); +} + int main() { // Build the fixture tree under a unique-ish temp dir. char tmpl[] = "/tmp/webserv_static_XXXXXX"; @@ -43,7 +77,15 @@ int main() { if (dir == NULL) { std::cerr << "mkdtemp failed" << std::endl; return 1; } g_root = dir; + writeFile(g_root + "/index.html", "

hello

"); + writeFile(g_root + "/a.css", "body{}"); + writeFile(g_root + "/a.js", "var x=1;"); + writeFile(g_root + "/blob.bin", "\x00\x01\x02 raw"); + test_missing_file_is_404(); + test_serves_html_200(); + test_content_type_css_js(); + test_unknown_ext_is_octet_stream(); std::cout << g_passed << " passed, " << g_failed << " failed" << std::endl; return g_failed == 0 ? 0 : 1; From 25f7cceea54ccd8fe5a404958f54c722230cb34f Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 20:53:08 +0200 Subject: [PATCH 3/8] test(http): make writeFile fixture helper fail loud on I/O error --- tests/unit/test_static_handler.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp index e44240d..6a6c88c 100644 --- a/tests/unit/test_static_handler.cpp +++ b/tests/unit/test_static_handler.cpp @@ -33,6 +33,10 @@ static bool contains(const std::string& hay, const std::string& needle) { static void writeFile(const std::string& path, const std::string& body) { std::ofstream out(path.c_str()); + if (!out) { + std::cerr << "writeFile: cannot open " << path << std::endl; + std::exit(1); + } out << body; } From 8e87d60667847d466d66c197a9315e9a78021341 Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 20:55:07 +0200 Subject: [PATCH 4/8] feat(http): block path traversal with lexical normalization --- src/http/static_handler.cpp | 46 +++++++++++++++++++++++++++++- tests/unit/test_static_handler.cpp | 33 +++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp index 11cf0c4..a731222 100644 --- a/src/http/static_handler.cpp +++ b/src/http/static_handler.cpp @@ -3,6 +3,7 @@ #include "../../includes/utils.hpp" #include #include +#include // Static file serving for task 2.3. The why-it-takes-a-resolved-location and // why-it's-a-separate-header reasoning lives in handler.hpp. This file is the @@ -14,6 +15,43 @@ // 403 vs 404; access()/stat() return values carry everything I need, which // also dodges the "no errno after read/write" landmine entirely. +// Collapse "." and ".." in the request path lexically, BEFORE touching disk. +// This is the whole traversal defense: a ".." pops the last real segment, and +// a ".." with nothing left to pop means the request is climbing above root -- +// the /etc/passwd attack -- so I set `escaped` and the caller turns it into a +// 403 without ever stat()ing outside root. realpath() would be the obvious +// tool but it's not on the subject's authorized list (and it follows symlinks), +// so I do it by hand on the path string. Returns a clean path with a single +// leading '/' per segment; "" means the root directory itself ("/"). +// +// No percent-decoding: the request parser already rejects NUL/CTL bytes and +// doesn't decode, so "%2e%2e" arrives as a literal filename that can't +// traverse -- only real ".." segments are a threat, and those die here. +static std::string normalizePath(const std::string& path, bool& escaped) { + escaped = false; + std::vector stack; + std::size_t i = 0; + while (i < path.size()) { + while (i < path.size() && path[i] == '/') ++i; // eat slash run + std::size_t start = i; + while (i < path.size() && path[i] != '/') ++i; // grab a segment + if (i == start) break; // trailing slash + std::string seg = path.substr(start, i - start); + if (seg == ".") { + continue; + } else if (seg == "..") { + if (stack.empty()) { escaped = true; return ""; } + stack.pop_back(); + } else { + stack.push_back(seg); + } + } + std::string out; + for (std::size_t k = 0; k < stack.size(); ++k) + out += "/" + stack[k]; + return out; +} + // A small built-in error page. Custom error_page files are task 3.5; until // then a 404/403 still needs a body a browser can show. static Response errorResponse(int code) { @@ -81,7 +119,13 @@ Response StaticFileHandler::handleGet(const Request& req, std::string base = root; if (!base.empty() && base[base.size() - 1] == '/') base.erase(base.size() - 1); - std::string fsPath = base + req.getPath(); + + bool escaped = false; + std::string rel = normalizePath(req.getPath(), escaped); + if (escaped) + return errorResponse(403); // climbed above root -- traversal attempt + + std::string fsPath = base + rel; // rel is "" (root dir) or "/seg/seg..." if (::access(fsPath.c_str(), F_OK) != 0) return errorResponse(404); // doesn't exist -- return value, not errno diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp index 6a6c88c..f4b1695 100644 --- a/tests/unit/test_static_handler.cpp +++ b/tests/unit/test_static_handler.cpp @@ -74,6 +74,33 @@ static void test_unknown_ext_is_octet_stream() { "unknown extension -> octet-stream"); } +static void test_traversal_is_403() { + LocationConfig loc; + // The literal-dotdot attack the DoD names. Must never reach /etc/passwd. + Response r = StaticFileHandler::handleGet( + makeGet("/../../../../etc/passwd"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 403 ") == 0, + "../ escaping root -> 403"); +} + +static void test_dotdot_within_root_resolves() { + LocationConfig loc; + // "/sub/../index.html" stays inside root and must resolve to /index.html. + Response r = StaticFileHandler::handleGet( + makeGet("/sub/../index.html"), g_root, loc); + std::string out = r.serialize(); + check(out.compare(0, 13, "HTTP/1.1 200 ") == 0, "in-root .. -> 200"); + check(contains(out, "

hello

"), "resolves to /index.html"); +} + +static void test_dot_segments_ignored() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet( + makeGet("/./index.html"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 200 ") == 0, + "single-dot segment ignored -> 200"); +} + int main() { // Build the fixture tree under a unique-ish temp dir. char tmpl[] = "/tmp/webserv_static_XXXXXX"; @@ -86,10 +113,16 @@ int main() { writeFile(g_root + "/a.js", "var x=1;"); writeFile(g_root + "/blob.bin", "\x00\x01\x02 raw"); + ::mkdir((g_root + "/sub").c_str(), 0755); + writeFile(g_root + "/sub/page.html", "

sub

"); + test_missing_file_is_404(); test_serves_html_200(); test_content_type_css_js(); test_unknown_ext_is_octet_stream(); + test_traversal_is_403(); + test_dotdot_within_root_resolves(); + test_dot_segments_ignored(); std::cout << g_passed << " passed, " << g_failed << " failed" << std::endl; return g_failed == 0 ? 0 : 1; From d140ddbc55304e72f0937c9958e93521b6e5bf6c Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 20:58:10 +0200 Subject: [PATCH 5/8] test(http): add bare-dotdot and partial-escape traversal assertions --- tests/unit/test_static_handler.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp index f4b1695..90ccafb 100644 --- a/tests/unit/test_static_handler.cpp +++ b/tests/unit/test_static_handler.cpp @@ -97,8 +97,23 @@ static void test_dot_segments_ignored() { LocationConfig loc; Response r = StaticFileHandler::handleGet( makeGet("/./index.html"), g_root, loc); - check(r.serialize().compare(0, 13, "HTTP/1.1 200 ") == 0, + std::string out = r.serialize(); + check(out.compare(0, 13, "HTTP/1.1 200 ") == 0, "single-dot segment ignored -> 200"); + check(contains(out, "

hello

"), "single-dot segment -> serves index.html"); +} + +static void test_bare_dotdot_is_403() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet(makeGet("/.."), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 403 ") == 0, "/.. -> 403"); +} + +static void test_partial_escape_is_403() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet(makeGet("/foo/../../bar"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 403 ") == 0, + "/foo/../../bar escapes after descent -> 403"); } int main() { @@ -123,6 +138,8 @@ int main() { test_traversal_is_403(); test_dotdot_within_root_resolves(); test_dot_segments_ignored(); + test_bare_dotdot_is_403(); + test_partial_escape_is_403(); std::cout << g_passed << " passed, " << g_failed << " failed" << std::endl; return g_failed == 0 ? 0 : 1; From 142096a2d3a9159d311d9c32276989c325aa6a8f Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 21:04:36 +0200 Subject: [PATCH 6/8] feat(http): serve directory index, return 403 for permission/no-index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the S_ISDIR branch to handleGet: directory + loc.index present and resolving to a regular file → serveFile; else 403 (autoindex is task 5.3). Removes the now-redundant (void)loc; cast. Four new TDD tests (unreadable→403, dir+index→200, dir-no-index→403, root/→200). Also adds real fixture teardown to main() and drops the redundant explicit gitignore entry for run_static_tests (already matched by glob). --- .gitignore | 1 - src/http/static_handler.cpp | 16 +++++++-- tests/unit/test_static_handler.cpp | 55 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5857a7a..a95836f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,3 @@ docs/superpowers/ # unit test binaries (one per suite -- request, response, ...) tests/unit/run_*_tests -tests/unit/run_static_tests diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp index a731222..9f5ec48 100644 --- a/src/http/static_handler.cpp +++ b/src/http/static_handler.cpp @@ -114,7 +114,6 @@ static Response serveFile(const std::string& fsPath) { Response StaticFileHandler::handleGet(const Request& req, const std::string& root, const LocationConfig& loc) { - (void)loc; // root has no trailing slash by convention; getPath() always starts "/". std::string base = root; if (!base.empty() && base[base.size() - 1] == '/') @@ -134,8 +133,21 @@ Response StaticFileHandler::handleGet(const Request& req, if (::stat(fsPath.c_str(), &st) != 0) return errorResponse(404); // vanished between access and stat + if (S_ISDIR(st.st_mode)) { + // A directory serves its configured index file, if there is one and it + // exists as a regular file. No index (or none configured) is a 403 for + // now -- the autoindex listing is task 5.3, and this is its hook. + if (!loc.index.empty()) { + std::string idx = fsPath + "/" + loc.index; + struct stat ist; + if (::stat(idx.c_str(), &ist) == 0 && S_ISREG(ist.st_mode)) + return serveFile(idx); + } + return errorResponse(403); + } + if (S_ISREG(st.st_mode)) return serveFile(fsPath); - return errorResponse(403); // dir/FIFO/etc -- Task 4 handles dirs + return errorResponse(403); // FIFO/socket/device -- nothing to serve } diff --git a/tests/unit/test_static_handler.cpp b/tests/unit/test_static_handler.cpp index 90ccafb..abccd48 100644 --- a/tests/unit/test_static_handler.cpp +++ b/tests/unit/test_static_handler.cpp @@ -116,6 +116,38 @@ static void test_partial_escape_is_403() { "/foo/../../bar escapes after descent -> 403"); } +static void test_unreadable_is_403() { + LocationConfig loc; + Response r = StaticFileHandler::handleGet(makeGet("/secret.html"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 403 ") == 0, + "unreadable file -> 403"); +} + +static void test_dir_with_index_served() { + LocationConfig loc; + loc.index = "index.html"; + // "/sub2" is a directory; its index.html should be served at 200. + Response r = StaticFileHandler::handleGet(makeGet("/sub2"), g_root, loc); + std::string out = r.serialize(); + check(out.compare(0, 13, "HTTP/1.1 200 ") == 0, "dir + index -> 200"); + check(contains(out, "

idx

"), "serves the index file body"); +} + +static void test_dir_without_index_is_403() { + LocationConfig loc; // index left empty + Response r = StaticFileHandler::handleGet(makeGet("/sub2"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 403 ") == 0, + "dir, no index, autoindex off -> 403"); +} + +static void test_root_dir_index_served() { + LocationConfig loc; + loc.index = "index.html"; // g_root itself has index.html from Task 2 + Response r = StaticFileHandler::handleGet(makeGet("/"), g_root, loc); + check(r.serialize().compare(0, 13, "HTTP/1.1 200 ") == 0, + "root '/' with index -> 200"); +} + int main() { // Build the fixture tree under a unique-ish temp dir. char tmpl[] = "/tmp/webserv_static_XXXXXX"; @@ -131,6 +163,11 @@ int main() { ::mkdir((g_root + "/sub").c_str(), 0755); writeFile(g_root + "/sub/page.html", "

sub

"); + writeFile(g_root + "/secret.html", "secret"); + ::chmod((g_root + "/secret.html").c_str(), 0000); + ::mkdir((g_root + "/sub2").c_str(), 0755); + writeFile(g_root + "/sub2/index.html", "

idx

"); + test_missing_file_is_404(); test_serves_html_200(); test_content_type_css_js(); @@ -140,6 +177,24 @@ int main() { test_dot_segments_ignored(); test_bare_dotdot_is_403(); test_partial_escape_is_403(); + test_unreadable_is_403(); + test_dir_with_index_served(); + test_dir_without_index_is_403(); + test_root_dir_index_served(); + + // Tear down the fixture tree. Restore readability on the chmod-000 file + // before unlinking, otherwise unlink() may fail on some systems. + ::chmod((g_root + "/secret.html").c_str(), 0644); + ::unlink((g_root + "/secret.html").c_str()); + ::unlink((g_root + "/sub2/index.html").c_str()); + ::rmdir((g_root + "/sub2").c_str()); + ::unlink((g_root + "/sub/page.html").c_str()); + ::rmdir((g_root + "/sub").c_str()); + ::unlink((g_root + "/index.html").c_str()); + ::unlink((g_root + "/a.css").c_str()); + ::unlink((g_root + "/a.js").c_str()); + ::unlink((g_root + "/blob.bin").c_str()); + ::rmdir(g_root.c_str()); std::cout << g_passed << " passed, " << g_failed << " failed" << std::endl; return g_failed == 0 ? 0 : 1; From 459e88af5a9cf5170ee6342e6fd773436ade1e01 Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 21:04:56 +0200 Subject: [PATCH 7/8] docs(kanban): mark 2.3 File I/O & Static File Serving completed --- kanban.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/kanban.md b/kanban.md index 2fc820a..d475e03 100644 --- a/kanban.md +++ b/kanban.md @@ -267,7 +267,7 @@ --- -### [ ] 2.3: File I/O & Static File Serving +### [ completed ] 2.3: File I/O & Static File Serving **Assignee:** Dev C **Description:** - Implement StaticFileHandler class in src/http/static_handler.cpp @@ -285,13 +285,13 @@ - Error codes: 200, 404, 403 **DoD:** -- [ ] Serves index.html from root without errors -- [ ] Reads arbitrary .html, .css, .js files -- [ ] Returns 404 for missing files -- [ ] Returns 403 for permission denied -- [ ] Content-Type header correct for file types -- [ ] No path traversal vulnerabilities (../../../etc/passwd fails) -- [ ] File paths respect root directory from config +- [x] Serves index.html from root without errors +- [x] Reads arbitrary .html, .css, .js files +- [x] Returns 404 for missing files +- [x] Returns 403 for permission denied +- [x] Content-Type header correct for file types +- [x] No path traversal vulnerabilities (../../../etc/passwd fails) +- [x] File paths respect root directory from config --- From 47b84e90504b1da71d8739924c7ca7ba51e6fb85 Mon Sep 17 00:00:00 2001 From: ibrohimovmuhammad2020 Date: Sat, 20 Jun 2026 21:09:56 +0200 Subject: [PATCH 8/8] docs(http): note trusted-config index and symlink stance in static handler --- src/http/static_handler.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp index 9f5ec48..0a1c0bc 100644 --- a/src/http/static_handler.cpp +++ b/src/http/static_handler.cpp @@ -23,6 +23,9 @@ // tool but it's not on the subject's authorized list (and it follows symlinks), // so I do it by hand on the path string. Returns a clean path with a single // leading '/' per segment; "" means the root directory itself ("/"). +// Lexical normalization doesn't resolve symlinks -- a symlink inside root that +// points outside it would be followed. That's the accepted posture (nginx does +// the same by default), and realpath() isn't an option for the same reason. // // No percent-decoding: the request parser already rejects NUL/CTL bytes and // doesn't decode, so "%2e%2e" arrives as a literal filename that can't @@ -138,6 +141,8 @@ Response StaticFileHandler::handleGet(const Request& req, // exists as a regular file. No index (or none configured) is a 403 for // now -- the autoindex listing is task 5.3, and this is its hook. if (!loc.index.empty()) { + // loc.index comes from the server config, not the request -- it's + // operator-supplied, so I deliberately skip normalizePath here. std::string idx = fsPath + "/" + loc.index; struct stat ist; if (::stat(idx.c_str(), &ist) == 0 && S_ISREG(ist.st_mode))