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/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 --- diff --git a/src/http/static_handler.cpp b/src/http/static_handler.cpp new file mode 100644 index 0000000..0a1c0bc --- /dev/null +++ b/src/http/static_handler.cpp @@ -0,0 +1,158 @@ +#include "../../includes/handler.hpp" +#include "../../includes/string_utils.hpp" +#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 +// 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. + +// 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 ("/"). +// 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 +// 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) { + 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; +} + +// 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) { + // 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); + + 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 + + struct stat st; + 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()) { + // 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)) + return serveFile(idx); + } + return errorResponse(403); + } + + if (S_ISREG(st.st_mode)) + return serveFile(fsPath); + + 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 new file mode 100644 index 0000000..abccd48 --- /dev/null +++ b/tests/unit/test_static_handler.cpp @@ -0,0 +1,201 @@ +#include "../../includes/handler.hpp" +#include +#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; +} + +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()); + if (!out) { + std::cerr << "writeFile: cannot open " << path << std::endl; + std::exit(1); + } + out << body; +} + +// --- 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"); +} + +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"); +} + +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); + 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"); +} + +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"; + char* dir = mkdtemp(tmpl); + 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"); + + ::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(); + test_unknown_ext_is_octet_stream(); + test_traversal_is_403(); + test_dotdot_within_root_resolves(); + 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; +}