@@ -437,12 +437,32 @@ size_t CurlClient::HeaderCallback(char const* buffer,
437437 // End-of-headers terminator: emit and reset.
438438 context->response (std::move (context->current_response ));
439439 context->current_response = http::response_header<>{};
440+ // Strip the line terminator. Allow bare LF or bare CR per RFC 9112 §2.2;
441+ // libcurl preserves the original wire bytes for HTTP/1.x (only HTTP/2
442+ // synthesizes CRLF), so a non-compliant origin can deliver bare LF here.
443+ while (!line.empty () &&
444+ (line.back () == ' \r ' || line.back () == ' \n ' )) {
445+ line.remove_suffix (1 );
446+ }
447+
448+ if (line.empty ()) {
449+ // Terminator. If we're between responses (e.g., the line ends a
450+ // chunked-transfer trailer block), there's nothing to emit.
451+ if (context->reading_headers ) {
452+ context->response (std::move (context->current_response ));
453+ context->current_response = http::response_header<>{};
454+ context->reading_headers = false ;
455+ }
440456 return total_size;
441457 }
442458
443459 if (line.substr (0 , 5 ) == " HTTP/" ) {
444- // Status line: "HTTP/X.Y CODE REASON". Start a fresh response.
445- // Status line: "HTTP/X.Y CODE REASON". Start a fresh response.
460+ // Status line: "HTTP/X.Y CODE REASON". Only legitimate before any
461+ // header has been seen for this response — an interior HTTP/ line
462+ // would otherwise wipe accumulated state.
463+ if (context->reading_headers ) {
464+ return total_size;
465+ }
446466 // Beast default-constructs result_ to status::ok (200); reset to 0
447467 // so an unparseable status line surfaces as result_int() == 0.
448468 context->current_response = http::response_header<>{};
@@ -452,10 +472,19 @@ size_t CurlClient::HeaderCallback(char const* buffer,
452472 unsigned code = 0 ;
453473 auto const result = std::from_chars (
454474 line.data () + code_start + 1 , line.data () + line.size (), code);
455- if (result.ec == std::errc{} && code != 0 ) {
475+ // Three-digit status per RFC 7231 §6; the tight bound avoids
476+ // result(unsigned) throwing across the libcurl C frame.
477+ if (result.ec == std::errc{} && code >= 100 && code <= 999 ) {
456478 context->current_response .result (code);
457479 }
458480 }
481+ context->reading_headers = true ;
482+ return total_size;
483+ }
484+
485+ if (!context->reading_headers ) {
486+ // Header line received outside an active response — chunked trailer
487+ // or protocol-level junk. Ignore.
459488 return total_size;
460489 }
461490
@@ -472,7 +501,9 @@ size_t CurlClient::HeaderCallback(char const* buffer,
472501 (value.back () == ' ' || value.back () == ' \t ' )) {
473502 value.remove_suffix (1 );
474503 }
475- context->current_response .set (std::string (name), std::string (value));
504+ // insert() preserves duplicate-name headers (Set-Cookie, Via, …);
505+ // set() would collapse them and diverge from the Foxy backend.
506+ context->current_response .insert (std::string (name), std::string (value));
476507
477508 if (beast::iequals (name, " Content-Type" ) &&
478509 value.find (" text/event-stream" ) == std::string_view::npos) {
0 commit comments