Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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)"
Expand Down
27 changes: 27 additions & 0 deletions includes/handler.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#pragma once

#include <string>
#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);
};
16 changes: 8 additions & 8 deletions kanban.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

---

Expand Down
158 changes: 158 additions & 0 deletions src/http/static_handler.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#include "../../includes/handler.hpp"
#include "../../includes/string_utils.hpp"
#include "../../includes/utils.hpp"
#include <sys/stat.h>
#include <unistd.h>
#include <vector>

// 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<std::string> 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 = "<html><head><title>" + toString(code) + " " + phrase
+ "</title></head><body><h1>" + toString(code) + " "
+ phrase + "</h1></body></html>\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
}
Loading
Loading