Skip to content

Commit fc0a773

Browse files
etrclaude
andcommitted
TASK-012: review-pass fixes (security: header injection / simplify: & overload helper)
- Add CRLF/NUL validation to with_header, with_footer, with_cookie: reject key or value containing \r, \n, or \0 via std::invalid_argument (CWE-113 header injection, security-reviewer-iter1-1/2/3) - Add range validation to with_status: reject codes outside [100,599] per RFC 9110 §15 (security-reviewer-iter1-4) - Refactor 8 overload pairs to delegate to private do_set_* helpers, eliminating duplicated mutation logic (code-simplifier-iter1-1) - Add 16 new unit tests covering CRLF/NUL rejection and status bounds, following the TDD RED→GREEN cycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 81ab57a commit fc0a773

3 files changed

Lines changed: 274 additions & 15 deletions

File tree

src/http_response.cpp

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -192,53 +192,113 @@ void http_response::shoutCAST() {
192192
// -----------------------------------------------------------------------
193193
// Fluent with_* setters (TASK-012, PRD-RSP-REQ-004).
194194
//
195-
// Each setter has two ref-qualified overloads. The bodies are a one-line
196-
// mutation (insert_or_assign for the maps, plain assignment for status),
197-
// followed by `return *this` (& overload) or `return std::move(*this)`
198-
// (&& overload). insert_or_assign — rather than `m[k] = v` — is used so
199-
// the by-value `std::string` parameters can be moved into the map slot
200-
// directly, avoiding an extra string copy at the insertion site when
201-
// the caller hands the setter an rvalue.
195+
// Each setter has two ref-qualified overloads that delegate to a private
196+
// do_set_*() helper containing the validation + mutation logic. The
197+
// overloads differ only in their return statement: `& overload` returns
198+
// *this by lvalue reference; `&& overload` returns std::move(*this).
199+
// Centralising the mutation in a single helper means validation and
200+
// insert_or_assign are in exactly one place per setter, not duplicated
201+
// across every overload pair.
202+
//
203+
// Validation (security, TASK-012 review-pass):
204+
// * with_header / with_footer: reject key or value containing CR,
205+
// LF, or NUL — these characters can split an HTTP response and
206+
// inject additional headers (CWE-113).
207+
// * with_cookie: same CRLF/NUL rejection on name and value.
208+
// * with_status: code must be in [100, 599] per RFC 9110 §15.
209+
//
210+
// insert_or_assign — rather than `m[k] = v` — is used so the by-value
211+
// `std::string` parameters can be moved into the map slot directly.
202212
// -----------------------------------------------------------------------
213+
214+
// Shared forbidden-character set for header/footer/cookie field names
215+
// and values. The string_view spans all three bytes including the
216+
// embedded NUL.
217+
namespace {
218+
constexpr std::string_view kForbiddenFieldChars("\r\n\0", 3);
219+
220+
void validate_header_field(std::string_view context,
221+
std::string_view key,
222+
std::string_view value) {
223+
if (key.find_first_of(kForbiddenFieldChars) != std::string_view::npos) {
224+
throw std::invalid_argument(
225+
std::string(context) +
226+
": key contains forbidden control character (CR, LF, or NUL)");
227+
}
228+
if (value.find_first_of(kForbiddenFieldChars) != std::string_view::npos) {
229+
throw std::invalid_argument(
230+
std::string(context) +
231+
": value contains forbidden control character (CR, LF, or NUL)");
232+
}
233+
}
234+
} // namespace
235+
236+
void http_response::do_set_header(std::string key, std::string value) {
237+
validate_header_field("with_header", key, value);
238+
headers_.insert_or_assign(std::move(key), std::move(value));
239+
}
240+
241+
void http_response::do_set_footer(std::string key, std::string value) {
242+
validate_header_field("with_footer", key, value);
243+
footers_.insert_or_assign(std::move(key), std::move(value));
244+
}
245+
246+
void http_response::do_set_cookie(std::string key, std::string value) {
247+
validate_header_field("with_cookie", key, value);
248+
cookies_.insert_or_assign(std::move(key), std::move(value));
249+
}
250+
251+
void http_response::do_set_status(int code) {
252+
if (code < 100 || code > 599) {
253+
throw std::invalid_argument(
254+
"with_status: HTTP status code out of range [100, 599]");
255+
}
256+
status_code_ = code;
257+
}
258+
203259
http_response& http_response::with_header(std::string key,
204260
std::string value) & {
205-
headers_.insert_or_assign(std::move(key), std::move(value));
261+
do_set_header(std::move(key), std::move(value));
206262
return *this;
207263
}
264+
208265
http_response&& http_response::with_header(std::string key,
209266
std::string value) && {
210-
headers_.insert_or_assign(std::move(key), std::move(value));
267+
do_set_header(std::move(key), std::move(value));
211268
return std::move(*this);
212269
}
213270

214271
http_response& http_response::with_footer(std::string key,
215272
std::string value) & {
216-
footers_.insert_or_assign(std::move(key), std::move(value));
273+
do_set_footer(std::move(key), std::move(value));
217274
return *this;
218275
}
276+
219277
http_response&& http_response::with_footer(std::string key,
220278
std::string value) && {
221-
footers_.insert_or_assign(std::move(key), std::move(value));
279+
do_set_footer(std::move(key), std::move(value));
222280
return std::move(*this);
223281
}
224282

225283
http_response& http_response::with_cookie(std::string key,
226284
std::string value) & {
227-
cookies_.insert_or_assign(std::move(key), std::move(value));
285+
do_set_cookie(std::move(key), std::move(value));
228286
return *this;
229287
}
288+
230289
http_response&& http_response::with_cookie(std::string key,
231290
std::string value) && {
232-
cookies_.insert_or_assign(std::move(key), std::move(value));
291+
do_set_cookie(std::move(key), std::move(value));
233292
return std::move(*this);
234293
}
235294

236295
http_response& http_response::with_status(int code) & {
237-
status_code_ = code;
296+
do_set_status(code);
238297
return *this;
239298
}
299+
240300
http_response&& http_response::with_status(int code) && {
241-
status_code_ = code;
301+
do_set_status(code);
242302
return std::move(*this);
243303
}
244304

src/httpserver/http_response.hpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,16 @@ class http_response {
353353
void destroy_body() noexcept;
354354
void adopt_body_from(http_response& o) noexcept;
355355

356+
// Shared mutation helpers for the fluent setters (TASK-012
357+
// review-pass). Each helper validates its inputs, then performs the
358+
// map mutation or scalar assignment. Centralising the logic here
359+
// means the & and && overloads only differ in their return
360+
// statement; the mutation + validation is in exactly one place.
361+
void do_set_header(std::string key, std::string value);
362+
void do_set_footer(std::string key, std::string value);
363+
void do_set_cookie(std::string key, std::string value);
364+
void do_set_status(int code);
365+
356366
// Placement-new a concrete detail::body subclass into the SBO
357367
// buffer (or, if T does not fit, onto the heap via the matched
358368
// ::operator new(sizeof(T))/::operator delete pairing the

test/unit/http_response_test.cpp

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,195 @@ LT_BEGIN_AUTO_TEST(http_response_suite, with_header_moves_string_args)
601601
std::string_view(std::string(64, 'v')));
602602
LT_END_AUTO_TEST(with_header_moves_string_args)
603603

604+
// -----------------------------------------------------------------------
605+
// TASK-012 review-pass: security validation on fluent setters.
606+
//
607+
// with_header, with_footer, with_cookie must reject keys/values that
608+
// contain CR (\r), LF (\n), or NUL (\0) — these characters allow
609+
// HTTP response-header injection (CWE-113). with_status must reject
610+
// codes outside [100, 599] per RFC 9110 §15.
611+
// -----------------------------------------------------------------------
612+
613+
LT_BEGIN_AUTO_TEST(http_response_suite, with_header_rejects_crlf_in_value)
614+
http_response resp = http_response::string("body");
615+
bool threw = false;
616+
try {
617+
resp.with_header("X-Foo", "bar\r\nSet-Cookie: evil=1");
618+
} catch (const std::invalid_argument&) {
619+
threw = true;
620+
}
621+
LT_CHECK_EQ(threw, true);
622+
LT_END_AUTO_TEST(with_header_rejects_crlf_in_value)
623+
624+
LT_BEGIN_AUTO_TEST(http_response_suite, with_header_rejects_lf_in_value)
625+
http_response resp = http_response::string("body");
626+
bool threw = false;
627+
try {
628+
resp.with_header("X-Foo", "bar\ninjected");
629+
} catch (const std::invalid_argument&) {
630+
threw = true;
631+
}
632+
LT_CHECK_EQ(threw, true);
633+
LT_END_AUTO_TEST(with_header_rejects_lf_in_value)
634+
635+
LT_BEGIN_AUTO_TEST(http_response_suite, with_header_rejects_nul_in_value)
636+
http_response resp = http_response::string("body");
637+
bool threw = false;
638+
try {
639+
resp.with_header("X-Foo", std::string("bar\0baz", 7));
640+
} catch (const std::invalid_argument&) {
641+
threw = true;
642+
}
643+
LT_CHECK_EQ(threw, true);
644+
LT_END_AUTO_TEST(with_header_rejects_nul_in_value)
645+
646+
LT_BEGIN_AUTO_TEST(http_response_suite, with_header_rejects_crlf_in_key)
647+
http_response resp = http_response::string("body");
648+
bool threw = false;
649+
try {
650+
resp.with_header("X-Foo\r\nEvil", "value");
651+
} catch (const std::invalid_argument&) {
652+
threw = true;
653+
}
654+
LT_CHECK_EQ(threw, true);
655+
LT_END_AUTO_TEST(with_header_rejects_crlf_in_key)
656+
657+
LT_BEGIN_AUTO_TEST(http_response_suite, with_footer_rejects_crlf_in_value)
658+
http_response resp = http_response::string("body");
659+
bool threw = false;
660+
try {
661+
resp.with_footer("X-Footer", "val\r\ninjected");
662+
} catch (const std::invalid_argument&) {
663+
threw = true;
664+
}
665+
LT_CHECK_EQ(threw, true);
666+
LT_END_AUTO_TEST(with_footer_rejects_crlf_in_value)
667+
668+
LT_BEGIN_AUTO_TEST(http_response_suite, with_footer_rejects_lf_in_key)
669+
http_response resp = http_response::string("body");
670+
bool threw = false;
671+
try {
672+
resp.with_footer("X-Footer\nEvil", "value");
673+
} catch (const std::invalid_argument&) {
674+
threw = true;
675+
}
676+
LT_CHECK_EQ(threw, true);
677+
LT_END_AUTO_TEST(with_footer_rejects_lf_in_key)
678+
679+
LT_BEGIN_AUTO_TEST(http_response_suite, with_cookie_rejects_crlf_in_value)
680+
http_response resp = http_response::string("body");
681+
bool threw = false;
682+
try {
683+
resp.with_cookie("sid", "abc\r\nSet-Cookie: evil=1");
684+
} catch (const std::invalid_argument&) {
685+
threw = true;
686+
}
687+
LT_CHECK_EQ(threw, true);
688+
LT_END_AUTO_TEST(with_cookie_rejects_crlf_in_value)
689+
690+
LT_BEGIN_AUTO_TEST(http_response_suite, with_cookie_rejects_lf_in_name)
691+
http_response resp = http_response::string("body");
692+
bool threw = false;
693+
try {
694+
resp.with_cookie("sid\nevil", "value");
695+
} catch (const std::invalid_argument&) {
696+
threw = true;
697+
}
698+
LT_CHECK_EQ(threw, true);
699+
LT_END_AUTO_TEST(with_cookie_rejects_lf_in_name)
700+
701+
LT_BEGIN_AUTO_TEST(http_response_suite, with_cookie_rejects_nul_in_value)
702+
http_response resp = http_response::string("body");
703+
bool threw = false;
704+
try {
705+
resp.with_cookie("sid", std::string("abc\0def", 7));
706+
} catch (const std::invalid_argument&) {
707+
threw = true;
708+
}
709+
LT_CHECK_EQ(threw, true);
710+
LT_END_AUTO_TEST(with_cookie_rejects_nul_in_value)
711+
712+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_rejects_below_100)
713+
http_response resp = http_response::string("body");
714+
bool threw = false;
715+
try {
716+
resp.with_status(99);
717+
} catch (const std::invalid_argument&) {
718+
threw = true;
719+
}
720+
LT_CHECK_EQ(threw, true);
721+
LT_END_AUTO_TEST(with_status_rejects_below_100)
722+
723+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_rejects_above_599)
724+
http_response resp = http_response::string("body");
725+
bool threw = false;
726+
try {
727+
resp.with_status(600);
728+
} catch (const std::invalid_argument&) {
729+
threw = true;
730+
}
731+
LT_CHECK_EQ(threw, true);
732+
LT_END_AUTO_TEST(with_status_rejects_above_599)
733+
734+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_rejects_negative)
735+
http_response resp = http_response::string("body");
736+
bool threw = false;
737+
try {
738+
resp.with_status(-1);
739+
} catch (const std::invalid_argument&) {
740+
threw = true;
741+
}
742+
LT_CHECK_EQ(threw, true);
743+
LT_END_AUTO_TEST(with_status_rejects_negative)
744+
745+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_rejects_zero)
746+
http_response resp = http_response::string("body");
747+
bool threw = false;
748+
try {
749+
resp.with_status(0);
750+
} catch (const std::invalid_argument&) {
751+
threw = true;
752+
}
753+
LT_CHECK_EQ(threw, true);
754+
LT_END_AUTO_TEST(with_status_rejects_zero)
755+
756+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_accepts_boundary_100)
757+
http_response resp = http_response::string("body");
758+
bool threw = false;
759+
try {
760+
resp.with_status(100);
761+
} catch (const std::invalid_argument&) {
762+
threw = true;
763+
}
764+
LT_CHECK_EQ(threw, false);
765+
LT_CHECK_EQ(resp.get_status(), 100);
766+
LT_END_AUTO_TEST(with_status_accepts_boundary_100)
767+
768+
LT_BEGIN_AUTO_TEST(http_response_suite, with_status_accepts_boundary_599)
769+
http_response resp = http_response::string("body");
770+
bool threw = false;
771+
try {
772+
resp.with_status(599);
773+
} catch (const std::invalid_argument&) {
774+
threw = true;
775+
}
776+
LT_CHECK_EQ(threw, false);
777+
LT_CHECK_EQ(resp.get_status(), 599);
778+
LT_END_AUTO_TEST(with_status_accepts_boundary_599)
779+
780+
LT_BEGIN_AUTO_TEST(http_response_suite, with_header_accepts_valid_value)
781+
http_response resp = http_response::string("body");
782+
bool threw = false;
783+
try {
784+
resp.with_header("X-Foo", "valid value with spaces and colons: ok");
785+
} catch (const std::invalid_argument&) {
786+
threw = true;
787+
}
788+
LT_CHECK_EQ(threw, false);
789+
LT_CHECK_EQ(resp.get_header("X-Foo"),
790+
std::string_view("valid value with spaces and colons: ok"));
791+
LT_END_AUTO_TEST(with_header_accepts_valid_value)
792+
604793
LT_BEGIN_AUTO_TEST_ENV()
605794
AUTORUN_TESTS()
606795
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)