Parse WWW-Authenticate/Proxy-Authenticate as an RFC 9110 challenge list

detail::parse_www_authenticate() assumed a single challenge starting at
the first space in the field value and read only its first occurrence,
so a Basic challenge listed before Digest (or split across two field
lines, as some servers do) hid the Digest challenge entirely, and a
second Digest challenge with different parameters (RFC 7616 offering
both SHA-256 and MD5) could mix params from both. Combine repeated
field lines the same way the other list-valued headers do, then split
on commas that aren't inside a quoted-string so a quoted realm can
contain a comma, and track which challenge each auth-param belongs to
by the auth-scheme token that starts it. Also require at least one
auth-param before reporting a Digest challenge as found, since an
empty challenge can't produce a usable Authorization header.
This commit is contained in:
yhirose
2026-08-19 06:54:21 -04:00
parent 0151b3e23e
commit abf525d78c
2 changed files with 163 additions and 25 deletions
+71
View File
@@ -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<std::string> &challenges,
const std::string &expected_realm) {
std::atomic<int> 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) {