Skip to content

Commit 1c3d994

Browse files
fix: allow oauth in internal proxy
Signed-off-by: Mohammed Shetaya <mohammed.shetaya@procore.com>
1 parent c470ddd commit 1c3d994

5 files changed

Lines changed: 315 additions & 5 deletions

File tree

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

Lines changed: 16 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: 27]
154+
// [#next-free-field: 29]
155155
message OAuth2Config {
156156
enum AuthType {
157157
// The ``client_id`` and ``client_secret`` will be sent in the URL encoded request body.
@@ -197,6 +197,21 @@ message OAuth2Config {
197197
// This URI should not contain any query parameters.
198198
string redirect_uri = 4 [(validate.rules).string = {min_len: 1}];
199199

200+
// Optional list of allowed redirect domains for the URL encoded in the OAuth2 state parameter.
201+
// When set, the filter validates the host of the original request URL decoded from the state
202+
// parameter against this list before redirecting. Supports exact matches (e.g., "example.com")
203+
// and wildcard prefix matches (e.g., "*.example.com" matches "foo.example.com").
204+
// If not set, no domain restriction is applied.
205+
repeated string allowed_redirect_domains = 27;
206+
207+
// When set to true, the host and scheme encoded in the OAuth2 state parameter
208+
// will be derived from the configured redirect_uri instead of from the
209+
// request's :authority and :scheme headers. This is useful when Envoy is
210+
// deployed behind an external load balancer whose hostname differs from
211+
// Envoy's internal hostname.
212+
// Default is false.
213+
bool match_redirect_url_to_redirect_uri = 28;
214+
200215
// Matching criteria used to determine whether a path appears to be the result of a redirect from the authorization server.
201216
type.matcher.v3.PathMatcher redirect_path_matcher = 5
202217
[(validate.rules).message = {required: true}];

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

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,33 @@ 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 from host if present (e.g., "example.com:8080" -> "example.com")
311+
auto colon_pos = host.rfind(':');
312+
absl::string_view hostname =
313+
(colon_pos != absl::string_view::npos) ? host.substr(0, colon_pos) : host;
314+
315+
for (const auto& domain : allowed_domains) {
316+
if (absl::StartsWith(domain, "*.")) {
317+
// Wildcard match: "*.example.com" matches "foo.example.com"
318+
absl::string_view suffix = absl::string_view(domain).substr(1);
319+
if (absl::EndsWith(hostname, suffix) && hostname.size() > suffix.size()) {
320+
return true;
321+
}
322+
} else {
323+
// Exact match
324+
if (absl::EqualsIgnoreCase(hostname, domain)) {
325+
return true;
326+
}
327+
}
328+
}
329+
return false;
330+
}
331+
305332
/**
306333
* Encodes the state parameter for the OAuth2 flow.
307334
* The state parameter is a base64Url encoded JSON object containing the original request URL, a
@@ -456,6 +483,9 @@ FilterConfig::FilterConfig(
456483
authorization_query_params_(buildAutorizationQueryParams(proto_config)),
457484
client_id_(proto_config.credentials().client_id()),
458485
redirect_uri_(proto_config.redirect_uri()),
486+
allowed_redirect_domains_(proto_config.allowed_redirect_domains().begin(),
487+
proto_config.allowed_redirect_domains().end()),
488+
match_redirect_url_to_redirect_uri_(proto_config.match_redirect_url_to_redirect_uri()),
459489
redirect_matcher_(proto_config.redirect_path_matcher(), context),
460490
signout_path_(proto_config.signout_path(), context), secret_reader_(secret_reader),
461491
stats_(FilterConfig::generateStats(stats_prefix, proto_config.stat_prefix(), scope)),
@@ -915,7 +945,22 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) {
915945
if (Http::Utility::schemeIsHttp(headers.getSchemeValue())) {
916946
scheme = Http::Headers::get().SchemeValues.Http;
917947
}
918-
const std::string base_path = absl::StrCat(scheme, "://", host_);
948+
949+
// Format redirect_uri — needed for the query param, and when
950+
// match_redirect_url_to_redirect_uri is set, also for deriving the state URL host.
951+
auto formatter = THROW_OR_RETURN_VALUE(Formatter::FormatterImpl::create(config_->redirectUri()),
952+
Formatter::FormatterPtr);
953+
const auto redirect_uri = formatter->format({&headers}, decoder_callbacks_->streamInfo());
954+
955+
std::string base_path = absl::StrCat(scheme, "://", host_);
956+
957+
Http::Utility::Url parsed_redirect_uri;
958+
if (config_->matchRedirectUrlToRedirectUri() &&
959+
parsed_redirect_uri.initialize(redirect_uri, false)) {
960+
base_path =
961+
absl::StrCat(parsed_redirect_uri.scheme(), "://", parsed_redirect_uri.hostAndPort());
962+
}
963+
919964
const std::string original_url = absl::StrCat(base_path, headers.Path()->value().getStringView());
920965

921966
const CookieNames& cookie_names = config_->cookieNames();
@@ -949,9 +994,6 @@ void OAuth2Filter::redirectToOAuthServer(Http::RequestHeaderMap& headers) {
949994
auto query_params = config_->authorizationQueryParams();
950995
query_params.overwrite(queryParamsState, state);
951996

952-
Formatter::FormatterPtr formatter = THROW_OR_RETURN_VALUE(
953-
Formatter::FormatterImpl::create(config_->redirectUri()), Formatter::FormatterPtr);
954-
const auto redirect_uri = formatter->format({&headers}, decoder_callbacks_->streamInfo());
955997
const std::string escaped_redirect_uri = Http::Utility::PercentEncoding::urlEncode(redirect_uri);
956998
query_params.overwrite(queryParamsRedirectUri, escaped_redirect_uri);
957999

@@ -1496,6 +1538,13 @@ CallbackValidationResult OAuth2Filter::validateState(const Http::RequestHeaderMa
14961538
fmt::format("State url can not be initialized: {}", original_request_url)};
14971539
}
14981540

1541+
// Validate the host of the original request URL against allowed redirect domains.
1542+
if (!isHostAllowedDomain(url.hostAndPort(), config_->allowedRedirectDomains())) {
1543+
return {false, "", "", flow_id,
1544+
fmt::format("State url host is not in the allowed redirect domains: {}",
1545+
url.hostAndPort())};
1546+
}
1547+
14991548
return {true, "", original_request_url, flow_id, ""};
15001549
}
15011550

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ class FilterConfig : public Logger::Loggable<Logger::Id::oauth2> {
155155
return authorization_query_params_;
156156
}
157157
const std::string& redirectUri() const { return redirect_uri_; }
158+
const std::vector<std::string>& allowedRedirectDomains() const {
159+
return allowed_redirect_domains_;
160+
}
161+
bool matchRedirectUrlToRedirectUri() const { return match_redirect_url_to_redirect_uri_; }
158162
const Matchers::PathMatcher& redirectPathMatcher() const { return redirect_matcher_; }
159163
const Matchers::PathMatcher& signoutPath() const { return signout_path_; }
160164
std::string clientSecret() const { return secret_reader_->clientSecret(); }
@@ -220,6 +224,8 @@ class FilterConfig : public Logger::Loggable<Logger::Id::oauth2> {
220224
const Http::Utility::QueryParamsMulti authorization_query_params_;
221225
const std::string client_id_;
222226
const std::string redirect_uri_;
227+
const std::vector<std::string> allowed_redirect_domains_;
228+
const bool match_redirect_url_to_redirect_uri_;
223229
const Matchers::PathMatcher redirect_matcher_;
224230
const Matchers::PathMatcher signout_path_;
225231
std::shared_ptr<SecretReader> secret_reader_;

test/extensions/filters/http/oauth2/config_test.cc

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,50 @@ TEST(ConfigTest, ValidPartitionedConfigs) {
870870
EXPECT_TRUE(result.ok());
871871
}
872872

873+
TEST(ConfigTest, AllowedRedirectDomainsAndMatchRedirectUrl) {
874+
const std::string yaml = R"EOF(
875+
config:
876+
token_endpoint:
877+
cluster: foo
878+
uri: oauth.com/token
879+
timeout: 3s
880+
credentials:
881+
client_id: "secret"
882+
token_secret:
883+
name: token
884+
hmac_secret:
885+
name: hmac
886+
authorization_endpoint: https://oauth.com/oauth/authorize/
887+
redirect_uri: "https://external.example.com/callback"
888+
redirect_path_matcher:
889+
path:
890+
exact: /callback
891+
signout_path:
892+
path:
893+
exact: /signout
894+
allowed_redirect_domains:
895+
- "example.com"
896+
- "*.example.com"
897+
match_redirect_url_to_redirect_uri: true
898+
)EOF";
899+
900+
OAuth2Config factory;
901+
ProtobufTypes::MessagePtr proto_config = factory.createEmptyConfigProto();
902+
TestUtility::loadFromYaml(yaml, *proto_config);
903+
NiceMock<Server::Configuration::MockFactoryContext> context;
904+
context.server_factory_context_.cluster_manager_.initializeClusters({"foo"}, {});
905+
906+
NiceMock<Secret::MockSecretManager> secret_manager;
907+
ON_CALL(context.server_factory_context_, secretManager())
908+
.WillByDefault(ReturnRef(secret_manager));
909+
ON_CALL(secret_manager, findStaticGenericSecretProvider(_))
910+
.WillByDefault(Return(std::make_shared<Secret::GenericSecretConfigProviderImpl>(
911+
envoy::extensions::transport_sockets::tls::v3::GenericSecret())));
912+
913+
const auto result = factory.createFilterFactoryFromProto(*proto_config, "stats", context);
914+
EXPECT_TRUE(result.ok());
915+
}
916+
873917
} // namespace Oauth2
874918
} // namespace HttpFilters
875919
} // namespace Extensions

test/extensions/filters/http/oauth2/filter_test.cc

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4838,6 +4838,202 @@ TEST_F(OAuth2Test, OAuthTestCustomCookiePaths) {
48384838
}
48394839
}
48404840

4841+
// Helper to build a FilterConfig with allowed_redirect_domains and
4842+
// match_redirect_url_to_redirect_uri.
4843+
class OAuth2RedirectDomainTest : public OAuth2Test {
4844+
public:
4845+
OAuth2RedirectDomainTest() : OAuth2Test(false) {}
4846+
4847+
FilterConfigSharedPtr
4848+
getConfigWithRedirectDomains(const std::vector<std::string>& allowed_domains,
4849+
bool match_redirect_url = false,
4850+
const std::string& redirect_uri = "") {
4851+
envoy::extensions::filters::http::oauth2::v3::OAuth2Config p;
4852+
auto* endpoint = p.mutable_token_endpoint();
4853+
endpoint->set_cluster("auth.example.com");
4854+
endpoint->set_uri("auth.example.com/_oauth");
4855+
endpoint->mutable_timeout()->set_seconds(1);
4856+
p.set_redirect_uri(redirect_uri.empty() ? "%REQ(:scheme)%://%REQ(:authority)%" + TEST_CALLBACK
4857+
: redirect_uri);
4858+
p.mutable_redirect_path_matcher()->mutable_path()->set_exact(TEST_CALLBACK);
4859+
p.set_authorization_endpoint("https://auth.example.com/oauth/authorize/");
4860+
p.mutable_signout_path()->mutable_path()->set_exact("/_signout");
4861+
p.set_forward_bearer_token(true);
4862+
p.add_auth_scopes("user");
4863+
p.add_auth_scopes("openid");
4864+
p.add_auth_scopes("email");
4865+
p.add_resources("oauth2-resource");
4866+
p.add_resources("http://example.com");
4867+
p.add_resources("https://example.com/some/path%2F..%2F/utf8\xc3\x83;foo=bar?var1=1&var2=2");
4868+
auto credentials = p.mutable_credentials();
4869+
credentials->set_client_id(TEST_CLIENT_ID);
4870+
credentials->mutable_token_secret()->set_name("secret");
4871+
credentials->mutable_hmac_secret()->set_name("hmac");
4872+
p.mutable_use_refresh_token()->set_value(false);
4873+
4874+
for (const auto& domain : allowed_domains) {
4875+
p.add_allowed_redirect_domains(domain);
4876+
}
4877+
p.set_match_redirect_url_to_redirect_uri(match_redirect_url);
4878+
4879+
MessageUtil::validate(p, ProtobufMessage::getStrictValidationVisitor());
4880+
4881+
auto secret_reader = std::make_shared<MockSecretReader>();
4882+
return std::make_shared<FilterConfig>(p, factory_context_.server_factory_context_,
4883+
secret_reader, scope_, "test.");
4884+
}
4885+
};
4886+
4887+
/**
4888+
* Scenario: allowed_redirect_domains is set and the state URL host is not in the list.
4889+
*
4890+
* Expected behavior: the filter should reject the callback with 401.
4891+
*/
4892+
TEST_F(OAuth2RedirectDomainTest, AllowedRedirectDomainsRejectsInvalidHost) {
4893+
// TEST_ENCODED_STATE contains url with host "traffic.example.com".
4894+
// Only allow "other.example.com".
4895+
init(getConfigWithRedirectDomains({"other.example.com"}));
4896+
4897+
Http::TestRequestHeaderMapImpl request_headers{
4898+
{Http::Headers::get().Path.get(), "/_oauth?code=123&state=" + TEST_ENCODED_STATE},
4899+
{Http::Headers::get().Cookie.get(), "OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4900+
{Http::Headers::get().Cookie.get(),
4901+
"CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4902+
{Http::Headers::get().Host.get(), "traffic.example.com"},
4903+
{Http::Headers::get().Scheme.get(), "https"},
4904+
{Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get},
4905+
};
4906+
4907+
EXPECT_CALL(*validator_, setParams(_, _));
4908+
EXPECT_CALL(*validator_, isValid()).WillOnce(Return(false));
4909+
4910+
EXPECT_CALL(decoder_callbacks_, sendLocalReply(Http::Code::Unauthorized, _, _, _, _));
4911+
4912+
EXPECT_EQ(Http::FilterHeadersStatus::StopIteration,
4913+
filter_->decodeHeaders(request_headers, false));
4914+
}
4915+
4916+
/**
4917+
* Scenario: allowed_redirect_domains is set and the state URL host matches exactly.
4918+
*
4919+
* Expected behavior: the filter should allow the callback and proceed with token exchange.
4920+
*/
4921+
TEST_F(OAuth2RedirectDomainTest, AllowedRedirectDomainsAllowsExactMatch) {
4922+
// TEST_ENCODED_STATE contains url with host "traffic.example.com".
4923+
init(getConfigWithRedirectDomains({"traffic.example.com"}));
4924+
4925+
Http::TestRequestHeaderMapImpl request_headers{
4926+
{Http::Headers::get().Path.get(), "/_oauth?code=123&state=" + TEST_ENCODED_STATE},
4927+
{Http::Headers::get().Cookie.get(), "OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4928+
{Http::Headers::get().Cookie.get(),
4929+
"CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4930+
{Http::Headers::get().Host.get(), "traffic.example.com"},
4931+
{Http::Headers::get().Scheme.get(), "https"},
4932+
{Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get},
4933+
};
4934+
4935+
EXPECT_CALL(*validator_, setParams(_, _));
4936+
EXPECT_CALL(*validator_, isValid()).WillOnce(Return(false));
4937+
4938+
EXPECT_CALL(*oauth_client_, asyncGetAccessToken("123", TEST_CLIENT_ID, "asdf_client_secret_fdsa",
4939+
"https://traffic.example.com" + TEST_CALLBACK,
4940+
TEST_CODE_VERIFIER, AuthType::UrlEncodedBody));
4941+
4942+
EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndBuffer,
4943+
filter_->decodeHeaders(request_headers, false));
4944+
}
4945+
4946+
/**
4947+
* Scenario: allowed_redirect_domains is set with a wildcard and the state URL host matches.
4948+
*
4949+
* Expected behavior: "*.example.com" should match "traffic.example.com".
4950+
*/
4951+
TEST_F(OAuth2RedirectDomainTest, AllowedRedirectDomainsAllowsWildcardMatch) {
4952+
// TEST_ENCODED_STATE contains url with host "traffic.example.com".
4953+
init(getConfigWithRedirectDomains({"*.example.com"}));
4954+
4955+
Http::TestRequestHeaderMapImpl request_headers{
4956+
{Http::Headers::get().Path.get(), "/_oauth?code=123&state=" + TEST_ENCODED_STATE},
4957+
{Http::Headers::get().Cookie.get(), "OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN},
4958+
{Http::Headers::get().Cookie.get(),
4959+
"CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER},
4960+
{Http::Headers::get().Host.get(), "traffic.example.com"},
4961+
{Http::Headers::get().Scheme.get(), "https"},
4962+
{Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get},
4963+
};
4964+
4965+
EXPECT_CALL(*validator_, setParams(_, _));
4966+
EXPECT_CALL(*validator_, isValid()).WillOnce(Return(false));
4967+
4968+
EXPECT_CALL(*oauth_client_, asyncGetAccessToken("123", TEST_CLIENT_ID, "asdf_client_secret_fdsa",
4969+
"https://traffic.example.com" + TEST_CALLBACK,
4970+
TEST_CODE_VERIFIER, AuthType::UrlEncodedBody));
4971+
4972+
EXPECT_EQ(Http::FilterHeadersStatus::StopAllIterationAndBuffer,
4973+
filter_->decodeHeaders(request_headers, false));
4974+
}
4975+
4976+
// {"url":"https://external.example.com/original_path?var1=1&var2=2",
4977+
// "csrf_token":"00000000075bcd15.na6kru4x1pHgocSIeU/mdtHYn58Gh1bqweS4XXoiqVg=",
4978+
// "flow_id":"00000000075bcd15"}
4979+
static const std::string TEST_ENCODED_STATE_EXTERNAL_HOST =
4980+
"eyJ1cmwiOiJodHRwczovL2V4dGVybmFsLmV4YW1wbGUuY29tL29yaWdpbmFsX3BhdGg_dmFyMT0xJnZhcjI9MiIsIm"
4981+
"NzcmZfdG9rZW4iOiIwMDAwMDAwMDA3NWJjZDE1Lm5hNmtydTR4MXBIZ29jU0llVS9tZHRIWW41OEdoMWJxd2VTNFhY"
4982+
"b2lxVmc9IiwiZmxvd19pZCI6IjAwMDAwMDAwMDc1YmNkMTUifQ";
4983+
4984+
/**
4985+
* Scenario: match_redirect_url_to_redirect_uri is enabled with a static redirect_uri pointing
4986+
* to an external host. The internal :authority is "traffic.example.com" but the redirect_uri
4987+
* uses "external.example.com".
4988+
*
4989+
* Expected behavior: the state URL encoded in the redirect to the IdP should use
4990+
* "external.example.com" as the host, not "traffic.example.com".
4991+
*/
4992+
TEST_F(OAuth2RedirectDomainTest, MatchRedirectUrlToRedirectUri) {
4993+
init(getConfigWithRedirectDomains({}, true, "https://external.example.com/_oauth"));
4994+
4995+
Http::TestRequestHeaderMapImpl first_request_headers{
4996+
{Http::Headers::get().Path.get(), "/original_path?var1=1&var2=2"},
4997+
{Http::Headers::get().Host.get(), "traffic.example.com"},
4998+
{Http::Headers::get().Method.get(), Http::Headers::get().MethodValues.Get},
4999+
{Http::Headers::get().Scheme.get(), "https"},
5000+
};
5001+
5002+
Http::TestResponseHeaderMapImpl first_response_headers{
5003+
{Http::Headers::get().Status.get(), "302"},
5004+
{Http::Headers::get().SetCookie.get(),
5005+
"OauthNonce.00000000075bcd15=" + TEST_CSRF_TOKEN + ";path=/;Max-Age=600;secure;HttpOnly"},
5006+
{Http::Headers::get().SetCookie.get(),
5007+
"OauthNonce=" + TEST_CSRF_TOKEN + ";path=/;Max-Age=600;secure;HttpOnly"},
5008+
{Http::Headers::get().SetCookie.get(),
5009+
"CodeVerifier.00000000075bcd15=" + TEST_ENCRYPTED_CODE_VERIFIER +
5010+
";path=/;Max-Age=600;secure;HttpOnly"},
5011+
{Http::Headers::get().SetCookie.get(),
5012+
"CodeVerifier=" + TEST_ENCRYPTED_CODE_VERIFIER + ";path=/;Max-Age=600;secure;HttpOnly"},
5013+
{Http::Headers::get().Location.get(),
5014+
"https://auth.example.com/oauth/"
5015+
"authorize/?client_id=" +
5016+
TEST_CLIENT_ID + "&code_challenge=" + TEST_CODE_CHALLENGE +
5017+
"&code_challenge_method=S256"
5018+
"&redirect_uri=https%3A%2F%2Fexternal.example.com%2F_oauth"
5019+
"&response_type=code"
5020+
"&scope=" +
5021+
TEST_ENCODED_AUTH_SCOPES + "&state=" + TEST_ENCODED_STATE_EXTERNAL_HOST +
5022+
"&resource=oauth2-resource"
5023+
"&resource=http%3A%2F%2Fexample.com"
5024+
"&resource=https%3A%2F%2Fexample.com%2Fsome%2Fpath%252F..%252F%2Futf8%C3%83%3Bfoo%3Dbar%"
5025+
"3Fvar1%3D1%26var2%3D2"},
5026+
};
5027+
5028+
EXPECT_CALL(*validator_, setParams(_, _));
5029+
EXPECT_CALL(*validator_, isValid()).WillOnce(Return(false));
5030+
5031+
EXPECT_CALL(decoder_callbacks_, encodeHeaders_(HeaderMapEqualRef(&first_response_headers), true));
5032+
5033+
EXPECT_EQ(Http::FilterHeadersStatus::StopIteration,
5034+
filter_->decodeHeaders(first_request_headers, false));
5035+
}
5036+
48415037
} // namespace Oauth2
48425038
} // namespace HttpFilters
48435039
} // namespace Extensions

0 commit comments

Comments
 (0)