Skip to content

Commit 647feee

Browse files
etrclaude
andcommitted
TASK-064: fix partial-mutation in do_set_cookie and add semicolon guard to validate_attr_param
Two behavioral fixes driven by TDD: 1. Partial-mutation bug (code-simplifier-iter1-2): do_set_cookie now builds and validates the structured cookie first (with_name / with_value throw on invalid chars), then mirrors into the legacy cookies_ map only on success. Previously, insert_or_assign ran before with_name, leaving a dirty entry in the legacy map when with_name threw. Test: legacy_with_cookie_bad_key_leaves_maps_clean. 2. Attribute-injection guard (security-reviewer-iter1-1): validate_attr_param (used by with_domain and with_path) now rejects semicolons in addition to CR/LF/NUL. A semicolon in Domain or Path would be emitted verbatim by the renderer, injecting synthetic attributes into the Set-Cookie header (CWE-113). Tests: with_path_rejects_semicolon, with_domain_rejects_semicolon. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2172190 commit 647feee

4 files changed

Lines changed: 66 additions & 18 deletions

File tree

src/cookie.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ void validate_attr_param(std::string_view setter, std::string_view v) {
8686
": attribute value contains forbidden control character "
8787
"(CR, LF, or NUL)");
8888
}
89+
// CWE-113: a semicolon in a Domain or Path attribute value would
90+
// be emitted verbatim by the renderer, injecting synthetic
91+
// attributes into the Set-Cookie header (e.g. with_path("/; Secure")
92+
// produces 'name=val; Path=/; Secure' even though the caller only
93+
// meant to set a path).
94+
if (v.find(';') != std::string_view::npos) {
95+
throw std::invalid_argument(
96+
std::string(setter) +
97+
": attribute value contains forbidden character (';')");
98+
}
8999
}
90100

91101
// IMF-fixdate formatter (RFC 7231 §7.1.1.1):

src/http_response.cpp

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -215,24 +215,24 @@ void http_response::do_set_footer(std::string key, std::string value) {
215215
}
216216

217217
void http_response::do_set_cookie(std::string key, std::string value) {
218-
// Legacy v1 string-blob entry point. Validates with the v1
219-
// CR/LF/NUL guard (same as before TASK-064), mirrors into the
220-
// legacy `cookies_` map for the deprecated `get_cookie`/
221-
// `get_cookies` accessors, AND appends a structured cookie so the
222-
// dispatch path has a single render source. Pre-TASK-064 callers
223-
// that put a `;` in the value silently injected attributes;
224-
// post-TASK-064 the structured renderer is the only render source
225-
// and it bans `;` in cookie values -- so we keep the v1
226-
// validate_http_field for the legacy shim's input check and
227-
// simply rely on the structured renderer to enforce the stricter
228-
// RFC 6265 rules on emit. (Action: a legacy caller passing `;` in
229-
// the value will see the validation throw at to_set_cookie_header
230-
// time -- that is the v2 promise the task asks for.)
231-
validate_http_field("with_cookie", key, value);
232-
cookies_.insert_or_assign(key, value);
233-
cookie c;
234-
c.with_name(std::move(key)).with_value(std::move(value));
235-
structured_cookies_.push_back(std::move(c));
218+
// Legacy v1 string-blob entry point. Build and validate the
219+
// structured cookie FIRST — with_name / with_value throw
220+
// std::invalid_argument for forbidden characters (CR, LF, NUL,
221+
// ';', '=' in names). Only after the structured object is
222+
// successfully constructed do we mirror name/value into the legacy
223+
// `cookies_` map so the two stores never diverge on failure.
224+
//
225+
// Note: validate_http_field is intentionally NOT called here. The
226+
// structured cookie::with_name / with_value setters enforce the
227+
// stricter RFC 6265 rules (rejecting ';' in both name and value,
228+
// among others) and give callers the correct error message.
229+
// Calling validate_http_field first would give an incorrect
230+
// earlier error citing 'forbidden control character' rather than
231+
// the real reason.
232+
cookie new_cookie;
233+
new_cookie.with_name(key).with_value(value); // throws before any map mutation
234+
cookies_.insert_or_assign(std::move(key), std::move(value));
235+
structured_cookies_.push_back(std::move(new_cookie));
236236
}
237237

238238
void http_response::do_set_cookie_struct(cookie c) {

test/unit/cookie_render_test.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ LT_BEGIN_AUTO_TEST(cookie_render_suite, with_path_rejects_crlf)
153153
LT_CHECK_EQ(threw, true);
154154
LT_END_AUTO_TEST(with_path_rejects_crlf)
155155

156+
LT_BEGIN_AUTO_TEST(cookie_render_suite, with_path_rejects_semicolon)
157+
// CWE-113: a semicolon in Path would inject synthetic attributes
158+
// into the Set-Cookie header.
159+
bool threw = false;
160+
try { cookie{}.with_path("/; Secure"); }
161+
catch (const std::invalid_argument&) { threw = true; }
162+
LT_CHECK_EQ(threw, true);
163+
LT_END_AUTO_TEST(with_path_rejects_semicolon)
164+
165+
LT_BEGIN_AUTO_TEST(cookie_render_suite, with_domain_rejects_semicolon)
166+
// CWE-113: a semicolon in Domain would inject synthetic attributes.
167+
bool threw = false;
168+
try { cookie{}.with_domain("example.com; Secure"); }
169+
catch (const std::invalid_argument&) { threw = true; }
170+
LT_CHECK_EQ(threw, true);
171+
LT_END_AUTO_TEST(with_domain_rejects_semicolon)
172+
156173
// ---------------- Cycle 4: to_set_cookie_header() rendering ----------------
157174

158175
LT_BEGIN_AUTO_TEST(cookie_render_suite, render_minimal_name_value)

test/unit/http_response_cookie_wire_test.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,27 @@ LT_BEGIN_AUTO_TEST(http_response_cookie_wire_suite,
220220
std::string("sid=abc; Secure; SameSite=Strict"));
221221
LT_END_AUTO_TEST(wire_renderer_emits_full_attribute_set)
222222

223+
// ---------------- Cycle 9: partial-mutation guard (TASK-064 fix) ----
224+
225+
LT_BEGIN_AUTO_TEST(http_response_cookie_wire_suite,
226+
legacy_with_cookie_bad_key_leaves_maps_clean)
227+
// Regression: if with_name throws (key contains ';'), the legacy
228+
// cookies_ map must NOT be mutated. Before the fix, insert_or_assign
229+
// ran before with_name, so the map got a dirty entry on failure.
230+
http_response r = http_response::string("body");
231+
bool threw = false;
232+
try {
233+
r.with_cookie("bad;key", "value");
234+
} catch (const std::invalid_argument&) {
235+
threw = true;
236+
}
237+
LT_CHECK_EQ(threw, true);
238+
// The bad key must NOT appear in the legacy map.
239+
LT_CHECK_EQ(r.get_cookie("bad;key"), std::string_view(""));
240+
// The structured vector must also be empty.
241+
LT_CHECK_EQ(r.get_cookies_parsed().empty(), true);
242+
LT_END_AUTO_TEST(legacy_with_cookie_bad_key_leaves_maps_clean)
243+
223244
LT_BEGIN_AUTO_TEST_ENV()
224245
AUTORUN_TESTS()
225246
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)