From 72f30477eb8147c7d6937a868e7605c3a64e3641 Mon Sep 17 00:00:00 2001 From: Derek Argueta Date: Tue, 21 Apr 2026 18:37:50 -0500 Subject: [PATCH] ext_authz: add method_override to HttpService Adds a method_override field to the ext_authz HttpService config that, when set, replaces the HTTP method on the outgoing authorization request. Without this, the auth server must accept every method the upstream receives (GET, DELETE, PATCH, etc.) rather than exposing a single fixed endpoint like POST /auth. Fixes #5357 Signed-off-by: Derek Argueta --- .../filters/http/ext_authz/v3/ext_authz.proto | 8 +- changelogs/current.yaml | 8 ++ .../common/ext_authz/ext_authz_http_impl.cc | 16 ++++ .../common/ext_authz/ext_authz_http_impl.h | 7 ++ .../ext_authz/ext_authz_http_impl_test.cc | 95 +++++++++++++++++++ .../ext_authz/ext_authz_integration_test.cc | 66 +++++++++++++ 6 files changed, 199 insertions(+), 1 deletion(-) diff --git a/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto b/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto index 2cc6e689b6e20..322e9bdde91c3 100644 --- a/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto +++ b/api/envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto @@ -484,7 +484,7 @@ message BufferSettings { // metadata as well as body may be added to the client's response. See :ref:`allowed_client_headers // ` // for details. -// [#next-free-field: 11] +// [#next-free-field: 12] message HttpService { option (udpa.annotations.versioning).previous_message_type = "envoy.config.filter.http.ext_authz.v2.HttpService"; @@ -502,6 +502,12 @@ message HttpService { // Only one of ``path_prefix`` or ``path_override`` may be set. string path_override = 10; + // Overrides the HTTP method of the authorization request sent to the authorization service. + // If not set, the original request method is forwarded. This is useful when the authorization + // service exposes a single fixed endpoint (e.g. ``POST /auth``) regardless of the original + // request method. + string method_override = 11; + // Settings used for controlling authorization request metadata. AuthorizationRequest authorization_request = 7; diff --git a/changelogs/current.yaml b/changelogs/current.yaml index f9751bc4b7263..6864dccb6c6b9 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -576,6 +576,14 @@ new_features: to the HTTP ext_authz filter. When set, the request path sent to the authorization service is replaced entirely by this value. Only one of ``path_prefix`` or ``path_override`` may be set; validation fails at config load if both are specified. +- area: ext_authz + change: | + Added :ref:`method_override + ` + to the HTTP ext_authz filter. When set, the HTTP method of the request sent to the authorization + service is replaced with this value, regardless of the original request method. This allows + the authorization service to expose a single fixed endpoint (e.g. ``POST /auth``) rather than + handling every method the upstream receives. - area: stats change: | Added support to limit the number of metrics stored in each scope within the stats library. diff --git a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc index e8e9d07795a9c..332408d75e0db 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc +++ b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.cc @@ -124,6 +124,14 @@ absl::StatusOr validatePathOverride(absl::string_view path_override return std::string(path_override); } +absl::StatusOr validateMethodOverride(absl::string_view method_override) { + if (!method_override.empty() && + method_override.find_first_of(" \t\r\n") != absl::string_view::npos) { + return absl::InvalidArgumentError("method_override must not contain whitespace."); + } + return std::string(method_override); +} + absl::Status validateOnlyOneOfPathPrefixOrOverride(absl::string_view path_prefix, absl::string_view path_override) { if (!path_prefix.empty() && !path_override.empty()) { @@ -174,6 +182,8 @@ ClientConfig::ClientConfig(const envoy::extensions::filters::http::ext_authz::v3 path_prefix_(THROW_OR_RETURN_VALUE(validatePathPrefix(path_prefix), std::string)), path_override_(THROW_OR_RETURN_VALUE( validatePathOverride(config.http_service().path_override()), std::string)), + method_override_(THROW_OR_RETURN_VALUE( + validateMethodOverride(config.http_service().method_override()), std::string)), tracing_name_(fmt::format("async {} egress", config.http_service().server_uri().cluster())), request_headers_parser_(THROW_OR_RETURN_VALUE( Router::HeaderParser::configure( @@ -208,6 +218,8 @@ ClientConfig::ClientConfig( THROW_OR_RETURN_VALUE(validatePathPrefix(http_service.path_prefix()), std::string)), path_override_( THROW_OR_RETURN_VALUE(validatePathOverride(http_service.path_override()), std::string)), + method_override_(THROW_OR_RETURN_VALUE(validateMethodOverride(http_service.method_override()), + std::string)), tracing_name_(fmt::format("async {} egress", http_service.server_uri().cluster())), request_headers_parser_(THROW_OR_RETURN_VALUE( Router::HeaderParser::configure( @@ -328,6 +340,8 @@ void RawHttpClientImpl::check(RequestCallbacks& callbacks, } else { headers->addCopy(key, header.raw_value()); } + } else if (key == Http::Headers::get().Method && !config_->methodOverride().empty()) { + headers->addCopy(key, config_->methodOverride()); } else { headers->addCopy(key, header.raw_value()); } @@ -349,6 +363,8 @@ void RawHttpClientImpl::check(RequestCallbacks& callbacks, } else { headers->addCopy(key, header.second); } + } else if (key == Http::Headers::get().Method && !config_->methodOverride().empty()) { + headers->addCopy(key, config_->methodOverride()); } else { headers->addCopy(key, header.second); } diff --git a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h index 3fd7d4a3b760e..75421eb67117b 100644 --- a/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h +++ b/source/extensions/filters/common/ext_authz/ext_authz_http_impl.h @@ -49,6 +49,12 @@ class ClientConfig { */ const std::string& pathOverride() { return path_override_; } + /** + * Returns the authorization request method override. When non-empty, replaces the HTTP method + * on the outgoing authorization request. + */ + const std::string& methodOverride() const { return method_override_; } + /** * Returns authorization request timeout. */ @@ -131,6 +137,7 @@ class ClientConfig { const std::chrono::milliseconds timeout_; const std::string path_prefix_; const std::string path_override_; + const std::string method_override_; const std::string tracing_name_; Router::HeaderParserPtr request_headers_parser_; const bool encode_raw_headers_; diff --git a/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc b/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc index 767c68cde2002..6feb7a1d9b03a 100644 --- a/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc +++ b/test/extensions/filters/common/ext_authz/ext_authz_http_impl_test.cc @@ -376,6 +376,101 @@ TEST_F(ExtAuthzHttpClientTest, PathOverrideMustStartWithSlash) { "path_override should start with \"/\"."); } +// Verify method_override replaces the original request method on the authorization request. +TEST_F(ExtAuthzHttpClientTest, AuthorizationOkWithMethodOverride) { + const std::string yaml = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + method_override: "POST" + )EOF"; + initialize(yaml); + Http::RequestMessagePtr message_ptr = + sendRequest({{":path", "/hello"}, {":method", "GET"}, {"foo", "bar"}}); + EXPECT_EQ(message_ptr->headers().getMethodValue(), "POST"); +} + +// Verify that without method_override, the original request method is forwarded unchanged. +TEST_F(ExtAuthzHttpClientTest, AuthorizationOkWithoutMethodOverride) { + const std::string yaml = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + )EOF"; + initialize(yaml); + Http::RequestMessagePtr message_ptr = + sendRequest({{":path", "/hello"}, {":method", "DELETE"}, {"foo", "bar"}}); + EXPECT_EQ(message_ptr->headers().getMethodValue(), "DELETE"); +} + +// Verify method_override with encode_raw_headers also overrides the method. +TEST_F(ExtAuthzHttpClientTest, AuthorizationOkWithMethodOverrideRawHeaders) { + const std::string yaml = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + method_override: "POST" + encode_raw_headers: true + )EOF"; + initialize(yaml); + Http::RequestMessagePtr message_ptr = + sendRequest({{":path", "/hello"}, {":method", "GET"}}, {.encode_raw_headers = true}); + EXPECT_EQ(message_ptr->headers().getMethodValue(), "POST"); +} + +// Verify ClientConfig from HttpService directly also captures method_override. +TEST_F(ExtAuthzHttpClientTest, ClientConfigFromHttpServiceWithMethodOverride) { + envoy::extensions::filters::http::ext_authz::v3::HttpService http_service; + TestUtility::loadFromYaml(R"EOF( + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + method_override: "POST" + )EOF", + http_service); + auto cfg = std::make_shared(http_service, false, 250, factory_context_); + EXPECT_EQ(cfg->methodOverride(), "POST"); +} + +// Verify method_override containing whitespace is rejected at config load. +TEST_F(ExtAuthzHttpClientTest, MethodOverrideWithWhitespaceRejected) { + const std::string yaml = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + method_override: "POST EVIL" + )EOF"; + EXPECT_THROW_WITH_MESSAGE(createConfig(yaml), EnvoyException, + "method_override must not contain whitespace."); +} + +// Verify method_override and path_override can be combined to target a fixed endpoint. +TEST_F(ExtAuthzHttpClientTest, AuthorizationOkWithMethodAndPathOverride) { + const std::string yaml = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 0.25s + method_override: "POST" + path_override: "/auth" + )EOF"; + initialize(yaml); + Http::RequestMessagePtr message_ptr = + sendRequest({{":path", "/original/path"}, {":method", "GET"}, {"foo", "bar"}}); + EXPECT_EQ(message_ptr->headers().getMethodValue(), "POST"); + EXPECT_EQ(message_ptr->headers().getPathValue(), "/auth"); +} + // Verify request body is set correctly when the normal body is empty and raw body is set. TEST_F(ExtAuthzHttpClientTest, AuthorizationOkWithRawBody) { Http::RequestMessagePtr message_ptr = sendRequest( diff --git a/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc b/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc index 3982766672437..bf91f239a22f6 100644 --- a/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc +++ b/test/extensions/filters/http/ext_authz/ext_authz_integration_test.cc @@ -2658,6 +2658,72 @@ TEST_P(ExtAuthzHttpIntegrationTest, HttpRetryPolicyOldBehaviorWithFlagDisabled) cleanup(); } +// Verifies that method_override replaces the HTTP method on the request sent to the +// authorization server, regardless of the original client request method. +TEST_P(ExtAuthzHttpIntegrationTest, MethodOverride) { + config_helper_.addConfigModifier([this](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + auto* ext_authz_cluster = bootstrap.mutable_static_resources()->add_clusters(); + ext_authz_cluster->MergeFrom(bootstrap.static_resources().clusters()[0]); + ext_authz_cluster->set_name("ext_authz"); + + const std::string ext_authz_config = R"EOF( + http_service: + server_uri: + uri: "ext_authz:9000" + cluster: "ext_authz" + timeout: 300s + method_override: "POST" + failure_mode_allow: false + )EOF"; + TestUtility::loadFromYaml(ext_authz_config, proto_config_); + proto_config_.set_encode_raw_headers(encodeRawHeaders()); + + envoy::extensions::filters::network::http_connection_manager::v3::HttpFilter ext_authz_filter; + ext_authz_filter.set_name("envoy.filters.http.ext_authz"); + ext_authz_filter.mutable_typed_config()->PackFrom(proto_config_); + + config_helper_.prependFilter(MessageUtil::getJsonStringFromMessageOrError(ext_authz_filter)); + }); + + HttpIntegrationTest::initialize(); + + // Send a GET request from the client; method_override should cause POST to reach ext_authz. + auto conn = makeClientConnection(lookupPort("http")); + codec_client_ = makeHttpConnection(std::move(conn)); + const auto headers = Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, {":path", "/"}, {":scheme", "http"}, {":authority", "host"}}; + response_ = codec_client_->makeHeaderOnlyRequest(headers); + + AssertionResult result = + fake_upstreams_.back()->waitForHttpConnection(*dispatcher_, fake_ext_authz_connection_); + RELEASE_ASSERT(result, result.message()); + result = fake_ext_authz_connection_->waitForNewStream(*dispatcher_, ext_authz_request_); + RELEASE_ASSERT(result, result.message()); + result = ext_authz_request_->waitForEndStream(*dispatcher_); + RELEASE_ASSERT(result, result.message()); + + EXPECT_EQ(ext_authz_request_->headers().getMethodValue(), "POST"); + + Http::TestResponseHeaderMapImpl authz_response_headers{{":status", "200"}}; + ext_authz_request_->encodeHeaders(authz_response_headers, true); + + AssertionResult upstream_result = + fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, fake_upstream_connection_); + RELEASE_ASSERT(upstream_result, upstream_result.message()); + upstream_result = fake_upstream_connection_->waitForNewStream(*dispatcher_, upstream_request_); + RELEASE_ASSERT(upstream_result, upstream_result.message()); + upstream_result = upstream_request_->waitForEndStream(*dispatcher_); + RELEASE_ASSERT(upstream_result, upstream_result.message()); + + upstream_request_->encodeHeaders(Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + + ASSERT_TRUE(response_->waitForEndStream()); + EXPECT_TRUE(response_->complete()); + EXPECT_EQ("200", response_->headers().getStatusValue()); + + cleanup(); +} + class ExtAuthzLocalReplyIntegrationTest : public HttpIntegrationTest, public TestWithParam { public: