Skip to content

Commit 7bcd97e

Browse files
oauth2: add allowed_redirect_domains and original_request_uri
Adds two new fields to the OAuth2 filter config: * `original_request_uri`: an optional formatter-supported base URI used to build the original request URL that is encoded into the OAuth2 `state` parameter. Useful when Envoy sits behind a gateway that terminates the user-facing hostname, so the post-authentication redirect uses the public host rather than Envoy's internal `:authority`. * `allowed_redirect_domains`: an optional case-insensitive allow-list (exact match or `*.` wildcard) applied to the host of the formatted `redirect_uri`, the formatted `original_request_uri`, and the URL decoded from the `state` parameter on callback. Mitigates open-redirect attacks via injected `x-forwarded-host` headers or forged `state` values. Empty list (default) disables the check for backward compatibility. The formatted `redirect_uri` and `original_request_uri` are now also required to be parseable absolute URLs: an unparseable template output is rejected with 401 rather than silently passing through the allow-list. Authority parsing uses `Http::Utility::parseAuthority` so IPv6 literals are handled consistently. Signed-off-by: Mohammed Shetaya <mohammed.shetaya@procore.com>
1 parent 70765d7 commit 7bcd97e

5 files changed

Lines changed: 560 additions & 5 deletions

File tree

api/envoy/extensions/filters/http/oauth2/v3/oauth.proto

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ message OAuth2Credentials {
151151

152152
// OAuth config
153153
//
154-
// [#next-free-field: 28]
154+
// [#next-free-field: 30]
155155
message OAuth2Config {
156156
enum AuthType {
157157
// The ``client_id`` and ``client_secret`` will be sent in the URL encoded request body.
@@ -304,6 +304,44 @@ message OAuth2Config {
304304
// Note: If a request matches pass_through_matcher, it bypasses OAuth validation and this matcher won't be evaluated.
305305
// This matcher takes precedence over deny_redirect_matcher.
306306
repeated config.route.v3.HeaderMatcher allow_failed_matcher = 27;
307+
308+
// Optional base URI (scheme + host, e.g. ``https://app.example.com``) used to build the
309+
// original request URI that is encoded into the OAuth2 ``state`` parameter.
310+
// This URI will be used later to redirect users on a successful OAuth.
311+
//
312+
// This is useful when Envoy sits behind a gateway or load balancer that terminates the
313+
// user-facing hostname: In that case, the post-authentication redirect derived from ``state`` would
314+
// send the user to an internal host they didn't request.
315+
//
316+
// Supports request header formatting tokens.
317+
//
318+
// Example:
319+
//
320+
// original_request_uri: "%REQ(x-forwarded-proto?:scheme)%://%REQ(x-forwarded-host?:authority)%"
321+
//
322+
// If not set, defaults to ``<:scheme>://<:authority>`` of the incoming request.
323+
string original_request_uri = 28;
324+
325+
// Optional list of domains that are allowed as
326+
// 1. redirect_uri: which is what the IdP calls after OAuth
327+
// 2. original_request_uri: the one extracted from the state of an OAuth callback (where should the request go after OAuth)
328+
//
329+
// This mitigates:
330+
// - injecting a malicious x-forwarded-host or any header that is used to template the redirect urls
331+
// - open redirect attacks where an attacker crafts a ``state`` value pointing to an untrusted host.
332+
//
333+
// Each entry is matched against the host (with any port stripped) extracted from the
334+
// formatted ``redirect_uri``, the formatted ``original_request_uri``, and the URL decoded from
335+
// the ``state`` parameter on callback. Matching is case-insensitive and supports two forms:
336+
//
337+
// * Exact match, e.g. ``example.com`` matches only ``example.com``.
338+
// * Wildcard subdomain match using a leading ``*.``, e.g. ``*.example.com`` matches
339+
// ``foo.example.com`` and ``bar.baz.example.com`` but not ``example.com`` itself.
340+
//
341+
// IPv6 literals must be configured without surrounding brackets (e.g. ``::1``, not ``[::1]``).
342+
//
343+
// If this list is empty (the default), all hosts are allowed and no validation is performed.
344+
repeated string allowed_redirect_domains = 29;
307345
}
308346

309347
// Per-route OAuth2 config.

changelogs/current.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ removed_config_or_runtime:
1818
# *Normally occurs at the end of the* :ref:`deprecation period <deprecated>`
1919

2020
new_features:
21+
- area: oauth2
22+
change: |
23+
Added :ref:`original_request_uri
24+
<envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.original_request_uri>`
25+
to let the OAuth2 filter derive the post-authentication redirect URL from formatter tokens
26+
(e.g. ``x-forwarded-proto``/``x-forwarded-host``) when Envoy sits behind a gateway that
27+
terminates the user-facing hostname.
28+
- area: oauth2
29+
change: |
30+
Added :ref:`allowed_redirect_domains
31+
<envoy_v3_api_field_extensions.filters.http.oauth2.v3.OAuth2Config.allowed_redirect_domains>`
32+
to restrict the host of the formatted ``redirect_uri``, the formatted
33+
``original_request_uri``, and the URL decoded from the OAuth2 ``state`` parameter to an
34+
explicit allow-list (exact match or ``*.`` wildcard). Requests with out-of-list hosts are
35+
rejected with 401. Empty list (default) disables the check for backward compatibility.
36+
Formatter output that is not a parseable absolute URL is now also rejected with 401 instead
37+
of silently passing through.
2138
- area: dynamic_modules
2239
change: |
2340
Added ``envoy_dynamic_module_callback_is_validation_mode`` ABI callback that allows dynamic

source/extensions/filters/http/oauth2/filter.cc

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,34 @@ std::string generateCodeChallenge(const std::string& code_verifier) {
302302
return Base64Url::encode(sha256_string.data(), sha256_string.size());
303303
}
304304

305+
bool isHostAllowedDomain(absl::string_view host, const std::vector<std::string>& allowed_domains) {
306+
if (allowed_domains.empty()) {
307+
return true;
308+
}
309+
310+
// Strip port and any IPv6 brackets via the shared authority parser so that "example.com:8080",
311+
// "[::1]:443" and bare "[::1]" all normalize consistently. For IPv6 literals that are
312+
// recognized as IP addresses, parseAuthority returns the host without brackets, so allow-list
313+
// entries for IPv6 must also be written without brackets (e.g. "::1", not "[::1]").
314+
const absl::string_view hostname = Http::Utility::parseAuthority(host).host_;
315+
316+
for (const auto& domain : allowed_domains) {
317+
if (absl::StartsWith(domain, "*.")) {
318+
// Wildcard match: "*.example.com" matches "foo.example.com"
319+
absl::string_view suffix = absl::string_view(domain).substr(1);
320+
if (absl::EndsWith(hostname, suffix) && hostname.size() > suffix.size()) {
321+
return true;
322+
}
323+
} else {
324+
// Exact match
325+
if (absl::EqualsIgnoreCase(hostname, domain)) {
326+
return true;
327+
}
328+
}
329+
}
330+
return false;
331+
}
332+
305333
/**
306334
* Encodes the state parameter for the OAuth2 flow.
307335
* The state parameter is a base64Url encoded JSON object containing the original request URL, a
@@ -456,6 +484,9 @@ FilterConfig::FilterConfig(
456484
authorization_query_params_(buildAutorizationQueryParams(proto_config)),
457485
client_id_(proto_config.credentials().client_id()),
458486
redirect_uri_(proto_config.redirect_uri()),
487+
allowed_redirect_domains_(proto_config.allowed_redirect_domains().begin(),
488+
proto_config.allowed_redirect_domains().end()),
489+
original_request_uri_(proto_config.original_request_uri()),
459490
redirect_matcher_(proto_config.redirect_path_matcher(), context),
460491
signout_path_(proto_config.signout_path(), context), secret_reader_(secret_reader),
461492
stats_(FilterConfig::generateStats(stats_prefix, proto_config.stat_prefix(), scope)),
@@ -950,7 +981,45 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) {
950981
if (Http::Utility::schemeIsHttp(headers.getSchemeValue())) {
951982
scheme = Http::Headers::get().SchemeValues.Http;
952983
}
953-
const std::string base_path = absl::StrCat(scheme, "://", host_);
984+
985+
// Format redirect_uri — needed for the query param sent to the identity provider
986+
auto formatter = THROW_OR_RETURN_VALUE(Formatter::FormatterImpl::create(config_->redirectUri()),
987+
Formatter::FormatterPtr);
988+
const auto redirect_uri = formatter->format({&headers}, decoder_callbacks_->streamInfo());
989+
990+
// The formatted redirect_uri must be a parseable absolute URL; otherwise a malformed template
991+
// output could silently bypass the allow-list check below.
992+
Http::Utility::Url parsed_redirect_uri;
993+
if (!parsed_redirect_uri.initialize(redirect_uri, false)) {
994+
sendUnauthorizedResponse(fmt::format("redirect_uri is not a valid URL: {}", redirect_uri));
995+
return;
996+
}
997+
if (!isHostAllowedDomain(parsed_redirect_uri.hostAndPort(), config_->allowedRedirectDomains())) {
998+
sendUnauthorizedResponse(
999+
fmt::format("redirect_uri is not in the allowed redirect domains: {}", redirect_uri));
1000+
return;
1001+
}
1002+
1003+
auto base_path = absl::StrCat(scheme, "://", host_);
1004+
1005+
if (!config_->originalRequestUri().empty()) {
1006+
formatter = THROW_OR_RETURN_VALUE(
1007+
Formatter::FormatterImpl::create(config_->originalRequestUri()), Formatter::FormatterPtr);
1008+
base_path = formatter->format({&headers}, decoder_callbacks_->streamInfo());
1009+
}
1010+
1011+
// allow-list validation for the base path encoded into `state`.
1012+
Http::Utility::Url parsed_base_path;
1013+
if (!parsed_base_path.initialize(base_path, false)) {
1014+
sendUnauthorizedResponse(fmt::format("original_request_uri is not a valid URL: {}", base_path));
1015+
return;
1016+
}
1017+
if (!isHostAllowedDomain(parsed_base_path.hostAndPort(), config_->allowedRedirectDomains())) {
1018+
sendUnauthorizedResponse(
1019+
fmt::format("original_request_uri is not in the allowed redirect domains: {}", base_path));
1020+
return;
1021+
}
1022+
9541023
const std::string original_url = absl::StrCat(base_path, headers.Path()->value().getStringView());
9551024

9561025
const CookieNames& cookie_names = config_->cookieNames();
@@ -984,9 +1053,6 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) {
9841053
auto query_params = config_->authorizationQueryParams();
9851054
query_params.overwrite(queryParamsState, state);
9861055

987-
Formatter::FormatterPtr formatter = THROW_OR_RETURN_VALUE(
988-
Formatter::FormatterImpl::create(config_->redirectUri()), Formatter::FormatterPtr);
989-
const auto redirect_uri = formatter->format({&headers}, decoder_callbacks_->streamInfo());
9901056
const std::string escaped_redirect_uri = Http::Utility::PercentEncoding::urlEncode(redirect_uri);
9911057
query_params.overwrite(queryParamsRedirectUri, escaped_redirect_uri);
9921058

@@ -1598,6 +1664,13 @@ CallbackValidationResult OAuth2Filter::validateState(const Http::RequestHeaderMa
15981664
fmt::format("State url can not be initialized: {}", original_request_url)};
15991665
}
16001666

1667+
// Validate the host of the original request URL against allowed redirect domains.
1668+
if (!isHostAllowedDomain(url.hostAndPort(), config_->allowedRedirectDomains())) {
1669+
return {false, "", "", flow_id,
1670+
fmt::format("State url host is not in the allowed redirect domains: {}",
1671+
url.hostAndPort())};
1672+
}
1673+
16011674
return {true, "", original_request_url, flow_id, ""};
16021675
}
16031676

source/extensions/filters/http/oauth2/filter.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ class FilterConfig : public Router::RouteSpecificFilterConfig,
174174
return authorization_query_params_;
175175
}
176176
const std::string& redirectUri() const { return redirect_uri_; }
177+
const std::vector<std::string>& allowedRedirectDomains() const {
178+
return allowed_redirect_domains_;
179+
}
180+
const std::string& originalRequestUri() const { return original_request_uri_; }
177181
const Matchers::PathMatcher& redirectPathMatcher() const { return redirect_matcher_; }
178182
const Matchers::PathMatcher& signoutPath() const { return signout_path_; }
179183
std::string clientSecret() const { return secret_reader_->clientSecret(); }
@@ -245,6 +249,8 @@ class FilterConfig : public Router::RouteSpecificFilterConfig,
245249
const Http::Utility::QueryParamsMulti authorization_query_params_;
246250
const std::string client_id_;
247251
const std::string redirect_uri_;
252+
const std::vector<std::string> allowed_redirect_domains_;
253+
const std::string original_request_uri_;
248254
const Matchers::PathMatcher redirect_matcher_;
249255
const Matchers::PathMatcher signout_path_;
250256
std::shared_ptr<SecretReader> secret_reader_;

0 commit comments

Comments
 (0)