Skip to content

Commit 828006c

Browse files
etrclaude
andcommitted
TASK-008: review-pass fixes (security + performance iter1)
Applies fixes from the iter1 review pass on the detail::body hierarchy: file_body (CWE-367 / perf): - Open + fstat moved to constructor; size() is now accurate immediately. - Drops lseek(SEEK_END); materialize() uses st_size from fstat. Closes the TOCTOU window between size discovery and the fd handed to MHD_create_response_from_fd, and removes the side-effect on the fd's read position. - Adds destructor that closes fd_ only when MHD never took ownership (materialized_ stays false until from_fd returns non-null). deferred_body (CWE-476): - trampoline() guards against null cls and empty producer_ before invoking the std::function. MHD's callback path doesn't catch C++ exceptions, so a bad_function_call would terminate in MHD's IO thread; the guard returns MHD_CONTENT_READER_END_WITH_ERROR instead. - Constructor asserts producer_ is non-empty (debug-only precondition). Header docs: - file_body: documents path-canonicalisation contract (O_NOFOLLOW only blocks the final component) and fd ownership lifecycle. - iovec_body: documents the borrowed-pointer lifetime contract (iov_base buffers must outlive the MHD_Response*) and the heap allocation note from DR-005. - deferred_body: documents the std::function SBO caveat — capturing more than the implementation-defined threshold silently heap-allocates. Tests: - file_body_size_known_before_materialize: size() must be correct at construction (21 bytes for test_content), not only after materialize. - deferred_body_trampoline_null_cls_returns_error: trampoline with cls==nullptr returns MHD_CONTENT_READER_END_WITH_ERROR rather than dereferencing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13f0818 commit 828006c

3 files changed

Lines changed: 143 additions & 28 deletions

File tree

src/details/body.cpp

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -102,35 +102,54 @@ MHD_Response* string_body::materialize() {
102102
}
103103

104104
// ---------------------------------------------------------------------------
105-
// file_body — replicates v1 file_response::get_raw_response exactly.
105+
// file_body — opens the file and fstat's it at construction so size() is
106+
// accurate immediately. materialize() uses fstat's st_size; it never calls
107+
// lseek(), so the fd's read position remains at 0 when handed to
108+
// MHD_create_response_from_fd (security-reviewer-iter1-1 / CWE-367).
106109
// ---------------------------------------------------------------------------
107-
MHD_Response* file_body::materialize() {
110+
file_body::file_body(std::string path) noexcept
111+
: path_(std::move(path)) {
108112
#ifndef _WIN32
109-
int fd = ::open(path_.c_str(), O_RDONLY | O_NOFOLLOW);
113+
fd_ = ::open(path_.c_str(), O_RDONLY | O_NOFOLLOW);
110114
#else
111-
int fd = ::open(path_.c_str(), O_RDONLY);
115+
fd_ = ::open(path_.c_str(), O_RDONLY);
112116
#endif
113-
if (fd == -1) return nullptr;
117+
if (fd_ == -1) return;
114118

115119
struct stat sb;
116-
if (::fstat(fd, &sb) != 0 || !S_ISREG(sb.st_mode)) {
117-
::close(fd);
118-
return nullptr;
120+
if (::fstat(fd_, &sb) != 0 || !S_ISREG(sb.st_mode)) {
121+
::close(fd_);
122+
fd_ = -1;
123+
return;
119124
}
120125

121-
off_t size = ::lseek(fd, 0, SEEK_END);
122-
if (size == static_cast<off_t>(-1)) {
123-
::close(fd);
124-
return nullptr;
126+
// Use fstat's st_size directly — no lseek, no TOCTOU, no fd-position
127+
// side-effect (security-reviewer-iter1-1 / performance-reviewer-iter1-4).
128+
size_ = static_cast<std::size_t>(sb.st_size);
129+
}
130+
131+
file_body::~file_body() {
132+
// Close only if MHD never took ownership (materialized_ stays false until
133+
// MHD_create_response_from_fd returns non-null).
134+
if (!materialized_ && fd_ != -1) {
135+
::close(fd_);
125136
}
137+
}
126138

127-
if (size) {
128-
size_cached_ = static_cast<std::size_t>(size);
129-
return MHD_create_response_from_fd(
130-
static_cast<std::size_t>(size), fd);
139+
MHD_Response* file_body::materialize() {
140+
if (fd_ == -1) return nullptr;
141+
142+
if (size_) {
143+
MHD_Response* r = MHD_create_response_from_fd(size_, fd_);
144+
if (r != nullptr) {
145+
materialized_ = true; // MHD now owns fd_
146+
}
147+
return r;
131148
}
132-
::close(fd);
133-
size_cached_ = 0;
149+
// Zero-byte file: serve empty response without giving the fd to MHD.
150+
::close(fd_);
151+
fd_ = -1;
152+
materialized_ = true; // suppress ~file_body's close (already closed)
134153
return MHD_create_response_from_buffer(
135154
0, nullptr, MHD_RESPMEM_PERSISTENT);
136155
}
@@ -177,7 +196,14 @@ MHD_Response* pipe_body::materialize() {
177196
// ---------------------------------------------------------------------------
178197
ssize_t deferred_body::trampoline(void* cls, std::uint64_t pos,
179198
char* buf, std::size_t max) {
199+
// Guard against null cls or empty producer_ (security-reviewer-iter1-3 /
200+
// CWE-476). MHD's callback mechanism does not catch C++ exceptions, so
201+
// throwing std::bad_function_call here would call std::terminate().
202+
// Return MHD_CONTENT_READER_END_WITH_ERROR instead.
180203
auto* self = static_cast<deferred_body*>(cls);
204+
if (!self || !self->producer_) {
205+
return MHD_CONTENT_READER_END_WITH_ERROR;
206+
}
181207
return self->producer_(pos, buf, max);
182208
}
183209

src/httpserver/details/body.hpp

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#define SRC_HTTPSERVER_DETAILS_BODY_HPP_
3737

3838
#include <sys/types.h> // ssize_t
39+
#include <cassert>
3940
#include <cstddef>
4041
#include <cstdint>
4142
#include <functional>
@@ -116,24 +117,41 @@ class string_body final : public body {
116117
};
117118

118119
// ---------------------------------------------------------------------------
119-
// file_body — opens path on materialize(); returns nullptr if open or
120-
// fstat fails (matches v1 file_response::get_raw_response exactly).
121-
// size_cached_ is reserved for future use; size() currently returns it
122-
// untouched (set on materialize) so the value reflects the on-disk size
123-
// only after a successful materialise. This matches v1, which never
124-
// exposed a size accessor at all.
120+
// file_body — opens the file and runs fstat at construction so that:
121+
// * size() is accurate immediately (no need to call materialize() first)
122+
// * materialize() avoids the lseek TOCTOU race (security-reviewer-iter1-1):
123+
// st_size from fstat is used directly, the fd position is never changed
124+
// before being handed to MHD_create_response_from_fd.
125+
// * repeated open/fstat syscalls on re-materialize are eliminated
126+
// (performance-reviewer-iter1-2).
127+
//
128+
// Caller path contract (security-reviewer-iter1-2 / CWE-23):
129+
// path_ is assumed to be a validated, canonicalized path. O_NOFOLLOW
130+
// blocks the final component being a symlink, but intermediate components
131+
// are still followed. Callers supplying user-derived paths MUST canonicalize
132+
// them (e.g. realpath()) before constructing file_body.
133+
//
134+
// Ownership / lifecycle:
135+
// * If open or fstat fails at construction, fd_ == -1 and size_ == 0;
136+
// materialize() will return nullptr.
137+
// * If materialize() succeeds, MHD owns the fd (MHD_destroy_response closes
138+
// it). materialized_ is set to suppress ~file_body's close.
139+
// * If materialize() is never called, ~file_body closes fd_.
125140
// ---------------------------------------------------------------------------
126141
class file_body final : public body {
127142
public:
128-
explicit file_body(std::string path) noexcept : path_(std::move(path)) {}
143+
explicit file_body(std::string path) noexcept;
144+
~file_body() override;
129145

130146
body_kind kind() const noexcept override { return body_kind::file; }
131-
std::size_t size() const noexcept override { return size_cached_; }
147+
std::size_t size() const noexcept override { return size_; }
132148
MHD_Response* materialize() override;
133149

134150
private:
135151
std::string path_;
136-
std::size_t size_cached_ = 0;
152+
std::size_t size_ = 0;
153+
int fd_ = -1;
154+
bool materialized_ = false;
137155
};
138156

139157
// ---------------------------------------------------------------------------
@@ -145,6 +163,29 @@ class file_body final : public body {
145163
// total_size_ is computed once at construction so size() is O(1); MHD's
146164
// MHD_IoVec doesn't expose total length and the architecture-spec
147165
// size() contract is "logical body size in bytes".
166+
//
167+
// LIFETIME CONTRACT (security-reviewer-iter1-2 / CWE-416):
168+
// iovec_body owns the entries_ vector (the container), but the iov_base
169+
// pointers inside each iovec_entry are BORROWED — they point into
170+
// caller-owned buffers. After materialize() returns, libmicrohttpd holds
171+
// those borrowed pointers until MHD_destroy_response() is called.
172+
//
173+
// CALLERS MUST guarantee that all iov_base buffers (and the iovec_body
174+
// itself) outlive the MHD_Response* returned by materialize(). The
175+
// TASK-009/010 factory path enforces this by tying the iovec_body's
176+
// lifetime to http_response, and http_response must outlive the MHD
177+
// connection. Do NOT free the underlying buffer data before the
178+
// MHD_Response is destroyed.
179+
//
180+
// ALLOCATION NOTE (performance-reviewer-iter1-1):
181+
// std::vector unconditionally heap-allocates its backing store, so every
182+
// iovec_body construction performs one heap allocation. The SBO
183+
// static_assert only verifies that the vector control block (3 words)
184+
// fits in the 64-byte inline slot; the iovec_entry array itself lives on
185+
// the heap. This is intentional: iovec payloads are by definition
186+
// scatter lists of borrowed pointers, so a further small-vector
187+
// optimisation would only save one allocation while adding complexity.
188+
// Per DR-005 the heap fallback is accepted for iovec_body.
148189
// ---------------------------------------------------------------------------
149190
class iovec_body final : public body {
150191
public:
@@ -200,14 +241,39 @@ class pipe_body final : public body {
200241
// The trampoline is the C-callable wrapper MHD invokes; it dispatches
201242
// to producer_. Exposed publicly (static method) so unit tests can
202243
// drive it without a daemon.
244+
//
245+
// NULL GUARD (security-reviewer-iter1-3 / CWE-476):
246+
// trampoline() checks for null cls and empty producer_ before invoking
247+
// the callable. MHD's callback mechanism does not catch C++ exceptions;
248+
// a null-invoke would call std::terminate() in MHD's IO thread.
249+
// If cls is null or producer_ is empty, trampoline returns
250+
// MHD_CONTENT_READER_END_WITH_ERROR to signal an error to MHD.
251+
//
252+
// ALLOCATION NOTE (performance-reviewer-iter1-3):
253+
// std::function internally uses small-buffer optimisation (SBO), but
254+
// the SBO threshold is implementation-defined (typically 16-32 bytes on
255+
// libstdc++ / libc++). Lambdas that capture more than one pointer (e.g.
256+
// a user object reference plus a shared_ptr sentinel) will silently
257+
// heap-allocate inside std::function. The static_assert on
258+
// sizeof(deferred_body) only verifies that std::function's control
259+
// block fits in the 64-byte SBO buffer, NOT that the callable itself
260+
// is stored inline. Callers should minimise captures to stay within
261+
// std::function's internal SSO threshold if zero-allocation is required.
203262
// ---------------------------------------------------------------------------
204263
class deferred_body final : public body {
205264
public:
206265
using producer_type =
207266
std::function<ssize_t(std::uint64_t, char*, std::size_t)>;
208267

209268
explicit deferred_body(producer_type producer) noexcept
210-
: producer_(std::move(producer)) {}
269+
: producer_(std::move(producer)) {
270+
// Precondition: caller must not pass a null/empty callable.
271+
// An empty producer_ would cause trampoline() to return
272+
// MHD_CONTENT_READER_END_WITH_ERROR on every MHD read callback,
273+
// which is unlikely to be the caller's intent.
274+
assert(producer_ != nullptr &&
275+
"deferred_body: producer must not be empty");
276+
}
211277

212278
body_kind kind() const noexcept override { return body_kind::deferred; }
213279
std::size_t size() const noexcept override { return 0; } // size unknown

test/unit/body_test.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,18 @@ LT_BEGIN_AUTO_TEST(body_suite, file_body_kind_and_materialize_existing_file)
152152
MHD_destroy_response(r);
153153
LT_END_AUTO_TEST(file_body_kind_and_materialize_existing_file)
154154

155+
// security-reviewer-iter1-1 + performance-reviewer-iter1-2: file is opened and
156+
// stat'd at construction so size() is accurate before materialize() is called,
157+
// and materialize() uses fstat's st_size rather than lseek (no fd-position
158+
// side-effect, no TOCTOU window on the size).
159+
LT_BEGIN_AUTO_TEST(body_suite, file_body_size_known_before_materialize)
160+
// test_content is 21 bytes ("test content of file\n").
161+
httpserver::detail::file_body b("test_content");
162+
// size() must be non-zero and correct at construction time — the file is
163+
// opened and fstat'd in the constructor, not in materialize().
164+
LT_CHECK_EQ(b.size(), static_cast<std::size_t>(21));
165+
LT_END_AUTO_TEST(file_body_size_known_before_materialize)
166+
155167
LT_BEGIN_AUTO_TEST(body_suite, file_body_returns_null_on_missing_file)
156168
httpserver::detail::file_body b("/no/such/path/should/exist");
157169
// Mirrors v1 file_response::get_raw_response semantics.
@@ -248,6 +260,17 @@ LT_BEGIN_AUTO_TEST(body_suite, deferred_body_trampoline_invokes_stored_callable)
248260
LT_CHECK_EQ(out[1], 'i');
249261
LT_END_AUTO_TEST(deferred_body_trampoline_invokes_stored_callable)
250262

263+
// security-reviewer-iter1-3: trampoline must not invoke an empty/null
264+
// std::function — it should return MHD_CONTENT_READER_END_WITH_ERROR instead
265+
// of throwing std::bad_function_call (which would terminate in MHD's IO thread).
266+
LT_BEGIN_AUTO_TEST(body_suite, deferred_body_trampoline_null_cls_returns_error)
267+
// cls == nullptr: trampoline must guard against null self pointer.
268+
char out[16] = {};
269+
ssize_t n = httpserver::detail::deferred_body::trampoline(
270+
nullptr, 0, out, sizeof(out));
271+
LT_CHECK_EQ(n, static_cast<ssize_t>(MHD_CONTENT_READER_END_WITH_ERROR));
272+
LT_END_AUTO_TEST(deferred_body_trampoline_null_cls_returns_error)
273+
251274
LT_BEGIN_AUTO_TEST(body_suite, deferred_body_destructor_releases_callable)
252275
auto sentinel = std::make_shared<int>(42);
253276
std::weak_ptr<int> w = sentinel;

0 commit comments

Comments
 (0)