Skip to content

Commit 4a033c0

Browse files
etrclaude
andcommitted
TASK-085: residual test-smell sweep (auth fail-closed + smartptr fragility)
Item 2 (auth_handler swallow-and-pass): DECIDED fail-closed. A throwing auth_handler now short-circuits with 500 instead of silently passing the request through to the resource (a security fail-open, CWE-703). Fixed via an auth-local try/catch guard in install_auth_alias_() that logs and returns respond_with(500), matching the documented §5.2 / DR-009 contract. Test renamed throwing_handler_is_swallowed_and_request_passes -> throwing_auth_handler_fails_closed_with_500; contract documented in specs/architecture/04-components/hooks.md. Item 4 (smartptr parallel-runner fragility): fixed at the root. Replaced fixed ports with create_webserver(0) + ws.get_bound_port(), and replaced the shared static counted_resource::dtor_count with a per-test local std::atomic<int> passed by pointer. Removed the fragility comments and the set_up() reset. The TU is now parallel-runner safe. Items 1 (Makefile.am XFAIL comment) and 3 (alias-slot test): already satisfied by TASK-061 and TASK-066 respectively, per TASK-085's overlap clauses; verified and dropped, no edits needed. Full unit/integ suite 106/106 PASS. Pre-existing top-level check-doxygen baseline failure (7 @security/@ref warnings in untouched headers) is out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rkuh4aSmrD8m2f2vYqakb6
1 parent 60a1bbb commit 4a033c0

6 files changed

Lines changed: 98 additions & 57 deletions

File tree

specs/architecture/04-components/hooks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ hook_handle http_resource::add_hook(hook_phase, std::function<...>);
9999
100100
**Alias mutability.** All v1 alias setters are **construction-time-only**. Their backing storage is wired during `create_webserver` → `webserver` construction and not mutated afterward. Two aliases (`log_access`, `internal_error_handler`) occupy dedicated single-slot members on `webserver_impl` (`log_access_alias_`, `handler_exception_alias_`); the other three (`auth_handler`, `method_not_allowed_handler`, `not_found_handler`) are seated into the regular per-phase hook vectors via `add_hook(...).detach()` at construction. In neither case is the slot mutable after `webserver::start()`. Users who need runtime registration or replacement of an extension point should use the hook bus directly: `webserver::add_hook(phase, callable)` returns a `hook_handle` that supports `remove()`. This is a deliberate v2.0 design choice (DR-012 / TASK-066): the hook bus IS the runtime extension surface; aliases are documented construction-time sugar. The semantics are pinned by `log_access_alias_is_immutable_after_construction` and `handler_exception_alias_is_immutable_after_construction` in `test/unit/hooks_log_access_alias_slot_test.cpp`.
101101
102+
**`auth_handler` is fail-closed on exception.** The generic short-circuit firing path (`fire_short_circuit_hooks_for_phase`) catches an exception thrown by a `before_handler`/`request_received`/`body_chunk` hook, logs it, and treats it as `pass()` — i.e. the chain continues. The `auth_handler` alias is the one deliberate exception to that policy: because the auth seat is a security boundary, letting a throwing auth callable fall through to the resource would be a fail-open on authentication (CWE-703). The alias therefore wraps its own callable invocation in a local fail-closed guard: an exception is caught, logged via `log_dispatch_error`, and short-circuits the request with a **500** instead of `pass()`. This matches the §5.2 / DR-009 contract that "an exception thrown by a hook is routed through the same path as a throwing resource handler" (which terminates in a 500). Pinned by `throwing_auth_handler_fails_closed_with_500` in `test/unit/auth_handler_optional_signature_test.cpp` (TASK-085). A user `auth_handler` that wants a custom non-500 rejection on its own error path must catch internally and return an engaged `std::optional<http_response>`.
103+
102104
**Related requirements:** PRD-HOOK-REQ-001..009.
103105
**Related decisions:** DR-012, §5.6.
104106

specs/tasks/M7-v2-cleanup/TASK-085.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
A cluster of small, independent test smells that the audit groups under "Other test smells". Each is too small to be its own task but together they erode confidence in the test contract.
99

1010
**Action Items:**
11-
- [ ] `test/Makefile.am:67-74`: orphaned comment claiming `header_hygiene` is in `XFAIL_TESTS`; lines 532-535 admit it was removed when TASK-020 landed. Update the comment. (Mechanical — overlaps with TASK-061; do whichever lands first and drop from the other.)
12-
- [ ] `test/unit/auth_handler_optional_signature_test.cpp:176-192`: `throwing_handler_is_swallowed_and_request_passes` ratifies a questionable swallow-and-pass behaviour as the pin. Decide: is this the intended contract? If yes, update the test name to make the contract explicit (e.g., `auth_handler_exception_is_logged_and_request_proceeds_without_auth`) and document in `specs/architecture/` why. If no, change the behaviour to fail-closed (reject the request with 500 or equivalent) and update the test accordingly.
13-
- [ ] `test/unit/hooks_log_access_alias_slot_test.cpp:166-205`: "second registration replaces first" only simulates re-registration via two webservers. Once TASK-066 ships the runtime setter (or pins the immutable-after-start contract), update this test to exercise the real path.
14-
- [ ] `test/unit/webserver_register_smartptr_test.cpp:60-64, 148-156`: documented parallel-runner fragility. Diagnose root cause (most likely a shared static or filesystem path collision) and fix, or convert the affected sub-tests to serial-only with an explicit littletest annotation.
11+
- [x] `test/Makefile.am:67-74`: orphaned comment claiming `header_hygiene` is in `XFAIL_TESTS`; lines 532-535 admit it was removed when TASK-020 landed. Update the comment. (Mechanical — overlaps with TASK-061; do whichever lands first and drop from the other.)**Satisfied by TASK-061** (status Done; lines 67-74 are now the cookie-test block, the accurate historical XFAIL note lives at the `header_hygiene_SOURCES` block ~103-115). No orphaned comment remains; dropped per the overlap clause.
12+
- [x] `test/unit/auth_handler_optional_signature_test.cpp:176-192`: `throwing_handler_is_swallowed_and_request_passes` ratifies a questionable swallow-and-pass behaviour as the pin. Decide: is this the intended contract? If yes, update the test name to make the contract explicit (e.g., `auth_handler_exception_is_logged_and_request_proceeds_without_auth`) and document in `specs/architecture/` why. If no, change the behaviour to fail-closed (reject the request with 500 or equivalent) and update the test accordingly. — **DECIDED: fail-closed.** A throwing auth handler now returns 500 (auth-local guard in `src/detail/webserver_aliases.cpp`); test renamed to `throwing_auth_handler_fails_closed_with_500`; contract documented in `specs/architecture/04-components/hooks.md` ("`auth_handler` is fail-closed on exception"), removing the spec/impl contradiction with §5.2/DR-009.
13+
- [x] `test/unit/hooks_log_access_alias_slot_test.cpp:166-205`: "second registration replaces first" only simulates re-registration via two webservers. Once TASK-066 ships the runtime setter (or pins the immutable-after-start contract), update this test to exercise the real path. — **Satisfied by TASK-066** (status Done). TASK-066 chose option (b): aliases are immutable-after-construction, NO runtime setter. The misleading test was replaced by `log_access_alias_is_immutable_after_construction` + `handler_exception_alias_is_immutable_after_construction`, which exercise the real (immutability) contract.
14+
- [x] `test/unit/webserver_register_smartptr_test.cpp:60-64, 148-156`: documented parallel-runner fragility. Diagnose root cause (most likely a shared static or filesystem path collision) and fix, or convert the affected sub-tests to serial-only with an explicit littletest annotation. — **Fixed (root cause).** Replaced fixed ports with `create_webserver(0)` + `ws.get_bound_port()` (no cross-test port collision) and replaced the shared static `counted_resource::dtor_count` with a per-test local `std::atomic<int>` passed by pointer (no cross-test contamination). Fragility comments and the `set_up()` reset removed.
1515

1616
**Dependencies:**
1717
- Blocked by: TASK-066 (for the alias-slot test work)
@@ -26,4 +26,4 @@ A cluster of small, independent test smells that the audit groups under "Other t
2626
**Related Requirements:** PRD §2 test reliability NFR
2727
**Related Decisions:** None new
2828

29-
**Status:** Backlog
29+
**Status:** Done

specs/tasks/M7-v2-cleanup/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ TASK-093).
4949
| TASK-082 | Tighten static-size bounds in `http_resource_test` and `webserver_pimpl_test` | MED | S | Done |
5050
| TASK-083 | Wire real CI gates into benchmarks | MED | M | Done |
5151
| TASK-084 | Re-measure libstdc++/Linux v1 baseline for `get_headers` ns/call | MED | S | Done |
52-
| TASK-085 | Residual test-smell sweep | MED | S | Backlog |
52+
| TASK-085 | Residual test-smell sweep | MED | S | Done |
5353
| TASK-086 | Execute file-size split roadmap (FILE_LOC_MAX 750 → 500) | HIGH | L | Backlog |
5454
| TASK-087 | Restore msan CI lane | HIGH | M | Backlog |
5555
| TASK-088 | Re-enable helgrind / drd / sgcheck in valgrind CI lane | HIGH | M | Backlog |

src/detail/webserver_aliases.cpp

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
#include "httpserver/detail/webserver_impl.hpp"
5757

5858
#include <cassert>
59+
#include <exception>
5960
#include <functional>
6061
#include <memory>
6162
#include <optional>
@@ -227,7 +228,32 @@ void webserver::install_auth_alias_() {
227228
// (one heap alloc removed per request that runs through
228229
// this hook), and small responses ride the http_response
229230
// SBO with zero further allocs.
230-
auto rejection = ws_ptr->auth_handler(*ctx.request);
231+
// Fail-closed (TASK-085): the auth seat is a security
232+
// boundary, so a throwing auth callable must NOT fall
233+
// through to the resource. The generic short-circuit
234+
// firing path (fire_short_circuit_hooks_for_phase) treats
235+
// a throwing hook as pass(), which would be a security
236+
// fail-open here (CWE-703). Wrap the callable in a local
237+
// try/catch that short-circuits with a 500 instead,
238+
// matching the documented §5.2 / DR-009 contract that an
239+
// exception thrown by a hook is routed through the same
240+
// path as a throwing resource handler (which yields 500).
241+
std::optional<http_response> rejection;
242+
try {
243+
rejection = ws_ptr->auth_handler(*ctx.request);
244+
} catch (const std::exception& e) {
245+
impl_ptr->log_dispatch_error(
246+
std::string("auth_handler threw: ").append(e.what())
247+
.append("; failing closed with 500"));
248+
return hook_action::respond_with(
249+
http_response::empty().with_status(500));
250+
} catch (...) {
251+
impl_ptr->log_dispatch_error(
252+
"auth_handler threw unknown exception; "
253+
"failing closed with 500");
254+
return hook_action::respond_with(
255+
http_response::empty().with_status(500));
256+
}
231257
if (!rejection) {
232258
return hook_action::pass();
233259
}

test/unit/auth_handler_optional_signature_test.cpp

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -165,16 +165,21 @@ LT_BEGIN_AUTO_TEST(auth_handler_optional_signature_suite,
165165
ws.stop();
166166
LT_END_AUTO_TEST(engaged_optional_rejects_request)
167167

168-
// 3. Auth handler throwing a std::runtime_error: pin the observable
169-
// contract for error paths through the hook-invocation machinery.
170-
// The current dispatch path swallows the exception and lets the
171-
// request progress (equivalent to nullopt). The hook framework's
172-
// handler-exception slot is the named extension point for converting
173-
// an exception into a custom response; the alias hook itself stays
174-
// non-fatal so a buggy auth callable cannot DoS the whole server.
175-
// A future change that short-circuits with 500 must update this pin.
168+
// 3. Auth handler throwing a std::exception: the auth alias is
169+
// FAIL-CLOSED (TASK-085). A throwing auth callable must NOT let the
170+
// request through to the resource — that would be a security
171+
// fail-open on the authentication boundary (CWE-703). Instead the
172+
// alias hook catches the exception, logs it, and short-circuits with
173+
// a 500, matching the documented §5.2 / DR-009 contract that an
174+
// exception thrown by a hook is "routed through the same path as a
175+
// throwing resource handler" (which terminates in a 500). The
176+
// contrast with the generic short-circuit phases (which still treat
177+
// a throwing hook as pass()) is deliberate: the auth seat is the one
178+
// place where fail-open is most dangerous, so the alias wraps its own
179+
// callable invocation in a fail-closed guard. See the auth_handler
180+
// row in specs/architecture/04-components/hooks.md.
176181
LT_BEGIN_AUTO_TEST(auth_handler_optional_signature_suite,
177-
throwing_handler_is_swallowed_and_request_passes)
182+
throwing_auth_handler_fails_closed_with_500)
178183
webserver ws{create_webserver(PORT_3)
179184
.auth_handler([](const http_request&)
180185
-> std::optional<http_response> {
@@ -185,11 +190,12 @@ LT_BEGIN_AUTO_TEST(auth_handler_optional_signature_suite,
185190
ws.start(false);
186191

187192
fetch_result fr = fetch("localhost:" PORT_3_STRING "/protected");
188-
LT_CHECK_EQ(fr.response_code, 200);
189-
LT_CHECK_EQ(fr.body, std::string("SUCCESS"));
193+
// Fail-closed: the request must NOT reach the resource.
194+
LT_CHECK_EQ(fr.response_code, 500);
195+
LT_CHECK(fr.body != std::string("SUCCESS"));
190196

191197
ws.stop();
192-
LT_END_AUTO_TEST(throwing_handler_is_swallowed_and_request_passes)
198+
LT_END_AUTO_TEST(throwing_auth_handler_fails_closed_with_500)
193199

194200
// 4. Engaged optional carrying a 64 KB body arrives uncorrupted on the
195201
// wire. Regression target for the MOVE from the optional into

test/unit/webserver_register_smartptr_test.cpp

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,16 @@ using httpserver::http_resource;
5555
using httpserver::http_response;
5656
using httpserver::webserver;
5757

58-
// PORT and the PORT+N offsets below are fixed numeric values shared by all
59-
// tests in this TU. The tests run sequentially (single-process), so the
60-
// offsets (PORT+0 through PORT+5) do not conflict with each other.
61-
// If a future parallel test runner binds the same ports concurrently, switch
62-
// to create_webserver(0) and retrieve the OS-assigned port via
63-
// ws.get_bound_port() after ws.start().
64-
#define PORT 8080
58+
// TASK-085: this TU is parallel-runner safe.
59+
// - The one test that starts a server and does a curl round-trip
60+
// (unique_ptr_overload_compiles_and_serves) binds an OS-assigned
61+
// ephemeral port via create_webserver(0) and reads it back with
62+
// ws.get_bound_port() — no fixed port, no cross-test collision.
63+
// - Every other test constructs create_webserver(0) but never calls
64+
// start(), so no port is ever bound; the ephemeral 0 is harmless.
65+
// - The destructor-counting tests use a per-test local std::atomic<int>
66+
// passed to counted_resource by pointer, not a shared static, so two
67+
// tests running concurrently cannot contaminate each other's count.
6568

6669
namespace {
6770

@@ -77,21 +80,24 @@ class ok_resource : public http_resource {
7780
}
7881
};
7982

80-
// Resource whose destructor increments a static counter, so tests can
81-
// observe ownership-driven destruction.
83+
// Resource whose destructor increments a caller-supplied counter, so
84+
// tests can observe ownership-driven destruction. The counter is a
85+
// per-test local std::atomic<int> passed by pointer (TASK-085) rather
86+
// than a shared static, so concurrent tests cannot contaminate each
87+
// other's count under a parallel runner.
8288
class counted_resource : public http_resource {
8389
public:
84-
static std::atomic<int> dtor_count;
85-
86-
counted_resource() = default;
87-
~counted_resource() override { ++dtor_count; }
90+
explicit counted_resource(std::atomic<int>* counter)
91+
: counter_(counter) {}
92+
~counted_resource() override { ++(*counter_); }
8893

8994
http_response render_get(const http_request&) override {
9095
return http_response::string("OK");
9196
}
92-
};
9397

94-
std::atomic<int> counted_resource::dtor_count{0};
98+
private:
99+
std::atomic<int>* counter_;
100+
};
95101

96102
} // namespace
97103

@@ -145,15 +151,9 @@ static_assert(!has_raw_register_resource<webserver>::value,
145151
// ---- Runtime ownership tests ------------------------------------------
146152

147153
LT_BEGIN_SUITE(webserver_register_smartptr_suite)
148-
void set_up() {
149-
// Reset the static dtor_count before every test so tests do not
150-
// accumulate each other's destructor calls. This relies on the
151-
// current runner executing tests sequentially; if a parallel
152-
// runner is ever introduced, replace dtor_count with a per-test
153-
// local atomic passed by reference.
154-
counted_resource::dtor_count = 0;
155-
}
156-
154+
// No shared mutable state to reset: the destructor-counting tests
155+
// each own a local std::atomic<int> (TASK-085).
156+
void set_up() {}
157157
void tear_down() {}
158158
LT_END_SUITE(webserver_register_smartptr_suite)
159159

@@ -168,15 +168,19 @@ LT_END_SUITE(webserver_register_smartptr_suite)
168168
// explicit "compiles AND serves" requirement.
169169
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
170170
unique_ptr_overload_compiles_and_serves)
171-
webserver ws{create_webserver(PORT)};
171+
webserver ws{create_webserver(0)};
172172
auto r = std::make_unique<ok_resource>();
173173
ws.register_resource("/foo", std::move(r));
174174
ws.start(false);
175+
// OS-assigned ephemeral port: no fixed port to collide under a
176+
// parallel runner (TASK-085).
177+
const std::string url =
178+
"localhost:" + std::to_string(ws.get_bound_port()) + "/foo";
175179

176180
curl_global_init(CURL_GLOBAL_ALL);
177181
std::string s;
178182
CURL* curl = curl_easy_init();
179-
curl_easy_setopt(curl, CURLOPT_URL, "localhost:8080/foo");
183+
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
180184
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
181185
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
182186
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
@@ -192,35 +196,38 @@ LT_END_AUTO_TEST(unique_ptr_overload_compiles_and_serves)
192196
// when the webserver is destroyed."
193197
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
194198
unique_ptr_dtor_runs_on_webserver_destruction)
195-
LT_CHECK_EQ(counted_resource::dtor_count.load(), 0);
199+
std::atomic<int> dtor_count{0};
200+
LT_CHECK_EQ(dtor_count.load(), 0);
196201
{
197-
webserver ws{create_webserver(PORT + 1)};
198-
ws.register_resource("/x", std::make_unique<counted_resource>());
202+
webserver ws{create_webserver(0)};
203+
ws.register_resource(
204+
"/x", std::make_unique<counted_resource>(&dtor_count));
199205
// No start/stop needed — registration alone must transfer
200206
// ownership; webserver destruction must run the dtor.
201207
}
202-
LT_CHECK_EQ(counted_resource::dtor_count.load(), 1);
208+
LT_CHECK_EQ(dtor_count.load(), 1);
203209
LT_END_AUTO_TEST(unique_ptr_dtor_runs_on_webserver_destruction)
204210

205211
// shared_ptr semantics: caller's shared_ptr keeps the resource alive
206212
// past webserver destruction. The dtor runs only once both refs drop.
207213
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
208214
shared_ptr_caller_keeps_resource_alive)
209-
LT_CHECK_EQ(counted_resource::dtor_count.load(), 0);
210-
auto sp = std::make_shared<counted_resource>();
215+
std::atomic<int> dtor_count{0};
216+
LT_CHECK_EQ(dtor_count.load(), 0);
217+
auto sp = std::make_shared<counted_resource>(&dtor_count);
211218
{
212-
webserver ws{create_webserver(PORT + 2)};
219+
webserver ws{create_webserver(0)};
213220
ws.register_resource("/x", sp);
214221
}
215222
// Webserver destroyed; caller still holds a ref.
216-
LT_CHECK_EQ(counted_resource::dtor_count.load(), 0);
223+
LT_CHECK_EQ(dtor_count.load(), 0);
217224
sp.reset();
218-
LT_CHECK_EQ(counted_resource::dtor_count.load(), 1);
225+
LT_CHECK_EQ(dtor_count.load(), 1);
219226
LT_END_AUTO_TEST(shared_ptr_caller_keeps_resource_alive)
220227

221228
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
222229
null_unique_ptr_throws)
223-
webserver ws{create_webserver(PORT + 3)};
230+
webserver ws{create_webserver(0)};
224231
bool caught_invalid_argument = false;
225232
try {
226233
ws.register_resource("/x", std::unique_ptr<http_resource>{});
@@ -234,7 +241,7 @@ LT_END_AUTO_TEST(null_unique_ptr_throws)
234241

235242
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
236243
null_shared_ptr_throws)
237-
webserver ws{create_webserver(PORT + 4)};
244+
webserver ws{create_webserver(0)};
238245
bool caught_invalid_argument = false;
239246
try {
240247
ws.register_resource("/x", std::shared_ptr<http_resource>{});
@@ -249,7 +256,7 @@ LT_END_AUTO_TEST(null_shared_ptr_throws)
249256
// throw-on-null behavior.
250257
LT_BEGIN_AUTO_TEST(webserver_register_smartptr_suite,
251258
duplicate_registration_throws)
252-
webserver ws{create_webserver(PORT + 5)};
259+
webserver ws{create_webserver(0)};
253260
ws.register_resource("/dup", std::make_shared<ok_resource>());
254261
bool caught_invalid_argument = false;
255262
try {

0 commit comments

Comments
 (0)