mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-10-01 05:02:29 +07:00
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:
@@ -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<std::string> split_challenge_segments(const std::string &s) {
|
||||
std::vector<std::string> 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<std::string, std::string> &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<size_t>(m.position(1)),
|
||||
static_cast<size_t>(m.length(1)));
|
||||
auto val = m.length(2) > 0
|
||||
? s.substr(static_cast<size_t>(m.position(2)),
|
||||
static_cast<size_t>(m.length(2)))
|
||||
: s.substr(static_cast<size_t>(m.position(3)),
|
||||
static_cast<size_t>(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, "<scheme> <key>") 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) {
|
||||
// "<scheme>[ <key>]" 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user