From 160de3fda5aa3fc1a6b29b60b2fba7e615f365de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:18:40 +0000 Subject: [PATCH] Testing & CI hardening phase 2: fuzz the HTTP/1.1 parser, even out malformed-server coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues issue #48 after #53, taking the two hostile-input items: - The hand-rolled HTTP/1.1 message reader — the one network-facing parser with no fuzz coverage — moves out of socket_transport.cc into a pure, callback-fed function (smithy/http/http1.h) behind the same transports, byte-for-byte behavior-preserving. On top of it: * //fuzz:http1_fuzz, a libFuzzer harness that replays the wire bytes in varying chunk sizes so the incremental header/body accumulation paths are exercised, wired into the CI fuzz smoke loop and `make fuzz-smoke` (and the deterministic-driver //fuzz:http1_fuzz_smoke variant that runs in every bazel matrix job). * http1_hostile_test.cc, a platform-independent hostile bank at the pure parser level: request-smuggling framing (duplicate/conflicting CL, transfer-encoding, TE+CL), hostile content-lengths, malformed header blocks, every-strict-prefix truncation, header floods, plus the accept-side (padding, case-insensitivity, body-until-EOF) and the start-line helpers. Unlike socket_transport_hostile_test.cc it needs no sockets, so it also runs on Windows. Hardening found while banking: an empty Content-Length and a "+4"-style signed one previously parsed as valid lengths (strtoull laxity); the parser now requires digits-only per RFC 9110. - Malformed-server coverage was jsonrpc2-only (its model carries httpMalformedRequestTests; the alloy and official rpcv2Cbor suites carry none). New hand-written suites — outside the golden generated/ trees — pin the generated servers' reject paths before the handler runs: * simpleRestJson (PizzaAdminService): 404/405 routing, unparseable JSON body -> 400 + x-error-type SerializationException, wrong content-type -> 415, and the suite-exact enum-violation ValidationException message. * simpleRestJson @pattern-violation wire message (issue #48's explicit gap) via the roundtrip REST fixture's pattern-constrained SinkId, plus the exact length-violation message. * rpcv2Cbor (RpcV2Protocol): missing/wrong smithy-protocol header -> 400 SerializationException, wrong content-type -> 415 UnsupportedMediaTypeException, truncated and non-map CBOR bodies, 404/405 routing — asserting the CBOR __type and protocol headers on every error response. CI's clang-format check (and make lint/format) now covers protocol-tests outside generated/. Verified locally: bazel test //... green (72 tests; Boost/benchmark targets excluded per docs/development.md's proxy note, CI covers them), plus a 15s real-libFuzzer ASan run of the new harness (160k execs, no findings). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012VFUppN1idpmU1UrbHkknZ --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 14 ++ Makefile | 8 +- fuzz/BUILD.bazel | 1 + fuzz/http1_fuzz.cc | 61 ++++++ .../rpcv2cbor/malformed/BUILD.bazel | 21 ++ .../malformed/server_malformed_test.cc | 175 +++++++++++++++++ .../simplerestjson/malformed/BUILD.bazel | 34 ++++ .../malformed/pattern_violation_test.cc | 76 ++++++++ .../malformed/server_malformed_test.cc | 148 +++++++++++++++ runtime/BUILD.bazel | 15 ++ runtime/include/smithy/http/http1.h | 49 +++++ runtime/src/http/http1.cc | 121 ++++++++++++ runtime/src/http/socket_transport.cc | 120 ++---------- runtime/tests/http/http1_hostile_test.cc | 179 ++++++++++++++++++ 15 files changed, 919 insertions(+), 108 deletions(-) create mode 100644 fuzz/http1_fuzz.cc create mode 100644 protocol-tests/rpcv2cbor/malformed/BUILD.bazel create mode 100644 protocol-tests/rpcv2cbor/malformed/server_malformed_test.cc create mode 100644 protocol-tests/simplerestjson/malformed/BUILD.bazel create mode 100644 protocol-tests/simplerestjson/malformed/pattern_violation_test.cc create mode 100644 protocol-tests/simplerestjson/malformed/server_malformed_test.cc create mode 100644 runtime/include/smithy/http/http1.h create mode 100644 runtime/src/http/http1.cc create mode 100644 runtime/tests/http/http1_hostile_test.cc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fe4e947..7c3e63f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: CXX: clang++ run: | set -euo pipefail - for target in json_decode cbor_decode uri server_dispatch regex; do + for target in json_decode cbor_decode uri server_dispatch regex http1; do echo "== fuzzing $target" bazelisk build --config=fuzz "//fuzz:${target}_fuzz" ./bazel-bin/fuzz/${target}_fuzz -max_total_time=30 -print_final_stats=1 @@ -181,7 +181,8 @@ jobs: # its shape is locked by the golden diff check in the codegen job. - name: clang-format run: | - find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + find runtime examples codegen/compile-tests protocol-tests \ + \( -name '*.h' -o -name '*.cc' \) \ ! -path '*/generated/*' | xargs clang-format --dry-run --Werror # Excluded from tidy: src/json/json.cc includes the nlohmann backend, # which only exists inside the Bazel build graph; beast_src.cc is the diff --git a/CHANGELOG.md b/CHANGELOG.md index a36025d8..3a2eabac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,5 +79,19 @@ via `git_override` until then. (`Search(text, &steps)` instrumentation) instead of a wall-clock limit. - `make verify` / `make verify-full`: one-command local verification mirroring the CI jobs one-to-one. +- **HTTP/1.1 parser extracted and fuzzed** (`smithy/http/http1.h`): the + socket transports' hand-rolled message reader is now a pure, + callback-fed function with a libFuzzer harness (`//fuzz:http1_fuzz`, in + the CI smoke loop) and a platform-independent hostile bank + (`http1_hostile_test.cc`) covering smuggling framing, hostile + content-lengths, truncation-everywhere, and header floods. Hardening + found while banking: an empty or `+`-signed Content-Length previously + parsed as a valid length; both now reject (digits-only per RFC 9110). +- **Malformed-server coverage evened out**: hand-written suites pin how the + generated simpleRestJson and rpcv2Cbor servers reject hostile requests + (unparseable bodies, protocol-precondition violations, wrong + content-type/method/route) the way jsonrpc2's generated suite always did — + including the previously-unasserted simpleRestJson `@pattern`-violation + wire message. [Unreleased]: https://github.com/aaylward/smithy-cpp/commits/main diff --git a/Makefile b/Makefile index 3b906dff..e297c89f 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,8 @@ goldens: .PHONY: lint lint: - find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + find runtime examples codegen/compile-tests protocol-tests \ + \( -name '*.h' -o -name '*.cc' \) \ ! -path '*/generated/*' | xargs clang-format --dry-run --Werror buildifier --lint=warn --mode=check -r . @@ -53,7 +54,7 @@ sanitize: .PHONY: fuzz-smoke fuzz-smoke: - set -e; for target in json_decode cbor_decode uri server_dispatch regex; do \ + set -e; for target in json_decode cbor_decode uri server_dispatch regex http1; do \ echo "== fuzzing $$target"; \ CC=clang CXX=clang++ $(BAZEL) build --config=fuzz "//fuzz:$${target}_fuzz"; \ ./bazel-bin/fuzz/$${target}_fuzz -max_total_time=30 -print_final_stats=1; \ @@ -73,7 +74,8 @@ benchmarks: # Rewrites instead of checking: the fix-it twin of `lint` + codegen's spotless. .PHONY: format format: - find runtime examples codegen/compile-tests \( -name '*.h' -o -name '*.cc' \) \ + find runtime examples codegen/compile-tests protocol-tests \ + \( -name '*.h' -o -name '*.cc' \) \ ! -path '*/generated/*' | xargs clang-format -i buildifier --lint=warn -r . cd codegen && $(GRADLE) spotlessApply diff --git a/fuzz/BUILD.bazel b/fuzz/BUILD.bazel index b3774896..496391de 100644 --- a/fuzz/BUILD.bazel +++ b/fuzz/BUILD.bazel @@ -11,6 +11,7 @@ FUZZ_TARGETS = { "//runtime:cbor", "//runtime:core", ], + "http1": ["//runtime:http"], "regex": ["//runtime:core"], "uri": ["//runtime:http"], "server_dispatch": [ diff --git a/fuzz/http1_fuzz.cc b/fuzz/http1_fuzz.cc new file mode 100644 index 00000000..cc936e4e --- /dev/null +++ b/fuzz/http1_fuzz.cc @@ -0,0 +1,61 @@ +// Fuzz target: the hand-rolled HTTP/1.1 message reader behind the socket +// transports (issue #48 — the one network-facing parser that had no fuzz +// coverage). The input's first byte picks the read mode and a chunking +// pattern; the rest is the wire stream, delivered in varying slices so the +// incremental header/body accumulation paths get exercised, not just the +// all-at-once happy path. The parser must never crash, over-read, or accept +// a body larger than its documented cap; the start-line helpers must never +// crash on arbitrary bytes. +#include +#include +#include +#include +#include +#include +#include + +#include "smithy/http/http1.h" + +namespace { + +constexpr std::size_t kMaxBodyBytes = std::size_t{64} * 1024 * 1024; + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size == 0) return 0; + const std::uint8_t control = data[0]; + const bool body_until_eof = (control & 1) != 0; + // Chunk sizes cycle through a pattern seeded by the control byte: sizes of + // 1 stress byte-at-a-time accumulation, larger ones the buffered path. + const std::size_t patterns[4][3] = {{1, 1, 1}, {1, 7, 4096}, {3, 8192, 2}, {8192, 8192, 8192}}; + const std::size_t* chunk_sizes = patterns[(control >> 1) & 3]; + + const char* wire = reinterpret_cast(data + 1); + std::size_t remaining = size - 1; + std::size_t offset = 0; + int call = 0; + const auto read = [&](char* buffer, std::size_t capacity) -> long { + const std::size_t want = std::min(capacity, chunk_sizes[call++ % 3]); + const std::size_t take = std::min(want, remaining - offset); + if (take == 0) return 0; // EOF + std::memcpy(buffer, wire + offset, take); + offset += take; + return static_cast(take); + }; + + auto message = smithy::http::ReadHttp1Message(read, body_until_eof); + if (message.ok()) { + if (message->body.size() > kMaxBodyBytes) std::abort(); + // Whatever parsed must be internally consistent enough to iterate. + for (const auto& [name, value] : message->headers.entries()) { + if (name.find("\r\n") != std::string::npos) std::abort(); + (void)value; + } + std::string method; + std::string target; + (void)smithy::http::ParseRequestLine(message->start_line, &method, &target); + (void)smithy::http::ParseStatusLine(message->start_line); + } + return 0; +} diff --git a/protocol-tests/rpcv2cbor/malformed/BUILD.bazel b/protocol-tests/rpcv2cbor/malformed/BUILD.bazel new file mode 100644 index 00000000..55b01560 --- /dev/null +++ b/protocol-tests/rpcv2cbor/malformed/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") + +# Hand-written malformed-server coverage for rpcv2Cbor (issue #48): the +# official conformance suite has no httpMalformedRequestTests for this +# protocol, so this pins the generated server's reject paths the way +# jsonrpc2's generated server_malformed_tests.cc does for its protocol. +# Lives outside generated/ because that tree is a golden regenerated +# byte-for-byte in CI. + +cc_test( + name = "server_malformed_test", + size = "small", + srcs = ["server_malformed_test.cc"], + deps = [ + "//protocol-tests/rpcv2cbor/generated:server", + "//runtime:cbor", + "//runtime:core", + "//runtime:http", + "@googletest//:gtest_main", + ], +) diff --git a/protocol-tests/rpcv2cbor/malformed/server_malformed_test.cc b/protocol-tests/rpcv2cbor/malformed/server_malformed_test.cc new file mode 100644 index 00000000..f80b12de --- /dev/null +++ b/protocol-tests/rpcv2cbor/malformed/server_malformed_test.cc @@ -0,0 +1,175 @@ +// Hand-written malformed-server suite for rpcv2Cbor (issue #48). The +// official Smithy conformance suite carries no httpMalformedRequestTests for +// this protocol, so this pins how the generated RpcV2Protocol server rejects +// hostile requests — protocol preconditions, unparseable CBOR, bad routing — +// before the handler runs. Lives outside generated/ because that tree is a +// golden the codegen CI job regenerates byte-for-byte. + +#include + +#include +#include + +#include "smithy/cbor/cbor.h" +#include "smithy/protocoltests/rpcv2cbor/server.h" + +namespace smithy::protocoltests::rpcv2cbor { +namespace { + +class RecordingHandler : public RpcV2ProtocolHandler { + public: + smithy::Outcome EmptyInputOutput(const EmptyInputOutputInput&) override { + ++calls; + return EmptyInputOutputOutput{}; + } + smithy::Outcome Float16(const Float16Input&) override { + ++calls; + return Float16Output{}; + } + smithy::Outcome FractionalSeconds( + const FractionalSecondsInput&) override { + ++calls; + return FractionalSecondsOutput{}; + } + smithy::Outcome GreetingWithErrors( + const GreetingWithErrorsInput&) override { + ++calls; + return GreetingWithErrorsOutput{}; + } + smithy::Outcome NoInputOutput(const NoInputOutputInput&) override { + ++calls; + return NoInputOutputOutput{}; + } + smithy::Outcome OperationWithDefaults( + const OperationWithDefaultsInput&) override { + ++calls; + return OperationWithDefaultsOutput{}; + } + smithy::Outcome OptionalInputOutput( + const OptionalInputOutputInput&) override { + ++calls; + return OptionalInputOutputOutput{}; + } + smithy::Outcome RecursiveShapes(const RecursiveShapesInput&) override { + ++calls; + return RecursiveShapesOutput{}; + } + smithy::Outcome RpcV2CborDenseMaps( + const RpcV2CborDenseMapsInput&) override { + ++calls; + return RpcV2CborDenseMapsOutput{}; + } + smithy::Outcome RpcV2CborLists(const RpcV2CborListsInput&) override { + ++calls; + return RpcV2CborListsOutput{}; + } + smithy::Outcome RpcV2CborSparseMaps( + const RpcV2CborSparseMapsInput&) override { + ++calls; + return RpcV2CborSparseMapsOutput{}; + } + smithy::Outcome SimpleScalarProperties( + const SimpleScalarPropertiesInput&) override { + ++calls; + return SimpleScalarPropertiesOutput{}; + } + smithy::Outcome SparseNullsOperation( + const SparseNullsOperationInput&) override { + ++calls; + return SparseNullsOperationOutput{}; + } + int calls = 0; +}; + +class RpcV2CborMalformedTest : public testing::Test { + protected: + smithy::http::HttpRequest WellFormedRequest(const std::string& operation) { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/service/RpcV2Protocol/operation/" + operation; + request.headers.Set("smithy-protocol", "rpc-v2-cbor"); + request.headers.Set("content-type", "application/cbor"); + return request; + } + + smithy::http::HttpResponse Send(const smithy::http::HttpRequest& request) { + return server_.Handler()(request); + } + + // The protocol serializes errors as a CBOR map carrying __type. + std::string ErrorTypeOf(const smithy::http::HttpResponse& response) { + EXPECT_EQ(response.headers.Get("smithy-protocol").value_or(""), "rpc-v2-cbor"); + EXPECT_EQ(response.headers.Get("content-type").value_or(""), "application/cbor"); + const auto body = smithy::cbor::Decode(Blob::FromString(response.body)); + EXPECT_TRUE(body.ok()); + if (!body.ok() || !body->is_map()) return ""; + const smithy::Document* type = body->Find("__type"); + return type == nullptr ? "" : std::string(type->as_string()); + } + + std::shared_ptr handler_ = std::make_shared(); + RpcV2ProtocolServer server_{handler_}; +}; + +TEST_F(RpcV2CborMalformedTest, MissingSmithyProtocolHeaderIsRejected) { + auto request = WellFormedRequest("NoInputOutput"); + request.headers.Remove("smithy-protocol"); + const auto response = Send(request); + EXPECT_EQ(response.status, 400); + EXPECT_EQ(ErrorTypeOf(response), "SerializationException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, WrongSmithyProtocolHeaderIsRejected) { + auto request = WellFormedRequest("NoInputOutput"); + request.headers.Set("smithy-protocol", "rpc-v2-json"); + const auto response = Send(request); + EXPECT_EQ(response.status, 400); + EXPECT_EQ(ErrorTypeOf(response), "SerializationException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, WrongContentTypeIs415) { + auto request = WellFormedRequest("SimpleScalarProperties"); + request.headers.Set("content-type", "application/json"); + request.body = "{}"; + const auto response = Send(request); + EXPECT_EQ(response.status, 415); + EXPECT_EQ(ErrorTypeOf(response), "UnsupportedMediaTypeException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, TruncatedCborBodyIsSerializationException) { + auto request = WellFormedRequest("SimpleScalarProperties"); + request.body = "\x18"; // uint8 header, argument byte missing + const auto response = Send(request); + EXPECT_EQ(response.status, 400); + EXPECT_EQ(ErrorTypeOf(response), "SerializationException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, NonMapCborBodyIsSerializationException) { + auto request = WellFormedRequest("SimpleScalarProperties"); + request.body = "\x01"; // a bare integer where a structure map is required + const auto response = Send(request); + EXPECT_EQ(response.status, 400); + EXPECT_EQ(ErrorTypeOf(response), "SerializationException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, UnknownOperationIs404) { + const auto response = Send(WellFormedRequest("NoSuchOperation")); + EXPECT_EQ(response.status, 404); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(RpcV2CborMalformedTest, WrongMethodIs405) { + auto request = WellFormedRequest("NoInputOutput"); + request.method = "GET"; + const auto response = Send(request); + EXPECT_EQ(response.status, 405); + EXPECT_EQ(handler_->calls, 0); +} + +} // namespace +} // namespace smithy::protocoltests::rpcv2cbor diff --git a/protocol-tests/simplerestjson/malformed/BUILD.bazel b/protocol-tests/simplerestjson/malformed/BUILD.bazel new file mode 100644 index 00000000..ce64c595 --- /dev/null +++ b/protocol-tests/simplerestjson/malformed/BUILD.bazel @@ -0,0 +1,34 @@ +load("@rules_cc//cc:defs.bzl", "cc_test") + +# Hand-written malformed-server coverage for simpleRestJson (issue #48): the +# alloy conformance suite has no httpMalformedRequestTests, so nothing +# generated pins the reject paths the way jsonrpc2's generated +# server_malformed_tests.cc does. This package lives outside generated/ +# because that tree is a golden regenerated byte-for-byte in CI. + +cc_test( + name = "server_malformed_test", + size = "small", + srcs = ["server_malformed_test.cc"], + deps = [ + "//protocol-tests/simplerestjson/generated:server", + "//runtime:http", + "//runtime:json", + "@googletest//:gtest_main", + ], +) + +# The @pattern-violation message simpleRestJson never asserted: the alloy +# service models no @pattern, so this drives the roundtrip REST fixture's +# pattern-constrained SinkId instead. +cc_test( + name = "pattern_violation_test", + size = "small", + srcs = ["pattern_violation_test.cc"], + deps = [ + "//examples/roundtrip/rest/generated:server", + "//runtime:http", + "//runtime:json", + "@googletest//:gtest_main", + ], +) diff --git a/protocol-tests/simplerestjson/malformed/pattern_violation_test.cc b/protocol-tests/simplerestjson/malformed/pattern_violation_test.cc new file mode 100644 index 00000000..d9bd5096 --- /dev/null +++ b/protocol-tests/simplerestjson/malformed/pattern_violation_test.cc @@ -0,0 +1,76 @@ +// The simpleRestJson @pattern-violation message assertion issue #48 called +// out as missing: the alloy conformance service models no @pattern, so this +// drives the roundtrip REST fixture (whose SinkId carries +// @pattern("^[A-Za-z0-9]+$")) and pins the exact ValidationException wire +// message — the same suite-exact text the jsonrpc2 protocol tests already +// assert for their protocol. + +#include + +#include +#include + +#include "example/roundtrip/rest/server.h" +#include "smithy/json/json.h" + +namespace example::roundtrip::rest { +namespace { + +class RecordingHandler : public RoundTripRestHandler { + public: + smithy::Outcome DescribeSink(const DescribeSinkInput&) override { + ++calls; + return DescribeSinkOutput{}; + } + smithy::Outcome PutSink(const PutSinkInput&) override { + ++calls; + return PutSinkOutput{}; + } + smithy::Outcome UploadAttachment(const UploadAttachmentInput&) override { + ++calls; + return UploadAttachmentOutput{}; + } + int calls = 0; +}; + +class PatternViolationTest : public testing::Test { + protected: + // Returns the single fieldList entry of a 400 ValidationException. + smithy::Document Reject(const std::string& target) { + smithy::http::HttpRequest request; + request.method = "GET"; + request.target = target; + const smithy::http::HttpResponse response = server_.Handler()(request); + EXPECT_EQ(response.status, 400) << response.body; + EXPECT_EQ(response.headers.Get("x-error-type").value_or(""), "ValidationException"); + EXPECT_EQ(handler_->calls, 0); + auto body = smithy::json::Decode(response.body); + EXPECT_TRUE(body.ok()) << response.body; + const smithy::Document* field_list = body->Find("fieldList"); + EXPECT_NE(field_list, nullptr) << response.body; + EXPECT_EQ(field_list->as_list().size(), 1u) << response.body; + return field_list->as_list()[0]; + } + + std::shared_ptr handler_ = std::make_shared(); + RoundTripRestServer server_{handler_}; +}; + +TEST_F(PatternViolationTest, PatternViolationReportsTheExactMessage) { + const smithy::Document failure = Reject("/sinks/bad!id"); + EXPECT_EQ(failure.Find("path")->as_string(), "/sinkId"); + EXPECT_EQ(failure.Find("message")->as_string(), + "Value at '/sinkId' failed to satisfy constraint: Member must satisfy regular " + "expression pattern: ^[A-Za-z0-9]+$"); +} + +TEST_F(PatternViolationTest, LengthViolationReportsTheExactMessage) { + const smithy::Document failure = Reject("/sinks/" + std::string(33, 'a')); + EXPECT_EQ(failure.Find("path")->as_string(), "/sinkId"); + EXPECT_EQ(failure.Find("message")->as_string(), + "Value with length 33 at '/sinkId' failed to satisfy constraint: Member must have " + "length between 1 and 32, inclusive"); +} + +} // namespace +} // namespace example::roundtrip::rest diff --git a/protocol-tests/simplerestjson/malformed/server_malformed_test.cc b/protocol-tests/simplerestjson/malformed/server_malformed_test.cc new file mode 100644 index 00000000..989ce5bf --- /dev/null +++ b/protocol-tests/simplerestjson/malformed/server_malformed_test.cc @@ -0,0 +1,148 @@ +// Hand-written malformed-server suite for simpleRestJson (issue #48). The +// jsonrpc2 protocol tests generate an equivalent file from the model's +// httpMalformedRequestTests traits; the alloy conformance suite carries no +// such traits, so until upstream grows them this suite pins how the +// generated PizzaAdminService server rejects hostile requests — before the +// handler ever runs. Lives outside generated/ because that tree is a golden +// the codegen CI job regenerates byte-for-byte. + +#include + +#include +#include + +#include "smithy/json/json.h" +#include "smithy/protocoltests/simplerestjson/server.h" + +namespace smithy::protocoltests::simplerestjson { +namespace { + +// Counts invocations; a malformed request must be rejected before any +// operation runs, so every test asserts calls stays 0. +class RecordingHandler : public PizzaAdminServiceHandler { + public: + smithy::Outcome AddMenuItem(const AddMenuItemInput&) override { + ++calls; + return AddMenuItemOutput{}; + } + smithy::Outcome CustomCode(const CustomCodeInput&) override { + ++calls; + return CustomCodeOutput{}; + } + smithy::Outcome GetEnum(const GetEnumInput&) override { + ++calls; + return GetEnumOutput{}; + } + smithy::Outcome GetIntEnum(const GetIntEnumInput&) override { + ++calls; + return GetIntEnumOutput{}; + } + smithy::Outcome GetMenu(const GetMenuInput&) override { + ++calls; + return GetMenuOutput{}; + } + smithy::Outcome HeaderEndpoint(const HeaderEndpointInput&) override { + ++calls; + return HeaderEndpointOutput{}; + } + smithy::Outcome Health(const HealthInput&) override { + ++calls; + return HealthOutput{}; + } + smithy::Outcome HttpPayloadRequiredWithDefault( + const HttpPayloadRequiredWithDefaultInput&) override { + ++calls; + return HttpPayloadRequiredWithDefaultOutput{}; + } + smithy::Outcome HttpPayloadWithDefault( + const HttpPayloadWithDefaultInput&) override { + ++calls; + return HttpPayloadWithDefaultOutput{}; + } + smithy::Outcome OpenUnions(const OpenUnionsInput&) override { + ++calls; + return OpenUnionsOutput{}; + } + smithy::Outcome RoundTrip(const RoundTripInput&) override { + ++calls; + return RoundTripOutput{}; + } + smithy::Outcome Version(const VersionInput&) override { + ++calls; + return VersionOutput{}; + } + int calls = 0; +}; + +class SimpleRestJsonMalformedTest : public testing::Test { + protected: + smithy::http::HttpResponse Send(smithy::http::HttpRequest request) { + return server_.Handler()(request); + } + + std::shared_ptr handler_ = std::make_shared(); + PizzaAdminServiceServer server_{handler_}; +}; + +TEST_F(SimpleRestJsonMalformedTest, UnknownRouteIs404) { + smithy::http::HttpRequest request; + request.method = "GET"; + request.target = "/no-such-route"; + EXPECT_EQ(Send(request).status, 404); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(SimpleRestJsonMalformedTest, WrongMethodIs405) { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/health"; + EXPECT_EQ(Send(request).status, 405); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(SimpleRestJsonMalformedTest, UnparseableJsonBodyIsSerializationException) { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/restaurant/r1/menu/item"; + request.headers.Set("content-type", "application/json"); + request.body = "{"; + const auto response = Send(request); + EXPECT_EQ(response.status, 400) << response.body; + EXPECT_EQ(response.headers.Get("x-error-type").value_or(""), "SerializationException"); + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(SimpleRestJsonMalformedTest, WrongContentTypeIs415) { + smithy::http::HttpRequest request; + request.method = "POST"; + request.target = "/restaurant/r1/menu/item"; + request.headers.Set("content-type", "text/plain"); + request.body = "{}"; + const auto response = Send(request); + EXPECT_EQ(response.status, 415) << response.body; + EXPECT_EQ(handler_->calls, 0); +} + +TEST_F(SimpleRestJsonMalformedTest, EnumLabelViolationReportsTheSuiteExactMessage) { + smithy::http::HttpRequest request; + request.method = "GET"; + request.target = "/get-enum/bogus"; + const auto response = Send(request); + EXPECT_EQ(response.status, 400) << response.body; + EXPECT_EQ(response.headers.Get("x-error-type").value_or(""), "ValidationException"); + EXPECT_EQ(handler_->calls, 0); + + const auto body = smithy::json::Decode(response.body); + ASSERT_TRUE(body.ok()) << response.body; + const smithy::Document* field_list = body->Find("fieldList"); + ASSERT_NE(field_list, nullptr) << response.body; + ASSERT_EQ(field_list->as_list().size(), 1u) << response.body; + const auto& failure = field_list->as_list()[0]; + EXPECT_EQ(failure.Find("path")->as_string(), "/aa"); + EXPECT_EQ(failure.Find("message")->as_string(), + "Value at '/aa' failed to satisfy constraint: Member must satisfy enum value set: " + "[v1, v2]"); +} + +} // namespace +} // namespace smithy::protocoltests::simplerestjson diff --git a/runtime/BUILD.bazel b/runtime/BUILD.bazel index 6209d362..1e857ec1 100644 --- a/runtime/BUILD.bazel +++ b/runtime/BUILD.bazel @@ -64,6 +64,7 @@ cc_library( name = "http", srcs = [ "src/http/headers.cc", + "src/http/http1.cc", "src/http/server_dispatch.cc", "src/http/socket_transport.cc", "src/http/trace_context.cc", @@ -71,6 +72,7 @@ cc_library( ], hdrs = [ "include/smithy/http/headers.h", + "include/smithy/http/http1.h", "include/smithy/http/loopback.h", "include/smithy/http/message.h", "include/smithy/http/server_dispatch.h", @@ -152,6 +154,19 @@ cc_test( ], ) +# Pure-parser twin of socket_transport_hostile_test: no sockets, so it runs +# byte-exact on every platform including Windows. +cc_test( + name = "http1_hostile_test", + size = "small", + srcs = ["tests/http/http1_hostile_test.cc"], + copts = COPTS, + deps = [ + ":http", + "@googletest//:gtest_main", + ], +) + cc_test( name = "socket_transport_hostile_test", size = "small", diff --git a/runtime/include/smithy/http/http1.h b/runtime/include/smithy/http/http1.h new file mode 100644 index 00000000..c3051e6b --- /dev/null +++ b/runtime/include/smithy/http/http1.h @@ -0,0 +1,49 @@ +#ifndef SMITHY_HTTP_HTTP1_H_ +#define SMITHY_HTTP_HTTP1_H_ + +#include +#include +#include +#include + +#include "smithy/core/outcome.h" +#include "smithy/http/headers.h" + +namespace smithy::http { + +// The HTTP/1.1 message reader behind SocketHttpClient/SocketHttpServer, +// factored out of the socket layer so hostile-input tests and the fuzz +// harness can drive it byte-for-byte without a live file descriptor. +// +// The framing contract is deliberately strict — both transports emit +// Connection: close, so a message is a definite Content-Length body or (for +// responses) body-until-EOF. Chunked transfer-encoding and conflicting +// content-lengths are rejected outright: they are the classic +// request-smuggling desync vectors. + +struct Http1Message { + std::string start_line; + Headers headers; + std::string body; +}; + +// Byte source with recv() semantics: fills up to `capacity` bytes of +// `buffer`, returns the count read, 0 on EOF, negative on error. +using Http1ReadFn = std::function; + +// Reads one HTTP/1.1 message from the byte source. When body_until_eof is +// true (client reading a response), a message without Content-Length extends +// to EOF; when false (server reading a request), it ends with the headers. +Outcome ReadHttp1Message(const Http1ReadFn& read, bool body_until_eof); + +// Splits a request line "GET /target HTTP/1.1" into method and target; +// false when the line does not have its two spaces. +bool ParseRequestLine(std::string_view line, std::string* method, std::string* target); + +// Extracts the status code from a status line "HTTP/1.1 200 OK"; an error +// when the line is not HTTP-shaped or the status is implausible. +Outcome ParseStatusLine(std::string_view line); + +} // namespace smithy::http + +#endif // SMITHY_HTTP_HTTP1_H_ diff --git a/runtime/src/http/http1.cc b/runtime/src/http/http1.cc new file mode 100644 index 00000000..c20142c4 --- /dev/null +++ b/runtime/src/http/http1.cc @@ -0,0 +1,121 @@ +#include "smithy/http/http1.h" + +#include +#include +#include +#include + +#include "smithy/core/error.h" + +namespace smithy::http { +namespace { + +constexpr std::size_t kMaxHeaderBytes = std::size_t{64} * 1024; +constexpr std::size_t kMaxBodyBytes = std::size_t{64} * 1024 * 1024; + +} // namespace + +Outcome ReadHttp1Message(const Http1ReadFn& read, bool body_until_eof) { + std::string buffer; + std::size_t header_end = std::string::npos; + std::array chunk{}; + while (header_end == std::string::npos) { + if (buffer.size() > kMaxHeaderBytes) return Error::Transport("http: headers too large"); + const long received = read(chunk.data(), chunk.size()); + if (received < 0) return Error::Transport("http: read failed"); + if (received == 0) return Error::Transport("http: connection closed mid-headers"); + buffer.append(chunk.data(), static_cast(received)); + header_end = buffer.find("\r\n\r\n"); + } + + Http1Message message; + std::string_view head(buffer.data(), header_end); + const auto line_end = head.find("\r\n"); + message.start_line = std::string(head.substr(0, line_end)); + std::string_view header_block = + line_end == std::string_view::npos ? std::string_view{} : head.substr(line_end + 2); + while (!header_block.empty()) { + const auto eol = header_block.find("\r\n"); + const std::string_view line = + eol == std::string_view::npos ? header_block : header_block.substr(0, eol); + const auto colon = line.find(':'); + if (colon == std::string_view::npos) return Error::Transport("http: malformed header line"); + std::string_view name = line.substr(0, colon); + std::string_view value = line.substr(colon + 1); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { + value.remove_suffix(1); + } + message.headers.Add(name, value); + if (eol == std::string_view::npos) break; + header_block.remove_prefix(eol + 2); + } + + // Reject ambiguous or unsupported framing before trusting a body length — + // the classic request-smuggling desync vectors. Neither transport direction + // implements chunked transfer, and conflicting content-lengths let a proxy + // and this server disagree on message boundaries. + if (message.headers.GetAll("content-length").size() > 1) { + return Error::Transport("http: conflicting content-length"); + } + if (message.headers.Has("transfer-encoding")) { + return Error::Transport("http: transfer-encoding is not supported"); + } + + message.body = buffer.substr(header_end + 4); + if (const auto length_text = message.headers.Get("content-length")) { + // Strict RFC 9110 field value: digits only. strtoull alone would also + // accept "+4", leading whitespace, or an empty value (as 0) — laxity a + // desync attack can hide in. Overflow clamps to ULLONG_MAX, which the + // size cap below rejects. + if (length_text->empty() || length_text->find_first_not_of("0123456789") != std::string::npos) { + return Error::Transport("http: invalid content-length"); + } + char* end = nullptr; + const unsigned long long length = std::strtoull(length_text->c_str(), &end, 10); + if (end != length_text->c_str() + length_text->size() || length > kMaxBodyBytes) { + return Error::Transport("http: invalid content-length"); + } + while (message.body.size() < length) { + const long received = read(chunk.data(), chunk.size()); + if (received <= 0) return Error::Transport("http: connection closed mid-body"); + message.body.append(chunk.data(), static_cast(received)); + } + if (message.body.size() != length) return Error::Transport("http: excess body bytes"); + } else if (body_until_eof) { + while (true) { + if (message.body.size() > kMaxBodyBytes) return Error::Transport("http: body too large"); + const long received = read(chunk.data(), chunk.size()); + if (received < 0) return Error::Transport("http: read failed"); + if (received == 0) break; + message.body.append(chunk.data(), static_cast(received)); + } + } + return message; +} + +bool ParseRequestLine(std::string_view line, std::string* method, std::string* target) { + const auto first = line.find(' '); + const auto second = + first == std::string_view::npos ? std::string_view::npos : line.find(' ', first + 1); + if (second == std::string_view::npos) return false; + *method = std::string(line.substr(0, first)); + *target = std::string(line.substr(first + 1, second - first - 1)); + return true; +} + +Outcome ParseStatusLine(std::string_view line) { + const auto space = line.find(' '); + if (space == std::string_view::npos || line.size() < space + 4 || line.substr(0, 5) != "HTTP/") { + return Error::Transport("http: malformed status line: " + std::string(line)); + } + const int status = std::atoi(std::string(line.substr(space + 1)).c_str()); + if (status < 100 || status > 599) { + return Error::Transport("http: implausible status in: " + std::string(line)); + } + return status; +} + +} // namespace smithy::http diff --git a/runtime/src/http/socket_transport.cc b/runtime/src/http/socket_transport.cc index c6b87cba..e199c7b8 100644 --- a/runtime/src/http/socket_transport.cc +++ b/runtime/src/http/socket_transport.cc @@ -1,8 +1,6 @@ #include "smithy/http/socket_transport.h" -#include -#include -#include +#include #include #include @@ -19,6 +17,7 @@ #include #endif +#include "smithy/http/http1.h" #include "smithy/http/server_dispatch.h" namespace smithy::http { @@ -83,89 +82,16 @@ bool SendAll(SocketFd fd, std::string_view data) { return true; } -constexpr std::size_t kMaxHeaderBytes = std::size_t{64} * 1024; -constexpr std::size_t kMaxBodyBytes = std::size_t{64} * 1024 * 1024; - -struct ParsedMessage { - std::string start_line; - Headers headers; - std::string body; -}; - -// Reads one HTTP/1.1 message. For responses without Content-Length the body -// extends to EOF (we always request/emit Connection: close). -Outcome ReadMessage(SocketFd fd, bool body_until_eof) { - std::string buffer; - std::size_t header_end = std::string::npos; - std::array chunk{}; - while (header_end == std::string::npos) { - if (buffer.size() > kMaxHeaderBytes) return Error::Transport("http: headers too large"); - const auto received = recv(fd, chunk.data(), static_cast(chunk.size()), 0); - if (received < 0) return Error::Transport("http: read failed"); - if (received == 0) return Error::Transport("http: connection closed mid-headers"); - buffer.append(chunk.data(), static_cast(received)); - header_end = buffer.find("\r\n\r\n"); - } - - ParsedMessage message; - std::string_view head(buffer.data(), header_end); - const auto line_end = head.find("\r\n"); - message.start_line = std::string(head.substr(0, line_end)); - std::string_view header_block = - line_end == std::string_view::npos ? std::string_view{} : head.substr(line_end + 2); - while (!header_block.empty()) { - const auto eol = header_block.find("\r\n"); - const std::string_view line = - eol == std::string_view::npos ? header_block : header_block.substr(0, eol); - const auto colon = line.find(':'); - if (colon == std::string_view::npos) return Error::Transport("http: malformed header line"); - std::string_view name = line.substr(0, colon); - std::string_view value = line.substr(colon + 1); - while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { - value.remove_prefix(1); - } - while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { - value.remove_suffix(1); - } - message.headers.Add(name, value); - if (eol == std::string_view::npos) break; - header_block.remove_prefix(eol + 2); - } - - // Reject ambiguous or unsupported framing before trusting a body length — - // the classic request-smuggling desync vectors. Neither transport direction - // implements chunked transfer, and conflicting content-lengths let a proxy - // and this server disagree on message boundaries. - if (message.headers.GetAll("content-length").size() > 1) { - return Error::Transport("http: conflicting content-length"); - } - if (message.headers.Has("transfer-encoding")) { - return Error::Transport("http: transfer-encoding is not supported"); - } - - message.body = buffer.substr(header_end + 4); - if (const auto length_text = message.headers.Get("content-length")) { - char* end = nullptr; - const unsigned long long length = std::strtoull(length_text->c_str(), &end, 10); - if (end != length_text->c_str() + length_text->size() || length > kMaxBodyBytes) { - return Error::Transport("http: invalid content-length"); - } - while (message.body.size() < length) { - const auto received = recv(fd, chunk.data(), static_cast(chunk.size()), 0); - if (received <= 0) return Error::Transport("http: connection closed mid-body"); - message.body.append(chunk.data(), static_cast(received)); - } - if (message.body.size() != length) return Error::Transport("http: excess body bytes"); - } else if (body_until_eof) { - while (true) { - if (message.body.size() > kMaxBodyBytes) return Error::Transport("http: body too large"); - const auto received = recv(fd, chunk.data(), static_cast(chunk.size()), 0); - if (received < 0) return Error::Transport("http: read failed"); - if (received == 0) break; - message.body.append(chunk.data(), static_cast(received)); - } - } - return message; +// Reads one HTTP/1.1 message (the parser itself lives in http1.cc, where the +// hostile-input bank and the fuzz harness exercise it without a socket). For +// responses without Content-Length the body extends to EOF (we always +// request/emit Connection: close). +Outcome ReadMessage(SocketFd fd, bool body_until_eof) { + return ReadHttp1Message( + [fd](char* buffer, std::size_t capacity) { + return static_cast(recv(fd, buffer, static_cast(capacity), 0)); + }, + body_until_eof); } } // namespace @@ -220,16 +146,10 @@ Outcome SocketHttpClient::Send(const HttpRequest& request) { if (!message) return std::move(message).error(); // Status line: "HTTP/1.1 200 OK". - const std::string& line = message->start_line; - const auto space = line.find(' '); - if (space == std::string::npos || line.size() < space + 4 || line.compare(0, 5, "HTTP/") != 0) { - return Error::Transport("http: malformed status line: " + line); - } + auto status = ParseStatusLine(message->start_line); + if (!status) return std::move(status).error(); HttpResponse response; - response.status = std::atoi(line.c_str() + space + 1); - if (response.status < 100 || response.status > 599) { - return Error::Transport("http: implausible status in: " + line); - } + response.status = *status; response.headers = std::move(message->headers); response.body = std::move(message->body); return response; @@ -287,16 +207,10 @@ void SocketHttpServer::AcceptLoop() { HttpResponse response; if (message) { // Request line: "GET /target HTTP/1.1". - const std::string& line = message->start_line; - const auto first = line.find(' '); - const auto second = - first == std::string::npos ? std::string::npos : line.find(' ', first + 1); - if (second == std::string::npos) { + HttpRequest request; + if (!ParseRequestLine(message->start_line, &request.method, &request.target)) { response = HttpResponse{400, {}, "malformed request line"}; } else { - HttpRequest request; - request.method = line.substr(0, first); - request.target = line.substr(first + 1, second - first - 1); request.headers = std::move(message->headers); request.body = std::move(message->body); response = InvokeHandlerGuarded(handler_, request); diff --git a/runtime/tests/http/http1_hostile_test.cc b/runtime/tests/http/http1_hostile_test.cc new file mode 100644 index 00000000..0099d368 --- /dev/null +++ b/runtime/tests/http/http1_hostile_test.cc @@ -0,0 +1,179 @@ +// Hostile-input bank for the HTTP/1.1 message reader, at the pure-parser +// level (no sockets, so unlike socket_transport_hostile_test.cc this runs on +// every platform, byte-exact and timeout-free). The socket-level suite pins +// the server's observable behavior; this bank pins the parser's verdict on +// each framing attack individually, including client-side response framing +// that no socket test drives. + +#include + +#include +#include +#include +#include + +#include "smithy/http/http1.h" + +namespace smithy::http { +namespace { + +// Reads from an in-memory wire capture, `chunk` bytes per call, EOF after. +Http1ReadFn FromString(std::string wire, std::size_t chunk = 8192) { + auto offset = std::make_shared(0); + auto data = std::make_shared(std::move(wire)); + return [offset, data, chunk](char* buffer, std::size_t capacity) -> long { + const std::size_t want = capacity < chunk ? capacity : chunk; + const std::size_t left = data->size() - *offset; + const std::size_t take = want < left ? want : left; + if (take == 0) return 0; + data->copy(buffer, take, *offset); + *offset += take; + return static_cast(take); + }; +} + +Outcome Parse(const std::string& wire, bool body_until_eof = false, + std::size_t chunk = 8192) { + return ReadHttp1Message(FromString(wire, chunk), body_until_eof); +} + +TEST(Http1HostileTest, RejectsSmugglingFraming) { + const struct { + const char* wire; + const char* why; + } bank[] = { + {"POST / HTTP/1.1\r\ncontent-length: 4\r\ncontent-length: 4\r\n\r\nabcd", + "duplicate content-length, even when they agree"}, + {"POST / HTTP/1.1\r\ncontent-length: 4\r\ncontent-length: 11\r\n\r\nabcd", + "conflicting content-length"}, + {"POST / HTTP/1.1\r\nContent-Length: 4\r\ncontent-LENGTH: 11\r\n\r\nabcd", + "conflicting content-length across case spellings"}, + {"POST / HTTP/1.1\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n", + "chunked transfer-encoding is unsupported"}, + {"POST / HTTP/1.1\r\ntransfer-encoding: identity\r\n\r\n", + "any transfer-encoding is unsupported"}, + {"POST / HTTP/1.1\r\ncontent-length: 4\r\ntransfer-encoding: chunked\r\n\r\nabcd", + "TE + CL is the classic desync"}, + }; + for (const auto& c : bank) { + EXPECT_FALSE(Parse(c.wire).ok()) << c.why; + } +} + +TEST(Http1HostileTest, RejectsHostileContentLengths) { + const struct { + const char* wire; + const char* why; + } bank[] = { + {"POST / HTTP/1.1\r\ncontent-length: abc\r\n\r\n", "non-numeric"}, + {"POST / HTTP/1.1\r\ncontent-length: 4x\r\n\r\nabcd", "trailing junk"}, + {"POST / HTTP/1.1\r\ncontent-length: 0x10\r\n\r\n", "hex"}, + {"POST / HTTP/1.1\r\ncontent-length: 4 4\r\n\r\nabcd", "embedded space"}, + {"POST / HTTP/1.1\r\ncontent-length: -1\r\n\r\n", "negative"}, + {"POST / HTTP/1.1\r\ncontent-length: +4\r\n\r\nabcd", "plus-signed"}, + {"POST / HTTP/1.1\r\ncontent-length: 67108865\r\n\r\n", "one over the 64 MiB cap"}, + {"POST / HTTP/1.1\r\ncontent-length: 18446744073709551617\r\n\r\n", + "2^64+1 overflows strtoull; must reject, not truncate"}, + {"POST / HTTP/1.1\r\ncontent-length: 99999999999999999999\r\n\r\n", + "over-uint64 must not become a small number"}, + {"POST / HTTP/1.1\r\ncontent-length:\r\n\r\n", "empty value"}, + }; + for (const auto& c : bank) { + EXPECT_FALSE(Parse(c.wire).ok()) << c.why; + } +} + +TEST(Http1HostileTest, RejectsMalformedHeaderBlocks) { + const struct { + const char* wire; + const char* why; + } bank[] = { + {"GET / HTTP/1.1\r\nno-colon-here\r\n\r\n", "header line without a colon"}, + {"GET / HTTP/1.1\r\n continued\r\n\r\n", "obs-fold continuation line"}, + {"GET / HTTP/1.1\r\nx: 1\r\ngarbage\r\n\r\n", "later line without a colon"}, + }; + for (const auto& c : bank) { + EXPECT_FALSE(Parse(c.wire).ok()) << c.why; + } +} + +TEST(Http1HostileTest, RejectsTruncationEverywhere) { + // Any strict prefix of a complete request must fail (headers never + // terminate) or report a mid-body close — never a parsed message. + const std::string full = "POST /x HTTP/1.1\r\nx-a: 1\r\ncontent-length: 4\r\n\r\nabcd"; + for (std::size_t cut = 0; cut < full.size(); ++cut) { + EXPECT_FALSE(Parse(full.substr(0, cut)).ok()) << "cut at " << cut; + } +} + +TEST(Http1HostileTest, RejectsOversizedSections) { + // Headers past the 64 KiB cap. + std::string flood = "GET / HTTP/1.1\r\n"; + for (int i = 0; i < 3000; ++i) flood += "x-h" + std::to_string(i) + ": aaaaaaaaaaaaaaaa\r\n"; + flood += "\r\n"; + EXPECT_FALSE(Parse(flood).ok()) << "header flood"; + + // Excess body bytes beyond the declared length. + EXPECT_FALSE(Parse("POST / HTTP/1.1\r\ncontent-length: 2\r\n\r\nabcd").ok()) + << "body longer than content-length"; + + // Read-error propagation. + EXPECT_FALSE(ReadHttp1Message([](char*, std::size_t) -> long { return -1; }, false).ok()); + EXPECT_FALSE(Parse("").ok()) << "immediate EOF"; +} + +TEST(Http1HostileTest, AcceptsStrictButUglyMessages) { + // Exact content-length, byte-at-a-time delivery. + auto message = Parse("POST /x HTTP/1.1\r\ncontent-length: 4\r\n\r\nabcd", false, 1); + ASSERT_TRUE(message.ok()); + EXPECT_EQ(message->start_line, "POST /x HTTP/1.1"); + EXPECT_EQ(message->body, "abcd"); + + // Tab/space padding trims; header lookup is case-insensitive; empty values + // and repeated non-framing headers are legal. + message = + Parse("GET / HTTP/1.1\r\nX-Padded: \t v \t \r\nx-empty:\r\nx-dup: a\r\nx-dup: b\r\n\r\n"); + ASSERT_TRUE(message.ok()); + EXPECT_EQ(message->headers.Get("x-padded"), "v"); + EXPECT_EQ(message->headers.Get("x-empty"), ""); + EXPECT_EQ(message->headers.GetAll("x-dup").size(), 2u); + + // Response framing: no content-length, body extends to EOF. + message = Parse("HTTP/1.1 200 OK\r\nx: 1\r\n\r\npayload beyond headers", true, 3); + ASSERT_TRUE(message.ok()); + EXPECT_EQ(message->body, "payload beyond headers"); + + // Same message read server-side (no EOF body): the body is empty. + message = Parse("HTTP/1.1 200 OK\r\nx: 1\r\n\r\n", false); + ASSERT_TRUE(message.ok()); + EXPECT_TRUE(message->body.empty()); + + // content-length: 0 with nothing after it. + message = Parse("POST / HTTP/1.1\r\ncontent-length: 0\r\n\r\n"); + ASSERT_TRUE(message.ok()); + EXPECT_TRUE(message->body.empty()); +} + +TEST(Http1HostileTest, StartLineHelpersMatchTheTransports) { + std::string method; + std::string target; + ASSERT_TRUE(ParseRequestLine("GET /a/b?q=1 HTTP/1.1", &method, &target)); + EXPECT_EQ(method, "GET"); + EXPECT_EQ(target, "/a/b?q=1"); + EXPECT_FALSE(ParseRequestLine("GET", &method, &target)); + EXPECT_FALSE(ParseRequestLine("GET /only-one-space", &method, &target)); + EXPECT_FALSE(ParseRequestLine("", &method, &target)); + + auto status = ParseStatusLine("HTTP/1.1 200 OK"); + ASSERT_TRUE(status.ok()); + EXPECT_EQ(*status, 200); + EXPECT_EQ(*ParseStatusLine("HTTP/1.1 599 "), 599); + EXPECT_FALSE(ParseStatusLine("HTTP/1.1 007 James").ok()) << "sub-100 status"; + EXPECT_FALSE(ParseStatusLine("HTTP/1.1 999 nope").ok()) << "over-599 status"; + EXPECT_FALSE(ParseStatusLine("ICY 200 OK").ok()) << "not HTTP"; + EXPECT_FALSE(ParseStatusLine("HTTP/1.1").ok()) << "no space"; + EXPECT_FALSE(ParseStatusLine("").ok()); +} + +} // namespace +} // namespace smithy::http