Add logging for various policy-controlled HTTP properties.

PiperOrigin-RevId: 972596088
diff --git a/build/source_list.bzl b/build/source_list.bzl
index f60d6dc..f85da00 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -1004,6 +1004,7 @@
     "balsa/framer_interface.h",
     "balsa/header_api.h",
     "balsa/header_properties.h",
+    "balsa/http_protocol_defects.h",
     "balsa/http_validation_policy.h",
     "balsa/noop_balsa_visitor.h",
     "balsa/simple_buffer.h",
diff --git a/build/source_list.gni b/build/source_list.gni
index 610ae26..84571da 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -1004,6 +1004,7 @@
     "src/quiche/balsa/framer_interface.h",
     "src/quiche/balsa/header_api.h",
     "src/quiche/balsa/header_properties.h",
+    "src/quiche/balsa/http_protocol_defects.h",
     "src/quiche/balsa/http_validation_policy.h",
     "src/quiche/balsa/noop_balsa_visitor.h",
     "src/quiche/balsa/simple_buffer.h",
diff --git a/build/source_list.json b/build/source_list.json
index 80c06ac..f504793 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -1003,6 +1003,7 @@
     "quiche/balsa/framer_interface.h",
     "quiche/balsa/header_api.h",
     "quiche/balsa/header_properties.h",
+    "quiche/balsa/http_protocol_defects.h",
     "quiche/balsa/http_validation_policy.h",
     "quiche/balsa/noop_balsa_visitor.h",
     "quiche/balsa/simple_buffer.h",
diff --git a/quiche/balsa/balsa_frame.cc b/quiche/balsa/balsa_frame.cc
index f2fdba4..8f0ac4d 100644
--- a/quiche/balsa/balsa_frame.cc
+++ b/quiche/balsa/balsa_frame.cc
@@ -394,6 +394,15 @@
                          http_validation_policy().sanitize_firstline_spaces,
                          has_multiple_spaces, has_cr_tab);
 
+  if (has_multiple_spaces) {
+    QUICHE_CODE_COUNT(multiple_spaces_in_firstline_detected);
+    protocol_defects_.multiple_spaces_in_firstline = true;
+  }
+  if (has_cr_tab) {
+    QUICHE_CODE_COUNT(tab_or_cr_found_in_firstline_detected);
+    protocol_defects_.tab_or_cr_found_in_firstline = true;
+  }
+
   if (!parse_success) {
     parse_state_ = BalsaFrameEnums::ERROR;
     HandleError(last_error_);
@@ -418,6 +427,10 @@
 
   if (is_request_) {
     const bool is_method_valid = header_properties::IsValidToken(part1);
+    if (!is_method_valid) {
+      QUICHE_CODE_COUNT(invalid_method_in_request_first_line_detected);
+      protocol_defects_.invalid_method_in_request_first_line = true;
+    }
     if (http_validation_policy().disallow_invalid_request_methods &&
         !is_method_valid) {
       QUICHE_CODE_COUNT(disallow_invalid_request_methods_enforced);
@@ -449,6 +462,8 @@
   if (headers_->parsed_response_code_ < 100 ||
       headers_->parsed_response_code_ > 599 || has_non_digit ||
       has_leading_zero) {
+    QUICHE_CODE_COUNT(invalid_response_code_detected);
+    protocol_defects_.invalid_response_code = true;
     if (http_validation_policy().disallow_invalid_response_codes) {
       QUICHE_CODE_COUNT(disallow_invalid_response_codes_enforced);
       parse_state_ = BalsaFrameEnums::ERROR;
@@ -552,6 +567,13 @@
       // continuation) and continuation is allowed.
       HandleWarning(is_trailer ? BalsaFrameEnums::OBS_FOLD_IN_TRAILERS
                                : BalsaFrameEnums::OBS_FOLD_IN_HEADERS);
+      if (is_trailer) {
+        QUICHE_CODE_COUNT(obs_fold_in_trailer_values_detected);
+        protocol_defects_.obs_fold_in_trailer_values = true;
+      } else {
+        QUICHE_CODE_COUNT(obs_fold_in_header_values_detected);
+        protocol_defects_.obs_fold_in_header_values = true;
+      }
       if (http_validation_policy().sanitize_obs_fold_in_header_values) {
         QUICHE_CODE_COUNT(sanitize_obs_fold_in_header_values_enforced);
         *has_continuation_lines = true;
@@ -585,6 +607,8 @@
     headers->header_lines_.back().has_continuation_line =
         header_has_continuation_line;
     if (current >= line_end) {
+      QUICHE_CODE_COUNT(header_missing_colon_detected);
+      protocol_defects_.header_missing_colon = true;
       if (http_validation_policy().require_header_colon) {
         QUICHE_CODE_COUNT(require_header_colon_enforced);
         HandleError(is_trailer ? BalsaFrameEnums::TRAILER_MISSING_COLON
@@ -610,6 +634,10 @@
         break;
       }
 
+      if (header_properties::IsInvalidHeaderKeyChar(c)) {
+        QUICHE_CODE_COUNT(header_name_contains_double_quote_detected);
+        protocol_defects_.header_name_contains_double_quote = true;
+      }
       // Generally invalid characters were found earlier.
       if (http_validation_policy().disallow_double_quote_in_header_name) {
         if (header_properties::IsInvalidHeaderKeyChar(c)) {
@@ -627,6 +655,8 @@
       }
 
       if (IsObsTextChar(c)) {
+        QUICHE_CODE_COUNT(obs_text_found_in_header_name_detected);
+        protocol_defects_.obs_text_found_in_header_name = true;
         if (http_validation_policy().disallow_obs_text_in_field_names) {
           QUICHE_CODE_COUNT(disallow_obs_text_in_field_names_enforced);
           HandleError(is_trailer
@@ -645,6 +675,8 @@
       // construct which is technically not allowed by the spec.
 
       // In strict mode, we do treat this invalid value-less key as an error.
+      QUICHE_CODE_COUNT(header_missing_colon_detected);
+      protocol_defects_.header_missing_colon = true;
       if (http_validation_policy().require_header_colon) {
         QUICHE_CODE_COUNT(require_header_colon_enforced);
         HandleError(is_trailer ? BalsaFrameEnums::TRAILER_MISSING_COLON
@@ -737,6 +769,7 @@
     headers_->transfer_encoding_is_chunked_ = false;
     return;
   }
+  protocol_defects_.unknown_transfer_encoding = true;
   if (http_validation_policy().validate_transfer_encoding) {
     HandleError(BalsaFrameEnums::UNKNOWN_TRANSFER_ENCODING);
   }
@@ -757,6 +790,8 @@
       return true;
     }
     if (*c == '\r' && c + 1 < stream_end && *(c + 1) != '\n') {
+      QUICHE_CODE_COUNT(lone_cr_in_request_headers_detected);
+      protocol_defects_.lone_cr_in_request_headers = true;
       if (http_validation_policy().disallow_lone_cr_in_request_headers) {
         QUICHE_CODE_COUNT(disallow_lone_cr_in_request_headers_enforced);
         return true;
@@ -860,6 +895,8 @@
       // Optionally, reject this per the RFC or simply keep one value.
       if (headers->content_length_status_ ==
           BalsaHeadersEnums::VALID_CONTENT_LENGTH) {
+        QUICHE_CODE_COUNT(multiple_content_length_keys_detected);
+        protocol_defects_.multiple_content_length_keys = true;
         if (http_validation_policy().disallow_multiple_content_length) {
           QUICHE_CODE_COUNT(disallow_multiple_content_length_enforced);
           HandleError(BalsaFrameEnums::MULTIPLE_CONTENT_LENGTH_KEYS);
@@ -870,6 +907,7 @@
     }
     if (absl::EqualsIgnoreCase(key, kTransferEncoding)) {
       if (transfer_encoding_idx != 0) {
+        protocol_defects_.multiple_transfer_encoding_keys = true;
         if (http_validation_policy().validate_transfer_encoding) {
           QUICHE_CODE_COUNT(multiple_transfer_encoding_keys_rejected);
           HandleError(BalsaFrameEnums::MULTIPLE_TRANSFER_ENCODING_KEYS);
@@ -888,6 +926,8 @@
     }
 
     if (content_length_idx != 0 && transfer_encoding_idx != 0) {
+      QUICHE_CODE_COUNT(transfer_encoding_and_content_length_detected);
+      protocol_defects_.transfer_encoding_and_content_length = true;
       if (http_validation_policy().validate_transfer_encoding &&
           http_validation_policy()
               .disallow_transfer_encoding_with_content_length) {
@@ -1381,6 +1421,17 @@
             HandleError(BalsaFrameEnums::INVALID_CHUNK_EXTENSION);
             return current - input;
           }
+          if (!found_semicolon || extension_is_empty) {
+            QUICHE_CODE_COUNT(missing_semicolon_in_chunk_extension_detected);
+          }
+          protocol_defects_.missing_semicolon_in_chunk_extension =
+              !found_semicolon || extension_is_empty;
+          if (found_semicolon && found_non_bws_before_semicolon) {
+            QUICHE_CODE_COUNT(
+                token_before_semicolon_in_chunk_extension_detected);
+          }
+          protocol_defects_.token_before_semicolon_in_chunk_extension =
+              found_semicolon && found_non_bws_before_semicolon;
         }
 
         chunk_length_character_extracted_ = false;
@@ -1440,6 +1491,8 @@
           // Right after the chunk should be a \r then a \n.
           if (c == '\r') {
             if (saw_slash_r_after_chunk_) {
+              QUICHE_CODE_COUNT(stray_data_after_chunk_detected);
+              protocol_defects_.stray_data_after_chunk = true;
               if (http_validation_policy().disallow_stray_data_after_chunk) {
                 QUICHE_CODE_COUNT(disallow_stray_data_after_chunk_enforced);
                 HandleError(BalsaFrameEnums::STRAY_DATA_AFTER_CHUNK);
@@ -1453,6 +1506,8 @@
             // Can't use last_char_was_slash_r_ because a \r might've been part
             // of the chunk data.
             if (!saw_slash_r_after_chunk_) {
+              QUICHE_CODE_COUNT(stray_data_after_chunk_detected);
+              protocol_defects_.stray_data_after_chunk = true;
               if (http_validation_policy().disallow_stray_data_after_chunk) {
                 QUICHE_CODE_COUNT(disallow_stray_data_after_chunk_enforced);
                 HandleError(BalsaFrameEnums::STRAY_DATA_AFTER_CHUNK);
@@ -1463,6 +1518,8 @@
             }
             break;
           } else {
+            QUICHE_CODE_COUNT(stray_data_after_chunk_detected);
+            protocol_defects_.stray_data_after_chunk = true;
             if (http_validation_policy().disallow_stray_data_after_chunk) {
               QUICHE_CODE_COUNT(disallow_stray_data_after_chunk_enforced);
               HandleError(BalsaFrameEnums::STRAY_DATA_AFTER_CHUNK);
@@ -1502,6 +1559,9 @@
             ++current;
 
             if (framing_found != kValidTerm1) {
+              QUICHE_CODE_COUNT(
+                  chunked_body_does_not_end_with_crlf_crlf_detected);
+              protocol_defects_.chunked_body_does_not_end_with_crlf_crlf = true;
               if (http_validation_policy()
                       .require_chunked_body_end_with_crlf_crlf) {
                 QUICHE_CODE_COUNT(
diff --git a/quiche/balsa/balsa_frame.h b/quiche/balsa/balsa_frame.h
index d71411c..99b1e2f 100644
--- a/quiche/balsa/balsa_frame.h
+++ b/quiche/balsa/balsa_frame.h
@@ -21,6 +21,8 @@
 #include "quiche/common/platform/api/quiche_flag_utils.h"
 #include "quiche/common/platform/api/quiche_logging.h"
 
+#include "quiche/balsa/http_protocol_defects.h"
+
 namespace quiche {
 
 namespace test {
@@ -194,6 +196,10 @@
 
   bool is_valid_target_uri() const { return is_valid_target_uri_; }
 
+  const HttpProtocolDefects& protocol_defects() const {
+    return protocol_defects_;
+  }
+
  protected:
   inline BalsaHeadersEnums::ContentLengthStatus ProcessContentLengthLine(
       size_t line_idx, size_t* length);
@@ -322,6 +328,7 @@
   // Specific to parsing of chunk extensions.
   bool in_quote_ : 1;
   bool is_escaped_ : 1;
+  HttpProtocolDefects protocol_defects_;
 };
 
 }  // namespace quiche
diff --git a/quiche/balsa/balsa_frame_test.cc b/quiche/balsa/balsa_frame_test.cc
index 962ec61..e8ceb16 100644
--- a/quiche/balsa/balsa_frame_test.cc
+++ b/quiche/balsa/balsa_frame_test.cc
@@ -747,18 +747,26 @@
 
     if (c >= 127) {
       EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::BALSA_NO_ERROR);
+      EXPECT_EQ(framer.protocol_defects().invalid_method_in_request_first_line,
+                true);
       EXPECT_FALSE(header_properties::IsValidToken(headers.request_method()));
       EXPECT_EQ(headers.request_method(), absl::StrCat("G", char_str, "ET"));
     } else if (absl::ascii_isspace(c)) {
+      EXPECT_EQ(framer.protocol_defects().invalid_method_in_request_first_line,
+                false);
       EXPECT_TRUE(header_properties::IsValidToken(headers.request_method()));
       EXPECT_EQ(headers.request_method(), "G");
     } else if (absl::ascii_iscntrl(c)) {
       EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::BALSA_NO_ERROR);
+      EXPECT_EQ(framer.protocol_defects().invalid_method_in_request_first_line,
+                false);
       EXPECT_TRUE(header_properties::IsValidToken(headers.request_method()));
       EXPECT_EQ(headers.request_method(), "G");
     } else if (absl::ascii_isgraph(c)) {
       EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::BALSA_NO_ERROR);
       bool char_is_tchar = header_properties::IsValidToken(char_str);
+      EXPECT_EQ(framer.protocol_defects().invalid_method_in_request_first_line,
+                !char_is_tchar);
       EXPECT_EQ(header_properties::IsValidToken(headers.request_method()),
                 char_is_tchar);
       EXPECT_EQ(headers.request_method(), absl::StrCat("G", char_str, "ET"));
@@ -879,6 +887,8 @@
     framer.ProcessInput(input.data(), input.size());
     EXPECT_EQ(headers.first_line(), tc.parsed);
     EXPECT_EQ(framer.ErrorCode(), tc.expected_error);
+    EXPECT_EQ(framer.protocol_defects().tab_or_cr_found_in_firstline,
+              tc.expected_tab_or_cr_defect);
   }
 }
 
@@ -944,6 +954,55 @@
   FirstLineParsedCorrectlyHelper(response_tokens, 4242, false, "   \t \t  ");
 }
 
+TEST(HTTPBalsaFrame, LargeAndSmallStatusCodes) {
+  struct TestCase {
+    const absl::string_view status_code;
+    const BalsaFrameEnums::ErrorCode expected_error;
+    const bool expected_invalid_response_code;
+  };
+  std::vector<TestCase> cases = {
+      {"0", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"99", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"100", BalsaFrameEnums::BALSA_NO_ERROR, false},
+      {"200", BalsaFrameEnums::BALSA_NO_ERROR, false},
+      {"599", BalsaFrameEnums::BALSA_NO_ERROR, false},
+      {"600", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"1000", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"65740", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"0200", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"+200", BalsaFrameEnums::BALSA_NO_ERROR, true},
+      {"200A", BalsaFrameEnums::FAILED_CONVERTING_STATUS_CODE_TO_INT, false},
+      {"99999999999999999999999",
+       BalsaFrameEnums::FAILED_CONVERTING_STATUS_CODE_TO_INT, false}};
+  for (const TestCase& tcase : cases) {
+    BalsaHeaders headers;
+    BalsaFrame framer;
+    framer.set_is_request(false);
+    framer.set_balsa_headers(&headers);
+    std::string firstline = absl::StrFormat(
+        "HTTP/1.1 %s OK\r\n"
+        "Content-Length: 0\r\n"
+        "\r\n",
+        tcase.status_code);
+    SCOPED_TRACE(absl::StrCat("Input: ", absl::CEscape(firstline)));
+
+    if (tcase.expected_error == BalsaFrameEnums::BALSA_NO_ERROR) {
+      EXPECT_EQ(framer.ProcessInput(firstline.data(), firstline.size()),
+                firstline.size());
+      EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::BALSA_NO_ERROR);
+      EXPECT_TRUE(framer.MessageFullyRead());
+    } else {
+      EXPECT_LT(framer.ProcessInput(firstline.data(), firstline.size()),
+                firstline.size());
+      EXPECT_EQ(framer.ErrorCode(), tcase.expected_error);
+      EXPECT_FALSE(framer.MessageFullyRead());
+    }
+
+    EXPECT_EQ(framer.protocol_defects().invalid_response_code,
+              tcase.expected_invalid_response_code);
+  }
+}
+
 TEST(HTTPBalsaFrame, LargeAndSmallStatusCodesWithPolicy) {
   struct TestCase {
     const absl::string_view status_code;
@@ -1130,6 +1189,7 @@
       CreateMessage("GET / \rHTTP/1.1\r\n", headers, 2, ":", "\r\n", "");
   framer.ProcessInput(message.data(), message.size());
   EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::INVALID_HEADER_CHARACTER);
+  EXPECT_TRUE(framer.protocol_defects().lone_cr_in_request_headers);
 }
 
 // Test that lone '\r' detection works correctly in the firstline
@@ -1168,6 +1228,7 @@
   EXPECT_EQ(message2.size(),
             framer.ProcessInput(message2.data(), message2.size()));
   EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::INVALID_HEADER_CHARACTER);
+  EXPECT_TRUE(framer.protocol_defects().lone_cr_in_request_headers);
 }
 
 TEST(HTTPBalsaFrame, CarriageReturnIllegalInHeaderKey) {
@@ -1183,6 +1244,7 @@
       CreateMessage("GET / HTTP/1.1\r\n", headers, 1, ":", "\r\n", "");
   framer.ProcessInput(message.data(), message.size());
   EXPECT_EQ(framer.ErrorCode(), BalsaFrameEnums::INVALID_HEADER_NAME_CHARACTER);
+  EXPECT_TRUE(framer.protocol_defects().lone_cr_in_request_headers);
 }
 
 TEST(HTTPBalsaFrame, ResponseLinesParsedProperly) {
@@ -1474,6 +1536,44 @@
   EXPECT_FALSE(balsa_frame_.Error());
 }
 
+TEST_F(HTTPBalsaFrameTest, BothTransferEncodingAndContentLengthAllowed) {
+  std::string message =
+      "GET / HTTP/1.1\r\n"
+      "Transfer-Encoding: chunked\r\n"
+      "Content-Length: 5\r\n"
+      "\r\n";
+
+  balsa_frame_.ProcessInput(message.data(), message.size());
+  EXPECT_FALSE(balsa_frame_.Error());
+  EXPECT_TRUE(
+      balsa_frame_.protocol_defects().transfer_encoding_and_content_length);
+  // Content-Length is provided to callers when Transfer-Encoding is present.
+  EXPECT_EQ(headers_.content_length(), 5);
+}
+
+TEST_F(HTTPBalsaFrameTest, BothTransferEncodingAndContentLengthDisallowed) {
+  HttpValidationPolicy http_validation_policy;
+  http_validation_policy.disallow_transfer_encoding_with_content_length = true;
+  balsa_frame_.set_http_validation_policy(http_validation_policy);
+
+  std::string message =
+      "GET / HTTP/1.1\r\n"
+      "Transfer-Encoding: chunked\r\n"
+      "Content-Length: 5\r\n"
+      "\r\n";
+
+  EXPECT_CALL(
+      visitor_mock_,
+      HandleError(BalsaFrameEnums::BOTH_TRANSFER_ENCODING_AND_CONTENT_LENGTH));
+
+  balsa_frame_.ProcessInput(message.data(), message.size());
+  EXPECT_TRUE(balsa_frame_.Error());
+  EXPECT_EQ(balsa_frame_.ErrorCode(),
+            BalsaFrameEnums::BOTH_TRANSFER_ENCODING_AND_CONTENT_LENGTH);
+  EXPECT_TRUE(
+      balsa_frame_.protocol_defects().transfer_encoding_and_content_length);
+}
+
 TEST_F(HTTPBalsaFrameTest,
        VisitorInvokedProperlyForPermittedMissingContentLength) {
   std::string message =
@@ -2037,6 +2137,39 @@
   EXPECT_FALSE(balsa_frame_.Error());
 }
 
+TEST_F(HTTPBalsaFrameTest, EmptyChunkExtensionDetectedAsDefect) {
+  std::string headers =
+      "POST / HTTP/1.1\r\n"
+      "Transfer-Encoding: chunked\r\n"
+      "\r\n";
+
+  // Empty extension
+  const absl::string_view chunks(
+      "8;\r\n"
+      "deadbeef\r\n"
+      "0\r\n\r\n");
+
+  ASSERT_EQ(headers.size(),
+            balsa_frame_.ProcessInput(headers.data(), headers.size()));
+
+  balsa_frame_.set_balsa_visitor(&visitor_mock_);
+  {
+    InSequence s1;
+    EXPECT_CALL(visitor_mock_, OnChunkLength(8));
+    EXPECT_CALL(visitor_mock_, OnChunkExtensionInput(";"));
+    EXPECT_CALL(visitor_mock_, OnBodyChunkInput("deadbeef"));
+    EXPECT_CALL(visitor_mock_, OnChunkLength(0));
+    EXPECT_CALL(visitor_mock_, OnChunkExtensionInput(""));
+  }
+
+  EXPECT_EQ(chunks.size(),
+            balsa_frame_.ProcessInput(chunks.data(), chunks.size()));
+
+  EXPECT_FALSE(balsa_frame_.Error());
+  EXPECT_TRUE(
+      balsa_frame_.protocol_defects().missing_semicolon_in_chunk_extension);
+}
+
 TEST_F(HTTPBalsaFrameTest, InvalidChunkExtensionWithCarriageReturn) {
   balsa_frame_.set_http_validation_policy(
       HttpValidationPolicy{.disallow_lone_cr_in_chunk_extension = true});
@@ -2247,6 +2380,7 @@
   EXPECT_TRUE(balsa_frame_.MessageFullyRead());
   EXPECT_FALSE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::STRAY_DATA_AFTER_CHUNK, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().stray_data_after_chunk);
 
   EXPECT_EQ(message_body, body_input);
   EXPECT_EQ(message_body_data, body_data);
@@ -2269,6 +2403,7 @@
             balsa_frame_.ProcessInput(body1.data(), body1.size()));
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::STRAY_DATA_AFTER_CHUNK, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().stray_data_after_chunk);
 }
 
 // A LF character preceded by CR is allowed even if separated into multiple
@@ -2331,6 +2466,8 @@
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
   // According to the RFC, this should be false!
   EXPECT_TRUE(balsa_frame_.MessageFullyRead());
+  EXPECT_TRUE(
+      balsa_frame_.protocol_defects().chunked_body_does_not_end_with_crlf_crlf);
 }
 
 TEST_F(
@@ -2368,6 +2505,8 @@
 
   EXPECT_EQ(BalsaFrameEnums::INVALID_CHUNK_FRAMING, balsa_frame_.ErrorCode());
   EXPECT_FALSE(balsa_frame_.MessageFullyRead());
+  EXPECT_TRUE(
+      balsa_frame_.protocol_defects().chunked_body_does_not_end_with_crlf_crlf);
 }
 
 TEST_F(HTTPBalsaFrameTest, FirstlinesWithMultipleSpacesAllowed) {
@@ -2387,6 +2526,7 @@
       << BalsaFrameEnums::ErrorCodeToString(balsa_frame_.ErrorCode());
 
   EXPECT_EQ("GET  / HTTP/1.1", headers_.first_line());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest, FirstlinesWithMultipleSpacesRejected) {
@@ -2405,6 +2545,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_SPACES_IN_REQUEST_LINE,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest, FirstlinesWithMultipleSpacesSanitized) {
@@ -2426,6 +2567,7 @@
       << BalsaFrameEnums::ErrorCodeToString(balsa_frame_.ErrorCode());
 
   EXPECT_EQ("GET / HTTP/1.1", headers_.first_line());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest, ResponseFirstlinesWithMultipleSpacesAllowed) {
@@ -2446,6 +2588,7 @@
       << BalsaFrameEnums::ErrorCodeToString(balsa_frame_.ErrorCode());
 
   EXPECT_EQ("HTTP/1.1 200  OK", headers_.first_line());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest, ResponseFirstlinesWithMultipleSpacesRejected) {
@@ -2465,6 +2608,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_SPACES_IN_STATUS_LINE,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest, ResponseFirstlinesWithMultipleSpacesSanitized) {
@@ -2487,6 +2631,7 @@
       << BalsaFrameEnums::ErrorCodeToString(balsa_frame_.ErrorCode());
 
   EXPECT_EQ("HTTP/1.1 200 OK", headers_.first_line());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_spaces_in_firstline);
 }
 
 TEST_F(HTTPBalsaFrameTest,
@@ -3694,6 +3839,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_CONTENT_LENGTH_KEYS,
             balsa_frame_.ErrorCode());
+  EXPECT_FALSE(balsa_frame_.protocol_defects().multiple_content_length_keys);
 }
 
 TEST_F(HTTPBalsaFrameTest, TwoDifferentContentLengthHeadersIsAnError) {
@@ -3707,6 +3853,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_CONTENT_LENGTH_KEYS,
             balsa_frame_.ErrorCode());
+  EXPECT_FALSE(balsa_frame_.protocol_defects().multiple_content_length_keys);
 }
 
 TEST_F(HTTPBalsaFrameTest, TwoSameContentLengthHeadersIsNotAnError) {
@@ -3718,6 +3865,7 @@
       "1";
   balsa_frame_.ProcessInput(header.data(), header.size());
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_content_length_keys);
   EXPECT_FALSE(balsa_frame_.Error());
   balsa_frame_.ProcessInput(header.data(), header.size());
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
@@ -3741,6 +3889,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_CONTENT_LENGTH_KEYS,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_content_length_keys);
 }
 
 TEST_F(HTTPBalsaFrameTest, ChunkedTransferEncodingWithContentLength) {
@@ -3775,6 +3924,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::MULTIPLE_TRANSFER_ENCODING_KEYS,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_transfer_encoding_keys);
 }
 
 TEST_F(HTTPBalsaFrameTest, AcceptTwoTransferEncodingHeaders) {
@@ -3793,6 +3943,7 @@
 
   EXPECT_FALSE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().multiple_transfer_encoding_keys);
 }
 
 TEST_F(HTTPBalsaFrameTest, TwoTransferEncodingTokensIsAnError) {
@@ -3806,6 +3957,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::UNKNOWN_TRANSFER_ENCODING,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().unknown_transfer_encoding);
 }
 
 TEST_F(HTTPBalsaFrameTest, AcceptTwoTransferEncodingTokens) {
@@ -3823,6 +3975,7 @@
 
   EXPECT_FALSE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().unknown_transfer_encoding);
 }
 
 TEST_F(HTTPBalsaFrameTest, UnknownTransferEncodingTokenIsAnError) {
@@ -3836,6 +3989,7 @@
   EXPECT_TRUE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::UNKNOWN_TRANSFER_ENCODING,
             balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().unknown_transfer_encoding);
 }
 
 TEST_F(HTTPBalsaFrameTest, AcceptUnknownTransferEncodingToken) {
@@ -3853,6 +4007,7 @@
 
   EXPECT_FALSE(balsa_frame_.Error());
   EXPECT_EQ(BalsaFrameEnums::BALSA_NO_ERROR, balsa_frame_.ErrorCode());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().unknown_transfer_encoding);
 }
 
 TEST_F(HTTPBalsaFrameTest, MissingContentLength) {
@@ -4966,6 +5121,7 @@
   EXPECT_EQ(message.size(),
             balsa_frame_.ProcessInput(message.data(), message.size()));
   EXPECT_FALSE(balsa_frame_.Error());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().obs_fold_in_header_values);
 }
 
 // A.k.a., ObsFoldDisallowed.
@@ -5090,6 +5246,7 @@
   EXPECT_EQ(message.size(),
             balsa_frame_.ProcessInput(message.data(), message.size()));
   EXPECT_FALSE(balsa_frame_.Error());
+  EXPECT_FALSE(balsa_frame_.protocol_defects().obs_text_found_in_header_name);
 }
 
 TEST_F(HTTPBalsaFrameTest, HeaderFieldNameWithObsTextButPolicyDisabled) {
@@ -5112,6 +5269,7 @@
   EXPECT_EQ(message.size(),
             balsa_frame_.ProcessInput(message.data(), message.size()));
   EXPECT_FALSE(balsa_frame_.Error());
+  EXPECT_TRUE(balsa_frame_.protocol_defects().obs_text_found_in_header_name);
 }
 
 TEST_F(HTTPBalsaFrameTest, HeaderFieldNameWithObsTextAndPolicyEnabled) {
diff --git a/quiche/balsa/http_protocol_defects.h b/quiche/balsa/http_protocol_defects.h
new file mode 100644
index 0000000..cfbdab6
--- /dev/null
+++ b/quiche/balsa/http_protocol_defects.h
@@ -0,0 +1,184 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef QUICHE_BALSA_HTTP_PROTOCOL_DEFECTS_H_
+#define QUICHE_BALSA_HTTP_PROTOCOL_DEFECTS_H_
+
+#include "absl/strings/string_view.h"
+#include "quiche/common/platform/api/quiche_export.h"
+#include "quiche/common/quiche_callbacks.h"
+
+namespace quiche {
+
+class QUICHE_EXPORT HttpProtocolDefects {
+ public:
+  bool invalid_method_in_request_first_line = false;
+  bool multiple_content_length_keys = false;
+  bool missing_semicolon_in_chunk_extension = false;
+  bool token_before_semicolon_in_chunk_extension = false;
+  bool invalid_response_code = false;
+  bool multiple_spaces_in_firstline = false;
+  bool obs_fold_in_header_values = false;
+  bool obs_fold_in_trailer_values = false;
+  bool chunked_body_does_not_end_with_crlf_crlf = false;
+  bool stray_data_after_chunk = false;
+  bool lone_cr_in_request_headers = false;
+  bool header_name_contains_double_quote = false;
+  bool header_missing_colon = false;
+  bool tab_or_cr_found_in_firstline = false;
+  bool obs_text_found_in_header_name = false;
+  bool transfer_encoding_and_content_length = false;
+  bool unknown_transfer_encoding = false;
+  bool multiple_transfer_encoding_keys = false;
+
+  void Merge(const HttpProtocolDefects& other) {
+    invalid_method_in_request_first_line |=
+        other.invalid_method_in_request_first_line;
+    multiple_content_length_keys |= other.multiple_content_length_keys;
+    missing_semicolon_in_chunk_extension |=
+        other.missing_semicolon_in_chunk_extension;
+    token_before_semicolon_in_chunk_extension |=
+        other.token_before_semicolon_in_chunk_extension;
+    invalid_response_code |= other.invalid_response_code;
+    multiple_spaces_in_firstline |= other.multiple_spaces_in_firstline;
+    obs_fold_in_header_values |= other.obs_fold_in_header_values;
+    obs_fold_in_trailer_values |= other.obs_fold_in_trailer_values;
+    chunked_body_does_not_end_with_crlf_crlf |=
+        other.chunked_body_does_not_end_with_crlf_crlf;
+    stray_data_after_chunk |= other.stray_data_after_chunk;
+    lone_cr_in_request_headers |= other.lone_cr_in_request_headers;
+    header_name_contains_double_quote |=
+        other.header_name_contains_double_quote;
+    header_missing_colon |= other.header_missing_colon;
+    tab_or_cr_found_in_firstline |= other.tab_or_cr_found_in_firstline;
+    obs_text_found_in_header_name |= other.obs_text_found_in_header_name;
+    transfer_encoding_and_content_length |=
+        other.transfer_encoding_and_content_length;
+    unknown_transfer_encoding |= other.unknown_transfer_encoding;
+    multiple_transfer_encoding_keys |= other.multiple_transfer_encoding_keys;
+  }
+
+  void ForEachDefect(
+      UnretainedCallback<void(absl::string_view)> callback) const {
+    if (invalid_method_in_request_first_line) {
+      callback("invalid_method_in_request_first_line");
+    }
+    if (multiple_content_length_keys) {
+      callback("multiple_content_length_keys");
+    }
+    if (missing_semicolon_in_chunk_extension) {
+      callback("missing_semicolon_in_chunk_extension");
+    }
+    if (token_before_semicolon_in_chunk_extension) {
+      callback("token_before_semicolon_in_chunk_extension");
+    }
+    if (invalid_response_code) {
+      callback("invalid_response_code");
+    }
+    if (multiple_spaces_in_firstline) {
+      callback("multiple_spaces_in_firstline");
+    }
+    if (obs_fold_in_header_values) {
+      callback("obs_fold_in_header_values");
+    }
+    if (obs_fold_in_trailer_values) {
+      callback("obs_fold_in_trailer_values");
+    }
+    if (chunked_body_does_not_end_with_crlf_crlf) {
+      callback("chunked_body_does_not_end_with_crlf_crlf");
+    }
+    if (stray_data_after_chunk) {
+      callback("stray_data_after_chunk");
+    }
+    if (lone_cr_in_request_headers) {
+      callback("lone_cr_in_request_headers");
+    }
+    if (header_name_contains_double_quote) {
+      callback("header_name_contains_double_quote");
+    }
+    if (header_missing_colon) {
+      callback("header_missing_colon");
+    }
+    if (tab_or_cr_found_in_firstline) {
+      callback("tab_or_cr_found_in_firstline");
+    }
+    if (obs_text_found_in_header_name) {
+      callback("obs_text_found_in_header_name");
+    }
+    if (transfer_encoding_and_content_length) {
+      callback("transfer_encoding_and_content_length");
+    }
+    if (unknown_transfer_encoding) {
+      callback("unknown_transfer_encoding");
+    }
+    if (multiple_transfer_encoding_keys) {
+      callback("multiple_transfer_encoding_keys");
+    }
+  }
+
+  // T should ideally be strongly typed as
+  // logs::gfe::GfeLog::HttpProtocolDefects but since Quiche can only depend on
+  // targets that are compatible with non_prod, a template is used instead.
+  template <typename T>
+  void PopulateLogDefects(T& log_defects) const {
+    if (invalid_method_in_request_first_line) {
+      log_defects.set_invalid_method_in_request_first_line(true);
+    }
+    if (multiple_content_length_keys) {
+      log_defects.set_multiple_content_length_keys_detected(true);
+    }
+    if (missing_semicolon_in_chunk_extension) {
+      log_defects.set_missing_semicolon_in_chunk_extension(true);
+    }
+    if (token_before_semicolon_in_chunk_extension) {
+      log_defects.set_token_before_semicolon_in_chunk_extension(true);
+    }
+    if (invalid_response_code) {
+      log_defects.set_invalid_response_code(true);
+    }
+    if (multiple_spaces_in_firstline) {
+      log_defects.set_multiple_spaces_found_in_first_line(true);
+    }
+    if (obs_fold_in_header_values) {
+      log_defects.set_obs_fold_in_header_value(true);
+    }
+    if (obs_fold_in_trailer_values) {
+      log_defects.set_obs_fold_in_trailer_value(true);
+    }
+    if (chunked_body_does_not_end_with_crlf_crlf) {
+      log_defects.set_chunked_request_ends_with_crlflf(true);
+    }
+    if (stray_data_after_chunk) {
+      log_defects.set_stray_data_after_chunk(true);
+    }
+    if (lone_cr_in_request_headers) {
+      log_defects.set_cr_found_in_header_value(true);
+    }
+    if (header_name_contains_double_quote) {
+      log_defects.set_header_name_contains_double_quote(true);
+    }
+    if (header_missing_colon) {
+      log_defects.set_header_missing_colon(true);
+    }
+    if (transfer_encoding_and_content_length) {
+      log_defects.set_transfer_encoding_and_content_length(true);
+    }
+    if (unknown_transfer_encoding) {
+      log_defects.set_unknown_transfer_encoding(true);
+    }
+    if (multiple_transfer_encoding_keys) {
+      log_defects.set_multiple_transfer_encoding_keys(true);
+    }
+    if (tab_or_cr_found_in_firstline) {
+      log_defects.set_tab_or_cr_found_in_firstline(true);
+    }
+    if (obs_text_found_in_header_name) {
+      log_defects.set_obs_text_found_in_header_name(true);
+    }
+  }
+};
+
+}  // namespace quiche
+
+#endif  // QUICHE_BALSA_HTTP_PROTOCOL_DEFECTS_H_