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 committed 2026-08-19 06:54:21 -04:00
1 parent 0151b3e23e
commit abf525d78c
2 files changed
+163 -25

No files matched your search

+92 -25
View File
@@ -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 {