Add server_mount_fuzzer for static file serving (#2666)

Fuzz Server::handle_file_request through a mount point backed by a temporary directory that holds symlinks escaping the mount root, one of them into a sibling directory sharing its name prefix. Abort if a token stored outside the root appears in a response. The seeds are based on those of #2661.

Move FuzzedStream into fuzzed_stream.h so that both server fuzzers share it, and add FuzzableServer::MarkAsRunning(), without which a server that is not listening writes no file.
This commit is contained in:
yhirose authored and GitHub committed 2026-11-12 21:52:33 -05:00
1 parent 53a7f4964e
commit 546e821c65
17 files changed
+272 -78

No files matched your search

+7 -2
View File
@@ -13,14 +13,15 @@ ZLIB_SUPPORT = -DCPPHTTPLIB_ZLIB_SUPPORT -lz
BROTLI_DIR = /usr/local/opt/brotli BROTLI_DIR = /usr/local/opt/brotli
# BROTLI_SUPPORT = -DCPPHTTPLIB_BROTLI_SUPPORT -I$(BROTLI_DIR)/include -L$(BROTLI_DIR)/lib -lbrotlicommon -lbrotlienc -lbrotlidec # BROTLI_SUPPORT = -DCPPHTTPLIB_BROTLI_SUPPORT -I$(BROTLI_DIR)/include -L$(BROTLI_DIR)/lib -lbrotlicommon -lbrotlienc -lbrotlidec
FUZZERS = server_fuzzer url_parser_fuzzer header_parser_fuzzer client_fuzzer multipart_parser_fuzzer websocket_fuzzer sse_parser_fuzzer FUZZERS = server_fuzzer url_parser_fuzzer header_parser_fuzzer client_fuzzer multipart_parser_fuzzer websocket_fuzzer sse_parser_fuzzer server_mount_fuzzer
# Runs all the tests and also fuzz tests against seed corpus. # Runs all the tests and also fuzz tests against seed corpus.
all : $(FUZZERS) all : $(FUZZERS)
./server_fuzzer corpus/* ./server_fuzzer corpus/*
./server_mount_fuzzer corpus_mount/*
# Fuzz target, so that you can choose which $(LIB_FUZZING_ENGINE) to use. # Fuzz target, so that you can choose which $(LIB_FUZZING_ENGINE) to use.
server_fuzzer : server_fuzzer.cc ../../httplib.h server_fuzzer : server_fuzzer.cc fuzzed_stream.h ../../httplib.h
# $(CXX) $(CXXFLAGS) -o $@ $< -Wl,-Bstatic $(OPENSSL_SUPPORT) -Wl,-Bdynamic -ldl $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread # $(CXX) $(CXXFLAGS) -o $@ $< -Wl,-Bstatic $(OPENSSL_SUPPORT) -Wl,-Bdynamic -ldl $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl $(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
zip -q -r server_fuzzer_seed_corpus.zip corpus zip -q -r server_fuzzer_seed_corpus.zip corpus
@@ -44,5 +45,9 @@ websocket_fuzzer : websocket_fuzzer.cc ../../httplib.h
sse_parser_fuzzer : sse_parser_fuzzer.cc ../../httplib.h sse_parser_fuzzer : sse_parser_fuzzer.cc ../../httplib.h
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl $(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
server_mount_fuzzer : server_mount_fuzzer.cc fuzzed_stream.h ../../httplib.h
$(CXX) $(CXXFLAGS) -o $@ $< $(ZLIB_SUPPORT) $(LIB_FUZZING_ENGINE) -pthread -lanl
zip -q -r server_mount_fuzzer_seed_corpus.zip corpus_mount
clean: clean:
rm -f server_fuzzer pem *.0 *.o *.1 *.srl *.zip rm -f server_fuzzer pem *.0 *.o *.1 *.srl *.zip
+3
View File
@@ -0,0 +1,3 @@
HEAD /mnt/a.txt HTTP/1.1
Host: x
+4
View File
@@ -0,0 +1,4 @@
GET /mnt/a.txt HTTP/1.1
Host: x
If-Modified-Since: Mon, 01 Jan 2024 00:00:00 GMT
+3
View File
@@ -0,0 +1,3 @@
GET /mnt/index.html HTTP/1.1
Host: x
+4
View File
@@ -0,0 +1,4 @@
GET /mnt/index.html HTTP/1.1
Host: x
If-None-Match: W/"bogusetag"
+3
View File
@@ -0,0 +1,3 @@
GET /mnt/link-in.txt HTTP/1.1
Host: x
+3
View File
@@ -0,0 +1,3 @@
GET /mnt/link-out.txt HTTP/1.1
Host: x
@@ -0,0 +1,3 @@
GET /mnt/link-sibling.txt HTTP/1.1
Host: x
+3
View File
@@ -0,0 +1,3 @@
GET /mnt/missing.txt HTTP/1.1
Host: x
+4
View File
@@ -0,0 +1,4 @@
GET /mnt/a.txt HTTP/1.1
Host: x
Range: bytes=0-3
+3
View File
@@ -0,0 +1,3 @@
GET /mnt/sub/ HTTP/1.1
Host: x
@@ -0,0 +1,3 @@
GET /mnt/../../../../etc/passwd HTTP/1.1
Host: x
@@ -0,0 +1,3 @@
GET /mnt/%2e%2e/secret.txt HTTP/1.1
Host: x
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include <cstdint>
#include <httplib.h>
class FuzzedStream : public httplib::Stream {
public:
FuzzedStream(const uint8_t *data, size_t size)
: data_(data), size_(size), read_pos_(0) {}
ssize_t read(char *ptr, size_t size) override {
if (size + read_pos_ > size_) { size = size_ - read_pos_; }
memcpy(ptr, data_ + read_pos_, size);
read_pos_ += size;
// Like SocketStream, pull in more than was asked for, which the line
// reader then takes through buffered_data(), so both of its paths run.
buffered_end_ =
(std::max)(buffered_end_, (std::min)(size_, read_pos_ + 4096));
return static_cast<ssize_t>(size);
}
const char *buffered_data(size_t &size) const override {
size = buffered_end_ > read_pos_ ? buffered_end_ - read_pos_ : 0;
return size ? reinterpret_cast<const char *>(data_ + read_pos_) : nullptr;
}
void consume_buffered(size_t size) override { read_pos_ += size; }
ssize_t write(const char *ptr, size_t size) override {
response_.append(ptr, size);
return static_cast<int>(size);
}
ssize_t write(const char *ptr) { return write(ptr, strlen(ptr)); }
ssize_t write(const std::string &s) { return write(s.data(), s.size()); }
bool is_readable() const override { return true; }
bool wait_readable() const override { return true; }
bool wait_writable() const override { return true; }
void get_remote_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 8080;
}
void get_local_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 8080;
}
socket_t socket() const override { return 0; }
time_t duration() const override { return 0; };
const std::string &response() const { return response_; }
private:
const uint8_t *data_;
size_t size_;
size_t read_pos_;
size_t buffered_end_ = 0;
std::string response_;
};
class FuzzableServer : public httplib::Server {
public:
// A server that is not listening counts as shutting down, and then writes
// no file. The socket is never used.
void MarkAsRunning() { svr_sock_ = 0; }
void ProcessFuzzedRequest(FuzzedStream &stream) {
bool connection_close = false;
process_request(stream,
/*remote_addr=*/"",
/*remote_port =*/0,
/*local_addr=*/"",
/*local_port =*/0,
/*last_connection=*/false, connection_close, nullptr);
}
};
+1 -76
View File
@@ -1,79 +1,4 @@
#include <cstdint> #include "fuzzed_stream.h"
#include <httplib.h>
class FuzzedStream : public httplib::Stream {
public:
FuzzedStream(const uint8_t *data, size_t size)
: data_(data), size_(size), read_pos_(0) {}
ssize_t read(char *ptr, size_t size) override {
if (size + read_pos_ > size_) { size = size_ - read_pos_; }
memcpy(ptr, data_ + read_pos_, size);
read_pos_ += size;
// Like SocketStream, pull in more than was asked for, which the line
// reader then takes through buffered_data(), so both of its paths run.
buffered_end_ =
(std::max)(buffered_end_, (std::min)(size_, read_pos_ + 4096));
return static_cast<ssize_t>(size);
}
const char *buffered_data(size_t &size) const override {
size = buffered_end_ > read_pos_ ? buffered_end_ - read_pos_ : 0;
return size ? reinterpret_cast<const char *>(data_ + read_pos_) : nullptr;
}
void consume_buffered(size_t size) override { read_pos_ += size; }
ssize_t write(const char *ptr, size_t size) override {
response_.append(ptr, size);
return static_cast<int>(size);
}
ssize_t write(const char *ptr) { return write(ptr, strlen(ptr)); }
ssize_t write(const std::string &s) { return write(s.data(), s.size()); }
bool is_readable() const override { return true; }
bool wait_readable() const override { return true; }
bool wait_writable() const override { return true; }
void get_remote_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 8080;
}
void get_local_ip_and_port(std::string &ip, int &port) const override {
ip = "127.0.0.1";
port = 8080;
}
socket_t socket() const override { return 0; }
time_t duration() const override { return 0; };
private:
const uint8_t *data_;
size_t size_;
size_t read_pos_;
size_t buffered_end_ = 0;
std::string response_;
};
class FuzzableServer : public httplib::Server {
public:
void ProcessFuzzedRequest(FuzzedStream &stream) {
bool connection_close = false;
process_request(stream,
/*remote_addr=*/"",
/*remote_port =*/0,
/*local_addr=*/"",
/*local_port =*/0,
/*last_connection=*/false, connection_close, nullptr);
}
};
static FuzzableServer g_server; static FuzzableServer g_server;
+117
View File
@@ -0,0 +1,117 @@
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <random>
#include <string>
#include <vector>
#include <sys/stat.h>
#include <unistd.h>
#include <utime.h>
#include "fuzzed_stream.h"
// Fixture layout, with <token> unknown to the fuzzer:
// root/mnt/{index.html,a.txt,data.bin,sub/index.html} served at /mnt
// root/mnt/link-in.txt -> a.txt (stays inside, may be served)
// root/mnt/link-out.txt -> ../outside.txt (escapes, must be refused)
// root/mnt/link-sibling.txt -> ../mnt_secret/secret.txt (shares the "mnt"
// prefix, must be refused)
// The response must never contain <token>.
namespace {
FuzzableServer g_server;
std::string g_root;
std::string g_token;
std::vector<std::string> g_paths; // In creation order
void check(bool ok, const char *what) {
if (!ok) {
fprintf(stderr, "fixture setup failed: %s\n", what);
abort();
}
}
void make_dir(const std::string &path) {
check(mkdir(path.c_str(), 0755) == 0, path.c_str());
g_paths.push_back(path);
}
void make_file(const std::string &path, const std::string &content) {
{
std::ofstream out(path, std::ios::binary);
out << content;
check(out.good(), path.c_str());
}
// Fixed, so that conditional requests behave the same in every run
utimbuf times = {1700000000, 1700000000};
check(utime(path.c_str(), &times) == 0, path.c_str());
g_paths.push_back(path);
}
void make_link(const std::string &target, const std::string &path) {
check(symlink(target.c_str(), path.c_str()) == 0, path.c_str());
g_paths.push_back(path);
}
std::string serve(const uint8_t *data, size_t size) {
FuzzedStream stream{data, size};
g_server.ProcessFuzzedRequest(stream);
return stream.response();
}
void check_served(const char *path) {
auto req = std::string("GET ") + path + " HTTP/1.1\r\nHost: x\r\n\r\n";
auto res = serve(reinterpret_cast<const uint8_t *>(req.data()), req.size());
// Without this, a fuzzer that serves nothing would pass its check forever
check(res.find("abcdef") != std::string::npos, path);
}
void remove_fixture() {
for (auto it = g_paths.rbegin(); it != g_paths.rend(); ++it) {
if (unlink(it->c_str()) != 0) { rmdir(it->c_str()); }
}
rmdir(g_root.c_str());
}
} // namespace
extern "C" int LLVMFuzzerInitialize(int * /*argc*/, char *** /*argv*/) {
std::random_device rd;
char token[33];
snprintf(token, sizeof(token), "%08x%08x%08x%08x", rd(), rd(), rd(), rd());
g_token = token;
auto tmpdir = getenv("TMPDIR");
auto tmpl = std::string(tmpdir && *tmpdir ? tmpdir : "/tmp") +
"/httplib-mount-XXXXXX";
check(mkdtemp(&tmpl[0]) != nullptr, tmpl.c_str());
g_root = tmpl;
atexit(remove_fixture);
auto mnt = g_root + "/mnt";
make_dir(mnt);
make_dir(mnt + "/sub");
make_dir(g_root + "/mnt_secret");
make_file(mnt + "/index.html", "<html>index</html>");
make_file(mnt + "/a.txt", "abcdef");
make_file(mnt + "/data.bin", std::string("\x01\x02\x03\x04\x05\x06", 6));
make_file(mnt + "/sub/index.html", "<html>sub</html>");
make_file(g_root + "/outside.txt", g_token);
make_file(g_root + "/mnt_secret/secret.txt", g_token);
make_link("a.txt", mnt + "/link-in.txt");
make_link("../outside.txt", mnt + "/link-out.txt");
make_link("../mnt_secret/secret.txt", mnt + "/link-sibling.txt");
g_server.MarkAsRunning();
check(g_server.set_mount_point("/mnt", mnt), "set_mount_point");
check_served("/mnt/a.txt");
check_served("/mnt/link-in.txt");
return 0;
}
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (serve(data, size).find(g_token) != std::string::npos) { abort(); }
return 0;
}
+24
View File
@@ -0,0 +1,24 @@
"GET"
"HEAD"
"HTTP/1.1"
"/mnt/"
"/mnt/index.html"
"/mnt/a.txt"
"/mnt/data.bin"
"/mnt/sub/"
"/mnt/link-in.txt"
"/mnt/link-out.txt"
"/mnt/link-sibling.txt"
".."
"%2e%2e"
"%2f"
"//"
"Range: bytes=0-"
"Range: bytes=0-3,5-"
"If-None-Match: "
"W/"
"If-Modified-Since: "
"If-Range: "
"Accept-Encoding: gzip"