Skip to content

Commit

Permalink
OAuth2: add a nonce to the state parameter (#35919)
Browse files Browse the repository at this point in the history
Signed-off-by: Huabing Zhao <zhaohuabing@gmail.com>
  • Loading branch information
zhaohuabing committed Sep 16, 2024
1 parent 52bd9ba commit b6c24ab
Show file tree
Hide file tree
Showing 6 changed files with 538 additions and 247 deletions.
6 changes: 5 additions & 1 deletion api/envoy/extensions/filters/http/oauth2/v3/oauth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE;

// [#next-free-field: 6]
message OAuth2Credentials {
// [#next-free-field: 6]
// [#next-free-field: 7]
message CookieNames {
// Cookie name to hold OAuth bearer token value. When the authentication server validates the
// client and returns an authorization token back to the OAuth filter, no matter what format
Expand All @@ -52,6 +52,10 @@ message OAuth2Credentials {
// Cookie name to hold the refresh token. Defaults to ``RefreshToken``.
string refresh_token = 5
[(validate.rules).string = {well_known_regex: HTTP_HEADER_NAME ignore_empty: true}];

// Cookie name to hold the nonce value. Defaults to ``OauthNonce``.
string oauth_nonce = 6
[(validate.rules).string = {well_known_regex: HTTP_HEADER_NAME ignore_empty: true}];
}

// The client_id to be used in the authorize calls. This value will be URL encoded when sent to the OAuth server.
Expand Down
2 changes: 2 additions & 0 deletions changelogs/current.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ new_features:
the auth server when a connection fails to be established.
Added :ref:`cookie_domain <envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Credentials.cookie_domain>`
field to OAuth2 filter to allow setting the domain of cookies.
Added a nonce to the state parameter in the authorization request to mitigate CSRF attacks. The nonce is generated by the
OAuth2 filter and stored in a cookie. This feature is enabled by defaut starting from this release.
- area: access log
change: |
Added support for :ref:`%DOWNSTREAM_PEER_CHAIN_FINGERPRINTS_1% <config_access_log_format_response_flags>`,
Expand Down
203 changes: 154 additions & 49 deletions source/extensions/filters/http/oauth2/filter.cc
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ constexpr const char* CookieDomainFormatString = ";domain={}";

constexpr absl::string_view UnauthorizedBodyMessage = "OAuth flow failed.";

const std::string& queryParamsError() { CONSTRUCT_ON_FIRST_USE(std::string, "error"); }
const std::string& queryParamsCode() { CONSTRUCT_ON_FIRST_USE(std::string, "code"); }
const std::string& queryParamsState() { CONSTRUCT_ON_FIRST_USE(std::string, "state"); }
constexpr absl::string_view queryParamsError = "error";
constexpr absl::string_view queryParamsCode = "code";
constexpr absl::string_view queryParamsState = "state";
constexpr absl::string_view queryParamsRedirectUri = "redirect_uri";

constexpr absl::string_view stateParamsUrl = "url";
constexpr absl::string_view stateParamsNonce = "nonce";

constexpr absl::string_view REDIRECT_RACE = "oauth.race_redirect";
constexpr absl::string_view REDIRECT_LOGGED_IN = "oauth.logged_in";
Expand Down Expand Up @@ -169,6 +173,21 @@ std::string encodeHmac(const std::vector<uint8_t>& secret, absl::string_view dom
return encodeHmacBase64(secret, domain, expires, token, id_token, refresh_token);
}

// Generates a nonce based on the current time
std::string generateFixedLengthNonce(TimeSource& time_source) {
constexpr size_t length = 16;

std::string nonce = fmt::format("{}", time_source.systemTime().time_since_epoch().count());

if (nonce.length() < length) {
nonce.append(length - nonce.length(), '0');
} else if (nonce.length() > length) {
nonce = nonce.substr(0, length);
}

return nonce;
}

} // namespace

FilterConfig::FilterConfig(
Expand Down Expand Up @@ -320,37 +339,37 @@ Http::FilterHeadersStatus OAuth2Filter::decodeHeaders(Http::RequestHeaderMap& he
// auth server. A cached login on the authorization server side will set cookies
// correctly but cause a race condition on future requests that have their location set
// to the callback path.

if (config_->redirectPathMatcher().match(path_str)) {
Http::Utility::QueryParamsMulti query_parameters =
Http::Utility::QueryParamsMulti::parseQueryString(path_str);

auto stateVal = query_parameters.getFirstValue(queryParamsState());
if (!stateVal.has_value()) {
ENVOY_LOG(error, "state query param does not exist: \n{}", query_parameters.data());
// Even though we're already logged in and don't technically need to validate the presence
// of the auth code, we still perform the validation to ensure consistency and reuse the
// validateOAuthCallback method. This is acceptable because the auth code is always present
// in the query string of the callback path according to the OAuth2 spec.
// More information can be found here:
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2
const CallbackValidationResult result = validateOAuthCallback(headers, path_str);
if (!result.is_valid_) {
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}

std::string state;
state = Http::Utility::PercentEncoding::urlDecodeQueryParameter(stateVal.value());
Http::Utility::Url state_url;
if (!state_url.initialize(state, false)) {
ENVOY_LOG(debug, "state url {} can not be initialized", state_url.toString());
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}
// Avoid infinite redirect storm
if (config_->redirectPathMatcher().match(state_url.pathAndQueryParams())) {
ENVOY_LOG(debug, "state url query params {} does not match redirect config",
state_url.pathAndQueryParams());
// Return 401 unauthorized if the original request URL in the state matches the redirect
// config to avoid infinite redirect loops.
Http::Utility::Url original_request_url;
original_request_url.initialize(result.original_request_url_, false);
if (config_->redirectPathMatcher().match(original_request_url.pathAndQueryParams())) {
ENVOY_LOG(debug, "state url query params {} matches the redirect path matcher",
original_request_url.pathAndQueryParams());
// TODO(zhaohuabing): Should return 401 unauthorized or 400 bad request?
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}

// Since the user is already logged in, we don't need to exchange the auth code for tokens.
// Instead, we redirect the user back to the original request URL.
Http::ResponseHeaderMapPtr response_headers{
Http::createHeaderMap<Http::ResponseHeaderMapImpl>(
{{Http::Headers::get().Status, std::to_string(enumToInt(Http::Code::Found))},
{Http::Headers::get().Location, state}})};
{Http::Headers::get().Location, result.original_request_url_}})};
decoder_callbacks_->encodeHeaders(std::move(response_headers), true, REDIRECT_RACE);
return Http::FilterHeadersStatus::StopIteration;
}
Expand Down Expand Up @@ -392,30 +411,15 @@ Http::FilterHeadersStatus OAuth2Filter::decodeHeaders(Http::RequestHeaderMap& he

// At this point, we *are* on /_oauth. We believe this request comes from the authorization
// server and we expect the query strings to contain the information required to get the access
// token
const auto query_parameters = Http::Utility::QueryParamsMulti::parseQueryString(path_str);
if (query_parameters.getFirstValue(queryParamsError()).has_value()) {
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}

// if the data we need is not present on the URL, stop execution
auto codeVal = query_parameters.getFirstValue(queryParamsCode());
auto stateVal = query_parameters.getFirstValue(queryParamsState());
if (!codeVal.has_value() || !stateVal.has_value()) {
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}

auth_code_ = codeVal.value();
state_ = Http::Utility::PercentEncoding::urlDecodeQueryParameter(stateVal.value());

Http::Utility::Url state_url;
if (!state_url.initialize(state_, false)) {
// token.
const CallbackValidationResult result = validateOAuthCallback(headers, path_str);
if (!result.is_valid_) {
sendUnauthorizedResponse();
return Http::FilterHeadersStatus::StopIteration;
}

original_request_url_ = result.original_request_url_;
auth_code_ = result.auth_code_;
Formatter::FormatterImpl formatter(config_->redirectUri());
const auto redirect_uri =
formatter.formatWithContext({&headers}, decoder_callbacks_->streamInfo());
Expand Down Expand Up @@ -477,9 +481,41 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) const
}

const std::string base_path = absl::StrCat(scheme, "://", host_);
const std::string state_path = absl::StrCat(base_path, headers.Path()->value().getStringView());
const std::string escaped_state =
Http::Utility::PercentEncoding::urlEncodeQueryParameter(state_path);
const std::string full_url = absl::StrCat(base_path, headers.Path()->value().getStringView());
const std::string escaped_url = Http::Utility::PercentEncoding::urlEncodeQueryParameter(full_url);

// Generate a nonce to prevent CSRF attacks
std::string nonce;
bool nonce_cookie_exists = false;
const auto nonce_cookie = Http::Utility::parseCookies(headers, [this](absl::string_view key) {
return key == config_->cookieNames().oauth_nonce_;
});
if (nonce_cookie.find(config_->cookieNames().oauth_nonce_) != nonce_cookie.end()) {
nonce = nonce_cookie.at(config_->cookieNames().oauth_nonce_);
nonce_cookie_exists = true;
} else {
nonce = generateFixedLengthNonce(time_source_);
}

// Set the nonce cookie if it does not exist.
if (!nonce_cookie_exists) {
// Expire the nonce cookie in 10 minutes.
// This should be enough time for the user to complete the OAuth flow.
std::string expire_in = std::to_string(10 * 60);
std::string cookie_tail_http_only = fmt::format(CookieTailHttpOnlyFormatString, expire_in);
if (!config_->cookieDomain().empty()) {
cookie_tail_http_only = absl::StrCat(
fmt::format(CookieDomainFormatString, config_->cookieDomain()), cookie_tail_http_only);
}
response_headers->addReferenceKey(
Http::Headers::get().SetCookie,
absl::StrCat(config_->cookieNames().oauth_nonce_, "=", nonce, cookie_tail_http_only));
}

// Encode the original request URL and the nonce to the state parameter
const std::string state =
absl::StrCat(stateParamsUrl, "=", escaped_url, "&", stateParamsNonce, "=", nonce);
const std::string escaped_state = Http::Utility::PercentEncoding::urlEncodeQueryParameter(state);

Formatter::FormatterImpl formatter(config_->redirectUri());
const auto redirect_uri =
Expand All @@ -488,8 +524,8 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) const
Http::Utility::PercentEncoding::urlEncodeQueryParameter(redirect_uri);

auto query_params = config_->authorizationQueryParams();
query_params.overwrite("redirect_uri", escaped_redirect_uri);
query_params.overwrite("state", escaped_state);
query_params.overwrite(queryParamsRedirectUri, escaped_redirect_uri);
query_params.overwrite(queryParamsState, escaped_state);
// Copy the authorization endpoint URL to replace its query params.
auto authorization_endpoint_url = config_->authorizationEndpointUrl();
const std::string path_and_query_params = query_params.replaceQueryString(
Expand All @@ -498,6 +534,7 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) const
const std::string new_url = authorization_endpoint_url.toString();

response_headers->setLocation(new_url + config_->encodedResourceQueryParams());

decoder_callbacks_->encodeHeaders(std::move(response_headers), true, REDIRECT_FOR_CREDENTIALS);

config_->stats().oauth_unauthorized_rq_.inc();
Expand Down Expand Up @@ -658,7 +695,7 @@ void OAuth2Filter::finishGetAccessTokenFlow() {
{{Http::Headers::get().Status, std::to_string(enumToInt(Http::Code::Found))}})};

addResponseCookies(*response_headers, getEncodedToken());
response_headers->setLocation(state_);
response_headers->setLocation(original_request_url_);

decoder_callbacks_->encodeHeaders(std::move(response_headers), true, REDIRECT_LOGGED_IN);
config_->stats().oauth_success_.inc();
Expand Down Expand Up @@ -759,6 +796,74 @@ void OAuth2Filter::sendUnauthorizedResponse() {
absl::nullopt, EMPTY_STRING);
}

// Validates the OAuth callback request.
// * Does the query parameters contain an error response?
// * Does the query parameters contain the code and state?
// * Does the state contain the original request URL and nonce?
// * Does the nonce in the state match the nonce in the cookie?
CallbackValidationResult OAuth2Filter::validateOAuthCallback(const Http::RequestHeaderMap& headers,
const absl::string_view path_str) {
// Return 401 unauthorized if the query parameters contain an error response.
const auto query_parameters = Http::Utility::QueryParamsMulti::parseQueryString(path_str);
if (query_parameters.getFirstValue(queryParamsError).has_value()) {
ENVOY_LOG(debug, "OAuth server returned an error: \n{}", query_parameters.data());
return {false, "", ""};
}

// Return 401 unauthorized if the query parameters do not contain the code and state.
auto codeVal = query_parameters.getFirstValue(queryParamsCode);
auto stateVal = query_parameters.getFirstValue(queryParamsState);
if (!codeVal.has_value() || !stateVal.has_value()) {
ENVOY_LOG(error, "code or state query param does not exist: \n{}", query_parameters.data());
return {false, "", ""};
}

// Return 401 unauthorized if the state query parameter does not contain the original request URL
// and nonce. state is an HTTP URL encoded string that contains the url and nonce, for example:
// state=url%3Dhttp%253A%252F%252Ftraffic.example.com%252Fnot%252F_oauth%26nonce%3D1234567890000000".
std::string state = Http::Utility::PercentEncoding::urlDecodeQueryParameter(stateVal.value());
const auto state_parameters = Http::Utility::QueryParamsMulti::parseParameters(state, 0, true);
auto urlVal = state_parameters.getFirstValue(stateParamsUrl);
auto nonceVal = state_parameters.getFirstValue(stateParamsNonce);
if (!urlVal.has_value() || !nonceVal.has_value()) {
ENVOY_LOG(error, "state query param does not contain url or nonce: \n{}", state);
return {false, "", ""};
}

// Return 401 unauthorized if the URL in the state is not valid.
std::string original_request_url = urlVal.value();
Http::Utility::Url url;
if (!url.initialize(original_request_url, false)) {
ENVOY_LOG(error, "state url {} can not be initialized", original_request_url);
return {false, "", ""};
}

// Return 401 unauthorized if the nonce cookie does not match the nonce in the state.
//
// This is to prevent attackers from injecting their own access token into a victim's
// sessions via CSRF attack. The attack can result in victims saving their sensitive data
// in the attacker's account.
// More information can be found at https://datatracker.ietf.org/doc/html/rfc6819#section-5.3.5
if (!validateNonce(headers, nonceVal.value())) {
ENVOY_LOG(error, "nonce cookie does not match nonce query param: \n{}", nonceVal.value());
return {false, "", ""};
}

return {true, codeVal.value(), original_request_url};
}

bool OAuth2Filter::validateNonce(const Http::RequestHeaderMap& headers, const std::string& nonce) {
const auto nonce_cookie = Http::Utility::parseCookies(headers, [this](absl::string_view key) {
return key == config_->cookieNames().oauth_nonce_;
});

if (nonce_cookie.find(config_->cookieNames().oauth_nonce_) != nonce_cookie.end() &&
nonce_cookie.at(config_->cookieNames().oauth_nonce_) == nonce) {
return true;
}
return false;
}

} // namespace Oauth2
} // namespace HttpFilters
} // namespace Extensions
Expand Down
26 changes: 20 additions & 6 deletions source/extensions/filters/http/oauth2/filter.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,29 @@ struct CookieNames {
cookie_names)
: CookieNames(cookie_names.bearer_token(), cookie_names.oauth_hmac(),
cookie_names.oauth_expires(), cookie_names.id_token(),
cookie_names.refresh_token()) {}
cookie_names.refresh_token(), cookie_names.oauth_nonce()) {}

CookieNames(const std::string& bearer_token, const std::string& oauth_hmac,
const std::string& oauth_expires, const std::string& id_token,
const std::string& refresh_token)
: bearer_token_(bearer_token.empty() ? "BearerToken" : bearer_token),
oauth_hmac_(oauth_hmac.empty() ? "OauthHMAC" : oauth_hmac),
const std::string& refresh_token, const std::string& oauth_nonce)
: bearer_token_(bearer_token.empty() ? BearerToken : bearer_token),
oauth_hmac_(oauth_hmac.empty() ? OauthHMAC : oauth_hmac),
oauth_expires_(oauth_expires.empty() ? OauthExpires : oauth_expires),
id_token_(id_token.empty() ? IdToken : id_token),
refresh_token_(refresh_token.empty() ? RefreshToken : refresh_token) {}
refresh_token_(refresh_token.empty() ? RefreshToken : refresh_token),
oauth_nonce_(oauth_nonce.empty() ? OauthNonce : oauth_nonce) {}

const std::string bearer_token_;
const std::string oauth_hmac_;
const std::string oauth_expires_;
const std::string id_token_;
const std::string refresh_token_;
const std::string oauth_nonce_;

static constexpr absl::string_view OauthExpires = "OauthExpires";
static constexpr absl::string_view BearerToken = "BearerToken";
static constexpr absl::string_view OauthHMAC = "OauthHMAC";
static constexpr absl::string_view OauthNonce = "OauthNonce";
static constexpr absl::string_view IdToken = "IdToken";
static constexpr absl::string_view RefreshToken = "RefreshToken";
};
Expand Down Expand Up @@ -235,6 +240,12 @@ class OAuth2CookieValidator : public CookieValidator {
const std::string cookie_domain_;
};

struct CallbackValidationResult {
bool is_valid_;
std::string auth_code_;
std::string original_request_url_;
};

/**
* The filter is the primary entry point for the OAuth workflow. Its responsibilities are to
* receive incoming requests and decide at what state of the OAuth workflow they are in. Logic
Expand Down Expand Up @@ -270,6 +281,7 @@ class OAuth2Filter : public Http::PassThroughFilter,
void finishRefreshAccessTokenFlow();
void updateTokens(const std::string& access_token, const std::string& id_token,
const std::string& refresh_token, std::chrono::seconds expires_in);
bool validateNonce(const Http::RequestHeaderMap& headers, const std::string& nonce);

private:
friend class OAuth2Test;
Expand All @@ -286,7 +298,7 @@ class OAuth2Filter : public Http::PassThroughFilter,
std::string expires_id_token_in_;
std::string new_expires_;
absl::string_view host_;
std::string state_;
std::string original_request_url_;
Http::RequestHeaderMap* request_headers_{nullptr};
bool was_refresh_token_flow_{false};

Expand All @@ -309,6 +321,8 @@ class OAuth2Filter : public Http::PassThroughFilter,
const std::chrono::seconds& expires_in) const;
void addResponseCookies(Http::ResponseHeaderMap& headers, const std::string& encoded_token) const;
const std::string& bearerPrefix() const;
CallbackValidationResult validateOAuthCallback(const Http::RequestHeaderMap& headers,
const absl::string_view path_str);
};

} // namespace Oauth2
Expand Down
Loading

0 comments on commit b6c24ab

Please sign in to comment.