diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8d1ccc..c680a0dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed +- **Bearer scheme matches RFC 6750 / RFC 9110.** `extract_bearer` now + treats the auth-scheme as case-insensitive and skips `1*SP` after it, + so `bearer` / `BEARER` and extra spaces no longer 401 a valid token. - **Inbound HTTP body honors Content-Length.** `parse_http_request` now takes at most `Content-Length` bytes from the header-read leftover, so `Content-Length: 0` stays empty and extra pipelined or smuggled bytes diff --git a/CMakeLists.txt b/CMakeLists.txt index 61f6b1dd..bb999ccb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1340,6 +1340,17 @@ if(INDEX_BUILD_TESTS) target_link_libraries(unit_sse_mailbox PRIVATE Threads::Threads) add_test(NAME unit_sse_mailbox COMMAND unit_sse_mailbox) + # --- Unit tests for RFC 6750 / RFC 9110 Bearer field parsing --- + add_executable(unit_auth + tests/test_auth.cpp + src/api/http_util.cpp + ) + target_include_directories(unit_auth PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/doctest + ) + add_test(NAME unit_auth COMMAND unit_auth) + # --- Unit tests for /v1/events agent routing --- add_executable(unit_route_event tests/test_route_event.cpp diff --git a/include/api/http_util.h b/include/api/http_util.h index 167894ca..73c094fd 100644 --- a/include/api/http_util.h +++ b/include/api/http_util.h @@ -13,4 +13,9 @@ std::vector split_path(const std::string& path); std::string url_decode(const std::string& s); std::map parse_query(const std::string& path); +// RFC 6750 §2.1 / RFC 9110 §11.1: parse an Authorization field value. +// Auth-scheme is case-insensitive; one or more SP separate scheme and +// token. Returns empty when the field is not Bearer credentials. +std::string parse_bearer_authorization(const std::string& field_value); + } // namespace arbiter diff --git a/src/api/auth.cpp b/src/api/auth.cpp index c6edc92a..49ecf8d7 100644 --- a/src/api/auth.cpp +++ b/src/api/auth.cpp @@ -2,22 +2,18 @@ #include "api/auth.h" #include "api/http_response.h" +#include "api/http_util.h" #include "tenant_store.h" namespace arbiter { -// Extract the bearer token from an Authorization header, or empty if missing. +// Header lookup only — scheme grammar lives in parse_bearer_authorization +// (RFC 6750 / RFC 9110: case-insensitive scheme, 1*SP). std::string extract_bearer(const HttpRequest& req) { auto it = req.headers.find("authorization"); if (it == req.headers.end()) return {}; - static constexpr const char* kPrefix = "Bearer "; - static constexpr size_t kPrefixLen = 7; - const std::string& hdr = it->second; - if (hdr.size() <= kPrefixLen || - hdr.compare(0, kPrefixLen, kPrefix) != 0) - return {}; - return hdr.substr(kPrefixLen); + return parse_bearer_authorization(it->second); } // Single-threaded helper for request handlers: refresh a Tenant snapshot diff --git a/src/api/http_util.cpp b/src/api/http_util.cpp index 153b0f39..2f7608fd 100644 --- a/src/api/http_util.cpp +++ b/src/api/http_util.cpp @@ -2,6 +2,7 @@ #include "api/http_util.h" +#include #include #include #include @@ -67,4 +68,30 @@ std::map parse_query(const std::string& path) { return out; } +std::string parse_bearer_authorization(const std::string& field_value) { + // RFC 9110 §11.1: authentication schemes are case-insensitive. + // RFC 6750 §2.1: credentials = "Bearer" 1*SP token68 + // + // A literal "Bearer " prefix 401s clients that send `bearer`, + // `BEARER`, or two spaces after the scheme. Trailing OWS on + // the field value is stripped so a proxy-appended space does not + // become part of the token hash. + static constexpr char kScheme[] = "bearer"; + static constexpr size_t kSchemeLen = 6; + if (field_value.size() < kSchemeLen + 2) return {}; + for (size_t i = 0; i < kSchemeLen; ++i) { + const unsigned char c = static_cast(field_value[i]); + if (static_cast(std::tolower(c)) != kScheme[i]) return {}; + } + size_t i = kSchemeLen; + if (i >= field_value.size() || field_value[i] != ' ') return {}; + while (i < field_value.size() && field_value[i] == ' ') ++i; + if (i >= field_value.size()) return {}; + size_t end = field_value.size(); + while (end > i && (field_value[end - 1] == ' ' || field_value[end - 1] == '\t')) + --end; + if (end <= i) return {}; + return field_value.substr(i, end - i); +} + } // namespace arbiter diff --git a/tests/test_auth.cpp b/tests/test_auth.cpp new file mode 100644 index 00000000..31e10e10 --- /dev/null +++ b/tests/test_auth.cpp @@ -0,0 +1,39 @@ +// tests/test_auth.cpp — RFC 6750 / RFC 9110 Bearer Authorization parsing. + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" +#include "api/http_util.h" + +using namespace arbiter; + +TEST_CASE("canonical Bearer scheme extracts the token") { + CHECK(parse_bearer_authorization("Bearer atr_secret") == "atr_secret"); +} + +TEST_CASE("auth-scheme is case-insensitive") { + CHECK(parse_bearer_authorization("bearer atr_secret") == "atr_secret"); + CHECK(parse_bearer_authorization("BEARER atr_secret") == "atr_secret"); + CHECK(parse_bearer_authorization("BeArEr atr_secret") == "atr_secret"); +} + +TEST_CASE("one or more spaces after the scheme are skipped") { + CHECK(parse_bearer_authorization("Bearer atr_secret") == "atr_secret"); + CHECK(parse_bearer_authorization("bearer atr_secret") == "atr_secret"); +} + +TEST_CASE("trailing OWS is not part of the token") { + CHECK(parse_bearer_authorization("Bearer atr_secret ") == "atr_secret"); + CHECK(parse_bearer_authorization("Bearer atr_secret\t") == "atr_secret"); + CHECK(parse_bearer_authorization("Bearer atr_secret \t ") == "atr_secret"); +} + +TEST_CASE("non-Bearer schemes and malformed fields are rejected") { + CHECK(parse_bearer_authorization("").empty()); + CHECK(parse_bearer_authorization("Bearer").empty()); + CHECK(parse_bearer_authorization("Bearer ").empty()); + CHECK(parse_bearer_authorization("Bearer ").empty()); + CHECK(parse_bearer_authorization("Bearer\tatr_secret").empty()); + CHECK(parse_bearer_authorization("Basic atr_secret").empty()); + CHECK(parse_bearer_authorization("XBearer atr_secret").empty()); + CHECK(parse_bearer_authorization("Bearerat_secret").empty()); +}