diff --git a/httplib.h b/httplib.h index b666a6a2..ed49c83b 100644 --- a/httplib.h +++ b/httplib.h @@ -9580,38 +9580,105 @@ public: static WSInit wsinit_; #endif +// RFC 9110 Section 11.6.1 defines a challenge list as +// WWW-Authenticate = #challenge +// challenge = auth-scheme [ 1*SP ( token68 / [ #auth-param ] ) ] +// auth-param = token BWS "=" BWS ( token / quoted-string ) +// so a server may offer several schemes, each with its own comma-separated +// auth-param list, in either order and either as separate field lines or +// packed into one. Splitting on every comma would break apart a challenge's +// own param list; splitting only on the first space would miss a Digest +// challenge that isn't first. Split on commas that aren't inside a +// quoted-string instead, then track which scheme each resulting segment +// belongs to: a segment whose text before "=" contains whitespace (or that +// has no "=" at all) starts a new challenge named by its leading token. +inline std::vector split_challenge_segments(const std::string &s) { + std::vector segments; + size_t start = 0; + auto in_quotes = false; + for (size_t i = 0; i < s.size(); i++) { + auto c = s[i]; + if (in_quotes) { + if (c == '\\' && i + 1 < s.size()) { + i++; + } else if (c == '"') { + in_quotes = false; + } + } else if (c == '"') { + in_quotes = true; + } else if (c == ',') { + segments.push_back(s.substr(start, i - start)); + start = i + 1; + } + } + segments.push_back(s.substr(start)); + return segments; +} + +inline std::string unescape_quoted_pairs(const std::string &s) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == '\\' && i + 1 < s.size()) { + out += s[++i]; + } else { + out += s[i]; + } + } + return out; +} + inline bool parse_www_authenticate(const Response &res, std::map &auth, bool is_proxy) { auto auth_key = is_proxy ? "Proxy-Authenticate" : "WWW-Authenticate"; - if (res.has_header(auth_key)) { - thread_local auto re = - std::regex(R"~((?:(?:,\s*)?(.+?)=(?:"(.*?)"|([^,]*))))~"); - auto s = res.get_header_value(auth_key); - auto pos = s.find(' '); - if (pos != std::string::npos) { - auto type = s.substr(0, pos); - if (type == "Basic") { - return false; - } else if (type == "Digest") { - s = s.substr(pos + 1); - auto beg = std::sregex_iterator(s.begin(), s.end(), re); - for (auto i = beg; i != std::sregex_iterator(); ++i) { - const auto &m = *i; - auto key = s.substr(static_cast(m.position(1)), - static_cast(m.length(1))); - auto val = m.length(2) > 0 - ? s.substr(static_cast(m.position(2)), - static_cast(m.length(2))) - : s.substr(static_cast(m.position(3)), - static_cast(m.length(3))); - auth[std::move(key)] = std::move(val); - } - return true; + auto combined = get_combined_header_value(res.headers, auth_key); + if (combined.empty()) { return false; } + + auto found_digest = false; + auto in_digest_challenge = false; + for (const auto &raw_segment : split_challenge_segments(combined)) { + auto segment = trim_copy(raw_segment); + if (segment.empty()) { continue; } + + auto eq_pos = segment.find('='); + // BWS is allowed on both sides of "=", so the text naming the key (or, + // for the first segment of a challenge, " ") must be + // trimmed before its boundaries are inspected. + auto key_part = trim_copy( + eq_pos == std::string::npos ? segment : segment.substr(0, eq_pos)); + auto space_pos = key_part.find_last_of(" \t"); + if (space_pos != std::string::npos || eq_pos == std::string::npos) { + // "[ ]" starts a new challenge. + auto scheme_end = + space_pos == std::string::npos ? key_part.size() : space_pos; + // RFC 7616 Section 3.7: a server may offer more than one Digest + // challenge (e.g. SHA-256 and MD5); keep only the first so a nonce + // from one challenge is never paired with another's algorithm. + in_digest_challenge = + !found_digest && + case_ignore::equal(key_part.substr(0, scheme_end), "Digest"); + if (in_digest_challenge) { found_digest = true; } + if (space_pos == std::string::npos) { + // Bare scheme (or a token68), no auth-param on this segment. + continue; } + key_part = key_part.substr(space_pos + 1); } + + if (!in_digest_challenge) { continue; } + + auto val = trim_copy(segment.substr(eq_pos + 1)); + auto unquoted = trim_double_quotes_copy(val); + if (unquoted.size() != val.size()) { + unquoted = unescape_quoted_pairs(unquoted); + } + auth[std::move(key_part)] = std::move(unquoted); } - return false; + + // A challenge with no auth-param can't produce a usable Authorization + // header, so treat it the same as no Digest challenge at all. + return found_digest && !auth.empty(); } class ContentProviderAdapter { diff --git a/test/test.cc b/test/test.cc index d3c9c28e..fa52c349 100644 --- a/test/test.cc +++ b/test/test.cc @@ -2869,6 +2869,77 @@ TEST(DigestAuthTest, FromHTTPWatch_Online) { } } +// RFC 9110 Section 11.6.1: a WWW-Authenticate field value is a +// comma-separated list of challenges, and each challenge may itself carry a +// comma-separated auth-param list, so a server can legally offer Basic +// before Digest, either as two field lines or packed into one. Runs one 401 +// -> Digest-retry round trip with the given field lines (get_combined_header_ +// value() joins them in receipt order) and checks that the retry carries a +// well-formed Digest Authorization header naming expected_realm. +static void +run_digest_challenge_list_test(const std::vector &challenges, + const std::string &expected_realm) { + std::atomic hits{0}; + + Server svr; + svr.Get("/x", [&](const Request &req, Response &res) { + if (++hits == 1) { + res.status = StatusCode::Unauthorized_401; + for (const auto &challenge : challenges) { + res.set_header("WWW-Authenticate", challenge); + } + } else { + auto authorization = req.get_header_value("Authorization"); + EXPECT_EQ(0u, authorization.rfind("Digest ", 0)); + EXPECT_NE(std::string::npos, + authorization.find("realm=\"" + expected_realm + "\"")); + EXPECT_EQ(std::string::npos, authorization.find("Basic")); + res.set_content("ok", "text/plain"); + } + }); + + auto port = svr.bind_to_any_port(HOST); + std::thread t([&]() { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + Client cli(HOST, port); + cli.set_digest_auth("hello", "world"); + auto res = cli.Get("/x"); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + EXPECT_EQ(2, hits.load()); +} + +static const char *kBasicChallenge = "Basic realm=\"decoy\""; +static const char *kDigestChallenge = + "Digest realm=\"testrealm\", qop=\"auth\", nonce=\"abc123\", " + "algorithm=MD5"; + +TEST(DigestAuthTest, BasicChallengeListedBeforeDigest) { + run_digest_challenge_list_test({kBasicChallenge, kDigestChallenge}, + "testrealm"); +} + +// Same challenge list with the schemes in the opposite order, to make sure +// the fix above didn't just special-case "Basic first". +TEST(DigestAuthTest, DigestChallengeListedBeforeBasic) { + run_digest_challenge_list_test({kDigestChallenge, kBasicChallenge}, + "testrealm"); +} + +// A comma inside a quoted auth-param value must not be mistaken for the +// separator between two challenges (or two auth-params). +TEST(DigestAuthTest, RealmContainingCommaIsNotSplit) { + run_digest_challenge_list_test( + {"Digest realm=\"test,realm\", qop=\"auth\", nonce=\"abc123\", " + "algorithm=MD5"}, + "test,realm"); +} + #endif TEST(SpecifyServerIPAddressTest, AnotherHostname_Online) {