4949#include < iostream>
5050#include < memory>
5151#include < mutex>
52+ #include < optional>
5253#include < regex>
5354#include < set>
5455#include < shared_mutex>
5556#include < stdexcept>
5657#include < string>
5758#include < string_view>
59+ #include < tuple>
5860#include < utility>
5961#include < vector>
6062
6163#include " httpserver/constants.hpp"
6264#include " httpserver/create_webserver.hpp"
6365#include " httpserver/feature_unavailable.hpp"
66+ #include " httpserver/hook_context.hpp"
67+ #include " httpserver/hook_phase.hpp"
6468#include " httpserver/websocket_handler.hpp"
6569#include " httpserver/detail/http_endpoint.hpp"
6670#include " httpserver/detail/lambda_resource.hpp"
7276#include " httpserver/http_utils.hpp"
7377#include " httpserver/string_utilities.hpp"
7478#include " httpserver/detail/body.hpp"
79+ #include " httpserver/detail/connection_state.hpp"
7580
7681#ifdef HAVE_GNUTLS
7782#include < gnutls/gnutls.h>
@@ -90,6 +95,73 @@ using httpserver::http::http_utils;
9095using httpserver::http::ip_representation;
9196using httpserver::http::base_unescaper;
9297
98+ namespace {
99+
100+ // Convert MHD's POSIX sockaddr (AF_INET / AF_INET6) into the
101+ // libhttpserver-defined peer_address POD that the hook public API
102+ // exposes. Keeps every <sys/socket.h> reference inside this TU so the
103+ // public hook surface stays MHD-clean.
104+ //
105+ // On AF_INET, the four address bytes go into bytes[0..3] (the rest stay
106+ // zero); on AF_INET6, all sixteen bytes are copied. `port` is stored in
107+ // host byte order (ntohs). A null sockaddr (defensive — MHD does not
108+ // document this case but the lookup_v2 / connection_info paths can
109+ // return nullptr in edge cases) yields a zeroed peer_address with
110+ // family::unspec so the hook still observes the event with a
111+ // meaningful "unknown peer" signal.
112+ ::httpserver::peer_address make_peer_address (const struct sockaddr * addr) {
113+ ::httpserver::peer_address out{};
114+ if (addr == nullptr ) return out;
115+ if (addr->sa_family == AF_INET ) {
116+ const auto * sin = reinterpret_cast <const struct sockaddr_in *>(addr);
117+ out.fam = ::httpserver::peer_address::family::ipv4;
118+ std::memcpy (out.bytes .data (), &sin->sin_addr , 4 );
119+ out.port = ntohs (sin->sin_port );
120+ } else if (addr->sa_family == AF_INET6 ) {
121+ const auto * sin6 = reinterpret_cast <const struct sockaddr_in6 *>(addr);
122+ out.fam = ::httpserver::peer_address::family::ipv6;
123+ std::memcpy (out.bytes .data (), &sin6->sin6_addr , 16 );
124+ out.port = ntohs (sin6->sin6_port );
125+ }
126+ return out;
127+ }
128+
129+ // Reduce a {default_policy, is_denied, is_allowed} triple to the
130+ // (accepted, reason) decision exposed via accept_ctx. Extracted out of
131+ // policy_callback to keep that function under the CCN gate.
132+ //
133+ // Truth table:
134+ // default=ACCEPT, denied, !allowed -> reject "denied"
135+ // default=REJECT, denied -> reject "denied"
136+ // default=REJECT, !denied, !allowed-> reject "not-on-allow-list"
137+ // anything else -> accept
138+ // An allow-list entry always overrides a deny-list entry (allow wins).
139+ std::pair<bool , std::optional<std::string_view>>
140+ classify_decision (int default_policy, bool is_denied, bool is_allowed) {
141+ if (default_policy == ::httpserver::http::http_utils::ACCEPT
142+ && is_denied && !is_allowed) {
143+ return {false , std::string_view{" denied" }};
144+ }
145+ if (default_policy == ::httpserver::http::http_utils::REJECT ) {
146+ if (is_denied) return {false , std::string_view{" denied" }};
147+ if (!is_allowed) return {false , std::string_view{" not-on-allow-list" }};
148+ }
149+ return {true , std::nullopt };
150+ }
151+
152+ // Short-circuit gate for a single phase: returns true when at least one
153+ // hook is registered. Shared by connection_notify (via the hooks_armed
154+ // lambda which delegates to this) and policy_callback (which calls it
155+ // directly since the hooks_armed lambda is local to connection_notify).
156+ // Uses relaxed memory order: a false-negative on a very early concurrent
157+ // add is acceptable; the hook simply fires on the next event.
158+ inline bool is_phase_armed (const detail::webserver_impl* impl,
159+ ::httpserver::hook_phase p) noexcept {
160+ return impl != nullptr && impl->has_hooks_for (p);
161+ }
162+
163+ } // namespace
164+
93165
94166namespace detail {
95167
@@ -158,11 +230,128 @@ void webserver_impl::request_completed(void *cls, struct MHD_Connection *connect
158230 }
159231}
160232
161- // connection_notify (NOTIFY_STARTED / NOTIFY_CLOSED) and policy_callback
162- // live in webserver_callbacks_lifecycle.cpp. Both
163- // callbacks fire lifecycle hooks and reach into <sys/socket.h> via the
164- // peer-address adapter; isolating them keeps this TU under the project
165- // FILE_LOC_MAX gate.
233+ // connection_notify (NOTIFY_STARTED / NOTIFY_CLOSED) and policy_callback:
234+ // the per-connection lifecycle trampolines. Both fire lifecycle hooks and
235+ // reach into <sys/socket.h> via the peer-address adapter in the anonymous
236+ // namespace above. (Previously carved into webserver_callbacks_lifecycle.cpp
237+ // for the FILE_LOC_MAX gate; folded back once the SLOC metric stopped
238+ // counting single-character lines brought this TU comfortably under the
239+ // ceiling.)
240+
241+ void webserver_impl::connection_notify (void * cls, struct MHD_Connection * connection,
242+ void ** socket_context,
243+ enum MHD_ConnectionNotificationCode toe) {
244+ // cls is the owning webserver* (set in webserver_lifecycle.cpp at
245+ // MHD_OPTION_NOTIFY_CONNECTION). It MAY be null in tests that
246+ // exercise the callback without an enclosing webserver; defensive
247+ // null-check gates every hook fire on a non-null impl.
248+ auto * ws = static_cast <webserver*>(cls);
249+ // `ws_impl` names the owning webserver's impl explicitly, distinguishing
250+ // it from the static trampoline's implicit `this` (which is the
251+ // webserver_impl on which connection_notify is declared, but the
252+ // trampoline pattern means `this` is never used here directly).
253+ webserver_impl* ws_impl = (ws != nullptr ) ? ws->impl_ .get () : nullptr ;
254+
255+ // Resolve the peer address from MHD via the live MHD_Connection*.
256+ // The connection is valid in both the STARTED and CLOSED branches
257+ // (MHD tears it down only after NOTIFY_CLOSED returns).
258+ auto resolve_peer = [&]() -> ::httpserver::peer_address {
259+ if (connection == nullptr ) return {};
260+ const MHD_ConnectionInfo* ci = MHD_get_connection_info (
261+ connection, MHD_CONNECTION_INFO_CLIENT_ADDRESS );
262+ if (ci == nullptr ) return {};
263+ return make_peer_address (ci->client_addr );
264+ };
265+
266+ // Short-circuit predicate: delegates to the file-scope is_phase_armed
267+ // free function so that both connection_notify and policy_callback use
268+ // the same idiom without duplicating the cast/load expression.
269+ auto hooks_armed = [&ws_impl](::httpserver::hook_phase p) -> bool {
270+ return is_phase_armed (ws_impl, p);
271+ };
272+
273+ switch (toe) {
274+ case MHD_CONNECTION_NOTIFY_STARTED : {
275+ // Allocate the per-connection state (and its embedded arena)
276+ // on connection start. The new is the only heap allocation
277+ // tied to a connection's lifetime; afterwards every request
278+ // on this connection draws its impl out of the arena.
279+ auto * cs = new detail::connection_state ();
280+ // Copy the per-request args DoS limits from the owning
281+ // webserver so populate_args() can size the
282+ // arguments_accumulator from the socket_context. 0 means
283+ // "use the compile-time defaults" -- see connection_state.hpp.
284+ if (ws != nullptr ) {
285+ cs->max_args_count = ws->config .max_args_count ;
286+ cs->max_args_bytes = ws->config .max_args_bytes ;
287+ }
288+ *socket_context = cs;
289+ // Fire connection_opened. Zero-cost when no hook is
290+ // registered: a single relaxed atomic load + branch.
291+ if (hooks_armed (::httpserver::hook_phase::connection_opened)) {
292+ ::httpserver::connection_open_ctx ctx{resolve_peer ()};
293+ ws_impl->hooks_dispatch_ .fire_connection_opened (ctx);
294+ }
295+ break ;
296+ }
297+ case MHD_CONNECTION_NOTIFY_CLOSED :
298+ // Fire connection_closed BEFORE the per-connection state is
299+ // deleted. The arena is not exposed through
300+ // connection_close_ctx today (only peer_address), but the
301+ // ordering choice is safe regardless and pins the contract.
302+ if (hooks_armed (::httpserver::hook_phase::connection_closed)) {
303+ ::httpserver::connection_close_ctx ctx{resolve_peer ()};
304+ ws_impl->hooks_dispatch_ .fire_connection_closed (ctx);
305+ }
306+ // MHD ordering guarantee: NOTIFY_COMPLETED fires before
307+ // NOTIFY_CLOSED for the same connection. By the time we reach
308+ // this branch, request_completed has already called reset_arena()
309+ // and the modded_request has already been deleted -- so the
310+ // connection_state is no longer referenced by any live object.
311+ // (Documents the invariant that prevents the concurrent
312+ // request_completed + NOTIFY_CLOSED race described in CWE-362.)
313+ delete static_cast <detail::connection_state*>(*socket_context);
314+ *socket_context = nullptr ;
315+ break ;
316+ }
317+ }
318+
319+ MHD_Result webserver_impl::policy_callback (void *cls, const struct sockaddr * addr, socklen_t addrlen) {
320+ // Parameter needed to respect MHD interface, but not needed here.
321+ std::ignore = addrlen;
322+
323+ const auto ws = static_cast <webserver*>(cls);
324+ auto * impl = ws->impl_ .get ();
325+
326+ // Compute the accept/reject decision (and its reason) up front,
327+ // then fire `accept_decision` strictly AFTER the decision is fixed
328+ // in `decision`. A throwing hook lands in fire_accept_decision's
329+ // catch and cannot change `decision` — this is a structural
330+ // guarantee, not a runtime check.
331+ bool accepted = true ;
332+ std::optional<std::string_view> reason{};
333+
334+ if (ws->config .ip_access_control_enabled ) {
335+ // Consistent deny/allow snapshot under the acl's own locks, released
336+ // before the user hook fires.
337+ const auto membership = impl->acl_ .classify (ip_representation (addr));
338+ std::tie (accepted, reason) =
339+ classify_decision (ws->config .default_policy ,
340+ membership.denied , membership.allowed );
341+ }
342+
343+ const MHD_Result decision = accepted ? MHD_YES : MHD_NO ;
344+
345+ // Fire the hook strictly after `decision` is fixed. The relaxed
346+ // atomic gate keeps zero-cost-when-unused.
347+ if (is_phase_armed (impl, ::httpserver::hook_phase::accept_decision)) {
348+ ::httpserver::accept_ctx ctx{
349+ make_peer_address (addr), accepted, reason};
350+ impl->hooks_dispatch_ .fire_accept_decision (ctx);
351+ }
352+
353+ return decision;
354+ }
166355
167356#ifdef HAVE_GNUTLS
168357// MHD_PskServerCredentialsCallback signature:
0 commit comments