diff --git a/CODEOWNERS b/CODEOWNERS index ea2982d3e130..252229fb9cec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -207,6 +207,8 @@ extensions/filters/http/oauth2 @derekargueta @mattklein123 /*/extensions/filters/http/bandwidth_limit @nitgoy @mattklein123 @yanavlasov @tonya11en # HTTP Basic Auth /*/extensions/filters/http/basic_auth @zhaohuabing @wbpcode +# HTTP API Key Auth +/*/extensions/filters/http/api_key_auth @wbpcode @sanposhiho # Original IP detection /*/extensions/http/original_ip_detection/custom_header @alyssawilk @mattklein123 /*/extensions/http/original_ip_detection/xff @alyssawilk @mattklein123 diff --git a/api/envoy/extensions/filters/http/api_key_auth/v3/BUILD b/api/envoy/extensions/filters/http/api_key_auth/v3/BUILD index 628f71321fba..d49202b74ab4 100644 --- a/api/envoy/extensions/filters/http/api_key_auth/v3/BUILD +++ b/api/envoy/extensions/filters/http/api_key_auth/v3/BUILD @@ -6,7 +6,6 @@ licenses(["notice"]) # Apache 2 api_proto_package( deps = [ - "//envoy/config/core/v3:pkg", "@com_github_cncf_xds//udpa/annotations:pkg", "@com_github_cncf_xds//xds/annotations/v3:pkg", ], diff --git a/api/envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.proto b/api/envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.proto index 0ea66523bdf6..a75b803cfea8 100644 --- a/api/envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.proto +++ b/api/envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.proto @@ -2,12 +2,11 @@ syntax = "proto3"; package envoy.extensions.filters.http.api_key_auth.v3; -import "envoy/config/core/v3/base.proto"; - import "xds/annotations/v3/status.proto"; import "udpa/annotations/sensitive.proto"; import "udpa/annotations/status.proto"; +import "validate/validate.proto"; option java_package = "io.envoyproxy.envoy.extensions.filters.http.api_key_auth.v3"; option java_outer_classname = "ApiKeyAuthProto"; @@ -17,38 +16,88 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; option (xds.annotations.v3.file_status).work_in_progress = true; // [#protodoc-title: APIKey Auth] -// [#not-implemented-hide:] // APIKey Auth :ref:`configuration overview `. // [#extension: envoy.filters.http.api_key_auth] // API Key HTTP authentication. // -// Example: +// For example, the following configuration configures the filter to authenticate the clients using +// the API key from the header ``X-API-KEY``. And only the clients with the key ``real-key`` are +// considered as authenticated. // // .. code-block:: yaml // -// authentication_header: "X-API-KEY" -// keys: -// inline_string: |- -// clientID1:apiKey1 -// clientID2:apiKey2 +// credentials: +// - key: real-key +// client: user +// key_sources: +// - header: "X-API-KEY" // -message APIKeyAuth { - // keys used to authenticate the client. - // It should be a map of clientID to apiKey. - // The clientID serves solely for identification purposes and isn't used for authentication. - config.core.v3.DataSource keys = 1 [(udpa.annotations.sensitive) = true]; +message ApiKeyAuth { + // The credentials that are used to authenticate the clients. + repeated Credential credentials = 1 [(udpa.annotations.sensitive) = true]; + + // The key sources to fetch the key from the coming request. + repeated KeySource key_sources = 2; +} + +// API key auth configuration of per route or per virtual host or per route configuration. +message ApiKeyAuthPerRoute { + // The credentials that are used to authenticate the clients. If this field is non-empty, then the + // credentials in the filter level configuration will be ignored and the credentials in this + // configuration will be used. + repeated Credential credentials = 1 [(udpa.annotations.sensitive) = true]; + + // The key sources to fetch the key from the coming request. If this field is non-empty, then the + // key sources in the filter level configuration will be ignored and the key sources in this + // configuration will be used. + repeated KeySource key_sources = 2; + + // A list of clients that are allowed to access the route or vhost. The clients listed here + // should be subset of the clients listed in the ``credentials`` to provide authorization control + // after the authentication is successful. If the list is empty, then all authenticated clients + // are allowed. This provides very limited but simple authorization. If more complex authorization + // is required, then use the :ref:`HTTP RBAC filter ` instead. + // + // .. note:: + // Setting this field and ``credentials`` at the same configuration entry is not an error but + // also makes no much sense because they provide similar functionality. Please only use + // one of them at same configuration entry except for the case that you want to share the same + // credentials list across multiple routes but still use different allowed clients for each + // route. + // + repeated string allowed_clients = 3; +} + +// Single credential entry that contains the API key and the related client id. +message Credential { + // The value of the unique API key. + string key = 1 [(validate.rules).string = {min_len: 1}]; + + // The unique id or identity that used to identify the client or consumer. + string client = 2 [(validate.rules).string = {min_len: 1}]; +} - // The header name to fetch the key. - // If multiple values are present in the given header, the filter rejects the request. - // Only one of authentication_header, authentication_query, or authentication_cookie should be set. - string authentication_header = 2; +message KeySource { + // The header name to fetch the key. If multiple header values are present, the first one will be + // used. If the header value starts with 'Bearer ', this prefix will be stripped to get the + // key value. + // + // If set, takes precedence over ``query`` and ``cookie``. + string header = 1 + [(validate.rules).string = + {max_len: 1024 well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true}]; - // The query parameter name to fetch the key. - // Only one of authentication_header, authentication_query, or authentication_cookie should be set. - string authentication_query = 3; + // The query parameter name to fetch the key. If multiple query values are present, the first one + // will be used. + // + // The field will be used if ``header`` is not set. If set, takes precedence over ``cookie``. + string query = 2 [(validate.rules).string = {max_len: 1024}]; // The cookie name to fetch the key. - // Only one of authentication_header, authentication_query, or authentication_cookie should be set. - string authentication_cookie = 4; + // + // The field will be used if the ``header`` and ``query`` are not set. + string cookie = 3 + [(validate.rules).string = + {max_len: 1024 well_known_regex: HTTP_HEADER_NAME strict: false ignore_empty: true}]; } diff --git a/changelogs/current.yaml b/changelogs/current.yaml index ed4e6baac66f..54e28ca2ad10 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -270,6 +270,10 @@ new_features: change: | Added support for dynamic cluster selection in UDP proxy. The cluster can be set by one of the session filters by setting a per-session state object under the key ``envoy.udp_proxy.cluster``. +- area: filters + change: | + Added :ref:`the Api Key Auth filter `, which + can be used to authenticate requests using an API key. - area: CEL-attributes change: | Added :ref:`attribute ` ``upstream.request_attempt_count`` diff --git a/docs/root/configuration/http/http_filters/_include/api-key-auth-filter.yaml b/docs/root/configuration/http/http_filters/_include/api-key-auth-filter.yaml new file mode 100644 index 000000000000..647f305542db --- /dev/null +++ b/docs/root/configuration/http/http_filters/_include/api-key-auth-filter.yaml @@ -0,0 +1,89 @@ +static_resources: + listeners: + - name: listener_0 + address: + socket_address: + protocol: TCP + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + path: "/admin" + route: + cluster: upstream_com + typed_per_filter_config: + api_key_auth: + "@type": type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + key_sources: + - query: api_key + allowed_clients: + - another_client + - match: + path: "/special" + route: + cluster: upstream_com + typed_per_filter_config: + api_key_auth: + "@type": type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute + credentials: + - key: special_key + client: special_client + key_sources: + - header: X-Special-Key + - match: + prefix: "/static" + route: + cluster: upstream_com + typed_per_filter_config: + api_key_auth: + "@type": type.googleapis.com/envoy.config.route.v3.FilterConfig + disabled: true + - match: + prefix: "/" + route: + cluster: upstream_com + http_filters: + - name: api_key_auth + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + credentials: + - key: one_key + client: one_client + - key: another_key + client: another_client + key_sources: + - header: Authorization + - name: envoy.filters.http.router + typed_config: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: upstream_com + type: LOGICAL_DNS + # Comment out the following line to test on v6 networks + dns_lookup_family: V4_ONLY + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: service_upstream_com + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: upstream.com + port_value: 443 + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + sni: upstream.com diff --git a/docs/root/configuration/http/http_filters/api_key_auth_filter.rst b/docs/root/configuration/http/http_filters/api_key_auth_filter.rst new file mode 100644 index 000000000000..8c07fba310e3 --- /dev/null +++ b/docs/root/configuration/http/http_filters/api_key_auth_filter.rst @@ -0,0 +1,94 @@ +.. _config_http_filters_api_key_auth: + +API key auth +============ + +This HTTP filter can be used to authenticate users based on the unique API key. The filter will +extract the API keys from either an HTTP header, a parameter query, or a cookie and verify them against +the configured credential list. + +If the API key is valid and the related client is allowed, the request will be allowed to continue. +If the API key is invalid or not exists, the request will be denied with 401 status code. +If the API key is valid but the related client is not allowed, the request will be denied with +403 status code. + +Configuration +------------- + +* This filter should be configured with the type URL ``type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth``. +* :ref:`v3 API reference ` + +An example configuration of the filter may look like the following: + +.. literalinclude:: _include/api-key-auth-filter.yaml + :language: yaml + :lines: 57-66 + :linenos: + :caption: :download:`api-key-auth-filter.yaml <_include/api-key-auth-filter.yaml>` + +Per-Route Configuration +----------------------- + +It's possible to override the filter's configuration for a specific scope like a route or virtual host. +And the overriding configuration could be partial to override only credential list or to override only +the API key source. + +And this filter also provides very limited authorization control. A simple ``allowed_clients`` could be +configured for specific scope like a route or virtual host to allow or deny specific clients. + +An example scope specific configuration of the filter may look like the following: + +.. literalinclude:: _include/api-key-auth-filter.yaml + :language: yaml + :lines: 16-55 + :linenos: + :caption: :download:`api-key-auth-filter.yaml <_include/api-key-auth-filter.yaml>` + +In this example we customize key source for ``/admin`` route and only allow limited clients to access +this route. We also customize the credential list for ``/special`` route and disable the filter for +``/static`` route. + +Combining the per-route configuration example and the filter configuration example, given the following +requests, the filter will behave as follows: + +.. code-block:: text + + # The request will be allowed because the API key is valid and the client is allowed. + GET /admin?api_key=another_key HTTP/1.1 + host: example.com + + # The request will be denied with 403 status code because the API key is valid but the client is + # not allowed. + GET /admin?api_key=one_key HTTP/1.1 + host: example.com + + # The request will be denied with 401 status code because the API key is invalid. + GET /admin?api_key=invalid_key HTTP/1.1 + host: example.com + + # The request will be allowed because the API key is valid and no client validation is configured. + GET /special HTTP/1.1 + host: example.com + X-Special-Key: "special_key" + + # The request will be allowed because the filter is disabled for specific route. + GET /static HTTP/1.1 + host: example.com + + # The request will be allowed because the API key is valid and no client validation is configured. + GET / HTTP/1.1 + host: example.com + Authorization: "Bearer one_key" + +Statistics +---------- + +The HTTP API key auth filter outputs statistics in the ``http..api_key_auth.`` namespace. + +.. csv-table:: + :header: Name, Type, Description + :widths: 1, 1, 2 + + allowed, Counter, Total number of allowed requests + unauthorized, Counter, Total number of requests that have invalid API key + forbidden, Counter, Total number of requests that have valid API key but not allowed diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index ebb07a3fa750..88757ad781de 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -9,6 +9,7 @@ HTTP filters adaptive_concurrency_filter admission_control_filter aws_lambda_filter + api_key_auth_filter aws_request_signing_filter bandwidth_limit_filter basic_auth_filter diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 2328680286cc..ae586d59cb76 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -133,6 +133,7 @@ EXTENSIONS = { "envoy.filters.http.adaptive_concurrency": "//source/extensions/filters/http/adaptive_concurrency:config", "envoy.filters.http.admission_control": "//source/extensions/filters/http/admission_control:config", "envoy.filters.http.alternate_protocols_cache": "//source/extensions/filters/http/alternate_protocols_cache:config", + "envoy.filters.http.api_key_auth": "//source/extensions/filters/http/api_key_auth:config", "envoy.filters.http.aws_lambda": "//source/extensions/filters/http/aws_lambda:config", "envoy.filters.http.aws_request_signing": "//source/extensions/filters/http/aws_request_signing:config", "envoy.filters.http.bandwidth_limit": "//source/extensions/filters/http/bandwidth_limit:config", diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index ad51fb5c37b9..4db356a93c02 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -236,6 +236,14 @@ envoy.filters.http.basic_auth: type_urls: - envoy.extensions.filters.http.basic_auth.v3.BasicAuth - envoy.extensions.filters.http.basic_auth.v3.BasicAuthPerRoute +envoy.filters.http.api_key_auth: + categories: + - envoy.filters.http + security_posture: robust_to_untrusted_downstream + status: alpha + type_urls: + - envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + - envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute envoy.filters.http.buffer: categories: - envoy.filters.http diff --git a/source/extensions/filters/http/api_key_auth/BUILD b/source/extensions/filters/http/api_key_auth/BUILD new file mode 100644 index 000000000000..40ba158b5e9f --- /dev/null +++ b/source/extensions/filters/http/api_key_auth/BUILD @@ -0,0 +1,40 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_library( + name = "api_key_auth_lib", + srcs = ["api_key_auth.cc"], + hdrs = ["api_key_auth.h"], + deps = [ + "//envoy/server:filter_config_interface", + "//source/common/common:base64_lib", + "//source/common/config:utility_lib", + "//source/common/http:header_map_lib", + "//source/common/http:header_utility_lib", + "//source/common/protobuf:utility_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":api_key_auth_lib", + "//envoy/registry", + "//source/common/config:datasource_lib", + "//source/common/protobuf:utility_lib", + "//source/extensions/filters/http/common:factory_base_lib", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/http/api_key_auth/api_key_auth.cc b/source/extensions/filters/http/api_key_auth/api_key_auth.cc new file mode 100644 index 000000000000..7e19e5e6a285 --- /dev/null +++ b/source/extensions/filters/http/api_key_auth/api_key_auth.cc @@ -0,0 +1,154 @@ +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" + +#include + +#include "envoy/http/header_map.h" + +#include "source/common/common/base64.h" +#include "source/common/http/header_utility.h" +#include "source/common/http/headers.h" +#include "source/common/http/utility.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { + +RouteConfig::RouteConfig(const ApiKeyAuthPerRouteProto& proto) : override_config_(proto) { + allowed_clients_.insert(proto.allowed_clients().begin(), proto.allowed_clients().end()); +} + +KeySources::Source::Source(absl::string_view header, absl::string_view query, + absl::string_view cookie) { + if (!header.empty()) { + source_ = Http::LowerCaseString(header); + } else if (!query.empty()) { + source_ = std::string(query); + query_source_ = true; + } else if (!cookie.empty()) { + source_ = std::string(cookie); + } else { + throw EnvoyException("One of 'header'/'query'/'cookie' must be set."); + } +} + +KeySources::KeySources(const Protobuf::RepeatedPtrField& proto_config) { + key_sources_.reserve(proto_config.size()); + for (const auto& source : proto_config) { + key_sources_.emplace_back(source.header(), source.query(), source.cookie()); + } +} + +absl::string_view KeySources::Source::getKey(const Http::RequestHeaderMap& headers, + std::string& buffer) const { + if (absl::holds_alternative(source_)) { + if (const auto header = headers.get(absl::get(source_)); + !header.empty()) { + absl::string_view header_view = header[0]->value().getStringView(); + if (absl::StartsWith(header_view, "Bearer ")) { + header_view = header_view.substr(7); + } + return header_view; + } + } else if (query_source_) { + auto params = + Http::Utility::QueryParamsMulti::parseAndDecodeQueryString(headers.getPathValue()); + if (auto iter = params.data().find(absl::get(source_)); + iter != params.data().end()) { + if (!iter->second.empty()) { + buffer = std::move(iter->second[0]); + return buffer; + } + } + } else { + buffer = Http::Utility::parseCookieValue(headers, absl::get(source_)); + return buffer; + } + + return {}; +} + +absl::string_view KeySources::getKey(const Http::RequestHeaderMap& headers, + std::string& buffer) const { + for (const auto& source : key_sources_) { + if (auto key = source.getKey(headers, buffer); !key.empty()) { + return key; + } + } + return {}; +} + +FilterConfig::FilterConfig(const ApiKeyAuthProto& proto_config, Stats::Scope& scope, + const std::string& stats_prefix) + : default_config_(proto_config), stats_(generateStats(scope, stats_prefix + "api_key_auth.")) {} + +ApiKeyAuthFilter::ApiKeyAuthFilter(FilterConfigSharedPtr config) : config_(std::move(config)) {} + +Http::FilterHeadersStatus ApiKeyAuthFilter::decodeHeaders(Http::RequestHeaderMap& headers, bool) { + const RouteConfig* route_config = + Http::Utility::resolveMostSpecificPerFilterConfig(decoder_callbacks_); + + OptRef credentials = config_->credentials(); + OptRef key_sources = config_->keySources(); + + // If there is an override config, then try to override the API key map and key source. + if (route_config != nullptr) { + if (OptRef route_credentials = route_config->credentials(); + route_credentials.has_value()) { + credentials = route_credentials; + } + if (OptRef route_key_sources = route_config->keySources(); + route_key_sources.has_value()) { + key_sources = route_key_sources; + } + } + + if (!key_sources.has_value()) { + return onDenied(Http::Code::Unauthorized, "Client authentication failed.", + "missing_key_sources"); + } + if (!credentials.has_value()) { + return onDenied(Http::Code::Unauthorized, "Client authentication failed.", + "missing_credentials"); + } + + std::string key_buffer; + absl::string_view key_result = key_sources->getKey(headers, key_buffer); + + if (key_result.empty()) { + return onDenied(Http::Code::Unauthorized, "Client authentication failed.", "missing_api_key"); + } + + const auto credential = credentials->find(key_result); + if (credential == credentials->end()) { + return onDenied(Http::Code::Unauthorized, "Client authentication failed.", "unkonwn_api_key"); + } + + // If route config is not null then check if the client is allowed or not based on the route + // configuration. + if (route_config != nullptr) { + if (!route_config->allowClient(credential->second)) { + return onDenied(Http::Code::Forbidden, "Client is forbidden.", "client_not_allowed"); + } + } + + config_->stats().allowed_.inc(); + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterHeadersStatus ApiKeyAuthFilter::onDenied(Http::Code code, absl::string_view body, + absl::string_view response_code_details) { + if (code == Http::Code::Unauthorized) { + config_->stats().unauthorized_.inc(); + } else { + config_->stats().forbidden_.inc(); + } + + decoder_callbacks_->sendLocalReply(code, body, nullptr, absl::nullopt, response_code_details); + return Http::FilterHeadersStatus::StopIteration; +} + +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/api_key_auth/api_key_auth.h b/source/extensions/filters/http/api_key_auth/api_key_auth.h new file mode 100644 index 000000000000..bf94abbfd127 --- /dev/null +++ b/source/extensions/filters/http/api_key_auth/api_key_auth.h @@ -0,0 +1,201 @@ +#pragma once + +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.validate.h" +#include "envoy/stats/stats_macros.h" + +#include "source/common/common/logger.h" +#include "source/extensions/filters/http/common/pass_through_filter.h" + +#include "absl/container/flat_hash_map.h" +#include "absl/container/flat_hash_set.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { + +/** + * All Basic Auth filter stats. @see stats_macros.h + */ +#define ALL_API_KEY_AUTH_STATS(COUNTER) \ + COUNTER(allowed) \ + COUNTER(unauthorized) \ + COUNTER(forbidden) + +/** + * Struct definition for API key auth stats. @see stats_macros.h + */ +struct ApiKeyAuthStats { + ALL_API_KEY_AUTH_STATS(GENERATE_COUNTER_STRUCT) +}; + +using ApiKeyAuthProto = envoy::extensions::filters::http::api_key_auth::v3::ApiKeyAuth; +using ApiKeyAuthPerRouteProto = + envoy::extensions::filters::http::api_key_auth::v3::ApiKeyAuthPerRoute; +using KeySourceProto = envoy::extensions::filters::http::api_key_auth::v3::KeySource; + +// Credentials is a map of API key to client ID. +using Credentials = absl::flat_hash_map; + +/** + * The sources to get the API key from the incoming request. + */ +class KeySources { +public: + KeySources(const Protobuf::RepeatedPtrField& proto_config); + + /** + * To get the API key from the incoming request. + * @param headers the incoming request headers. + * @param buffer the buffer that used to store the API key value that parsed from query or + * cookie. + * @return the result string view of getting the API key. The string view will reference to + * HTTP request header value or the input buffer. + */ + absl::string_view getKey(const Http::RequestHeaderMap& headers, std::string& buffer) const; + + /** + * To check if the sources are empty. + */ + bool empty() const { return key_sources_.empty(); } + +private: + class Source { + public: + Source(absl::string_view header, absl::string_view query, absl::string_view cookie); + absl::string_view getKey(const Http::RequestHeaderMap& headers, std::string& buffer) const; + + private: + absl::variant source_{""}; + bool query_source_{}; + }; + + std::vector key_sources_; +}; + +/** + * The parsed configuration for API key auth. This class is shared by the filter configuration + * and the route configuration. + */ +struct ApiKeyAuthConfig { +public: + template + ApiKeyAuthConfig(const ProtoType& proto_config) : key_sources_(proto_config.key_sources()) { + credentials_.reserve(proto_config.credentials().size()); + + for (const auto& credential : proto_config.credentials()) { + if (credentials_.contains(credential.key())) { + throw EnvoyException("Duplicate API key."); + } + credentials_[credential.key()] = credential.client(); + } + } + + /** + * To get the optional reference of the key sources. + */ + OptRef keySources() const { + return !key_sources_.empty() ? makeOptRef(key_sources_) : OptRef{}; + } + + /** + * To get the optional reference of the credentials. + */ + OptRef credentials() const { + return !credentials_.empty() ? makeOptRef(credentials_) + : OptRef{}; + } + +private: + const KeySources key_sources_; + Credentials credentials_; +}; + +class RouteConfig : public Router::RouteSpecificFilterConfig { +public: + RouteConfig(const ApiKeyAuthPerRouteProto& proto_config); + + /** + * To get the optional reference of the credentials. If this returns an valid reference, then + * the credentials will override the default credentials. + */ + OptRef credentials() const { return override_config_.credentials(); } + + /** + * To get the optional reference of the key sources. If this returns an valid reference, then + * the key sources will override the default key sources. + */ + OptRef keySources() const { return override_config_.keySources(); } + + /** + * To check if the client is allowed. + * @param client the client ID to check. + * @return true if the client is allowed, otherwise false. + */ + bool allowClient(absl::string_view client) const { + return allowed_clients_.empty() || allowed_clients_.contains(client); + } + +private: + ApiKeyAuthConfig override_config_; + absl::flat_hash_set allowed_clients_; +}; + +struct AuthResult { + bool authenticated{}; + bool authorized{}; + absl::string_view response_code_details{}; +}; + +class FilterConfig { +public: + FilterConfig(const ApiKeyAuthProto& proto_config, Stats::Scope& scope, + const std::string& stats_prefix); + + /** + * To get the optional reference of the default credentials. + */ + OptRef credentials() const { return default_config_.credentials(); } + + /** + * To get the optional reference of the default key sources. + */ + OptRef keySources() const { return default_config_.keySources(); } + + /** + * To get the stats of the filter. + */ + ApiKeyAuthStats& stats() { return stats_; } + +private: + static ApiKeyAuthStats generateStats(Stats::Scope& scope, const std::string& prefix) { + return ApiKeyAuthStats{ALL_API_KEY_AUTH_STATS(POOL_COUNTER_PREFIX(scope, prefix))}; + } + + ApiKeyAuthConfig default_config_; + ApiKeyAuthStats stats_; +}; + +using FilterConfigSharedPtr = std::shared_ptr; + +// The Envoy filter to process HTTP api key auth. +class ApiKeyAuthFilter : public Http::PassThroughDecoderFilter, + public Logger::Loggable { +public: + ApiKeyAuthFilter(FilterConfigSharedPtr config); + + // Http::StreamDecoderFilter + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool) override; + +private: + Http::FilterHeadersStatus onDenied(Http::Code code, absl::string_view body, + absl::string_view response_code_details); + + FilterConfigSharedPtr config_; +}; + +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/api_key_auth/config.cc b/source/extensions/filters/http/api_key_auth/config.cc new file mode 100644 index 000000000000..ed7c9a69e883 --- /dev/null +++ b/source/extensions/filters/http/api_key_auth/config.cc @@ -0,0 +1,34 @@ +#include "source/extensions/filters/http/api_key_auth/config.h" + +#include "source/common/config/datasource.h" +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { + +Http::FilterFactoryCb ApiKeyAuthFilterFactory::createFilterFactoryFromProtoTyped( + const ApiKeyAuthProto& proto_config, const std::string& stats_prefix, + Server::Configuration::FactoryContext& context) { + + FilterConfigSharedPtr config = + std::make_unique(proto_config, context.scope(), stats_prefix); + return [config](Http::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamDecoderFilter(std::make_shared(config)); + }; +} + +Router::RouteSpecificFilterConfigConstSharedPtr +ApiKeyAuthFilterFactory::createRouteSpecificFilterConfigTyped( + const ApiKeyAuthPerRouteProto& proto_config, Server::Configuration::ServerFactoryContext&, + ProtobufMessage::ValidationVisitor&) { + return std::make_unique(proto_config); +} + +REGISTER_FACTORY(ApiKeyAuthFilterFactory, Server::Configuration::NamedHttpFilterConfigFactory); + +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/api_key_auth/config.h b/source/extensions/filters/http/api_key_auth/config.h new file mode 100644 index 000000000000..461e6c19e6e2 --- /dev/null +++ b/source/extensions/filters/http/api_key_auth/config.h @@ -0,0 +1,32 @@ +#pragma once + +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.validate.h" + +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" +#include "source/extensions/filters/http/common/factory_base.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { + +class ApiKeyAuthFilterFactory + : public Common::FactoryBase { +public: + ApiKeyAuthFilterFactory() : FactoryBase("envoy.filters.http.api_key_auth") {} + +private: + Http::FilterFactoryCb + createFilterFactoryFromProtoTyped(const ApiKeyAuthProto& config, const std::string& stats_prefix, + Server::Configuration::FactoryContext& context) override; + Router::RouteSpecificFilterConfigConstSharedPtr + createRouteSpecificFilterConfigTyped(const ApiKeyAuthPerRouteProto& proto_config, + Server::Configuration::ServerFactoryContext&, + ProtobufMessage::ValidationVisitor&) override; +}; + +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/api_key_auth/BUILD b/test/extensions/filters/http/api_key_auth/BUILD new file mode 100644 index 000000000000..d3ef16735425 --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/BUILD @@ -0,0 +1,81 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_fuzz_test", + "envoy_package", + "envoy_proto_library", +) +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "api_key_auth_test", + srcs = ["api_key_auth_test.cc"], + extension_names = ["envoy.filters.http.api_key_auth"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/api_key_auth:api_key_auth_lib", + "//test/mocks/server:server_mocks", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.filters.http.api_key_auth"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/api_key_auth:config", + "//test/mocks/server:server_mocks", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "integration_test", + size = "large", + srcs = ["integration_test.cc"], + extension_names = ["envoy.filters.http.api_key_auth"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/api_key_auth:config", + "//test/integration:http_protocol_integration_lib", + "//test/test_common:utility_lib", + "@envoy_api//envoy/config/route/v3:pkg_cc_proto", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) + +envoy_proto_library( + name = "api_key_auth_fuzz_proto", + srcs = ["api_key_auth_fuzz.proto"], + deps = [ + "//test/fuzz:common_proto", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg", + ], +) + +envoy_cc_fuzz_test( + name = "api_key_auth_fuzz_test", + srcs = ["api_key_auth_fuzz_test.cc"], + corpus = "api_key_auth_corpus", + rbe_pool = "6gig", + deps = [ + ":api_key_auth_fuzz_proto_cc_proto", + "//source/common/common:regex_lib", + "//source/common/router:string_accessor_lib", + "//source/extensions/filters/http/api_key_auth:api_key_auth_lib", + "//test/extensions/filters/http/common:mock_lib", + "//test/extensions/filters/http/common/fuzz:http_filter_fuzzer_lib", + "//test/fuzz:utility_lib", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:utility_lib", + "@envoy_api//envoy/extensions/filters/http/api_key_auth/v3:pkg_cc_proto", + ], +) diff --git a/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth b/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth new file mode 100644 index 000000000000..c3ce7a53b765 --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth @@ -0,0 +1,39 @@ +filter_config { + credentials { + credentials { + key: "fake-key" + client: "fake-client" + } + credentials { + key: "fake-key-2" + client: "fake-client-2" + } + } + key_sources { + key_sources { + header: "authorization" + } + key_sources { + cookie: "api_key" + } + key_sources { + query: "api_key" + } + } +} +request_data { + headers { + headers { + key: "authorization" + value: "Bearer fake-key" + } + headers { + key: ":path" + value: "/foo/bar?api_key=fake-key" + } + headers { + key: "cookie" + value: "api_key=fake-key" + } + } +} diff --git a/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth_with_override b/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth_with_override new file mode 100644 index 000000000000..d705c084d5d9 --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/api_key_auth_corpus/api_key_auth_with_override @@ -0,0 +1,50 @@ +filter_config { + credentials { + credentials { + key: "fake-key" + client: "fake-client" + } + credentials { + key: "fake-key-2" + client: "fake-client-2" + } + } + key_sources { + key_sources { + header: "authorization" + } + key_sources { + cookie: "api_key" + } + } +} +route_config { + credentials { + credentials { + key: "fake-key-3" + client: "fake-client-3" + } + } + key_sources { + key_sources { + query: "api_key" + } + } + allowed_clients: ["fake-client-3"] +} +request_data { + headers { + headers { + key: "authorization" + value: "Bearer fake-key" + } + headers { + key: ":path" + value: "/foo/bar?api_key=fake-key" + } + headers { + key: "cookie" + value: "api_key=fake-key" + } + } +} diff --git a/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz.proto b/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz.proto new file mode 100644 index 000000000000..0b4504a8e49d --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.api_key_auth; + +import "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.proto"; +import "test/fuzz/common.proto"; +import "validate/validate.proto"; + +message ApiKeyAuthFuzzInput { + envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth filter_config = 1 + [(validate.rules).message = {required: true}]; + + envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuthPerRoute route_config = 2; + + // HTTP request data. + test.fuzz.HttpData request_data = 3 [(validate.rules).message = {required: true}]; +} diff --git a/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz_test.cc b/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz_test.cc new file mode 100644 index 000000000000..e18895223230 --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/api_key_auth_fuzz_test.cc @@ -0,0 +1,71 @@ +#include + +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.validate.h" + +#include "source/common/common/regex.h" +#include "source/common/http/message_impl.h" +#include "source/common/router/string_accessor_impl.h" +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" + +#include "test/extensions/filters/http/api_key_auth/api_key_auth_fuzz.pb.h" +#include "test/extensions/filters/http/api_key_auth/api_key_auth_fuzz.pb.validate.h" +#include "test/extensions/filters/http/common/fuzz/http_filter_fuzzer.h" +#include "test/fuzz/fuzz_runner.h" +#include "test/mocks/server/factory_context.h" + +#include "gmock/gmock.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { +namespace { + +using envoy::extensions::filters::http::api_key_auth::ApiKeyAuthFuzzInput; +using testing::NiceMock; + +DEFINE_PROTO_FUZZER(const ApiKeyAuthFuzzInput& input) { + try { + TestUtility::validate(input); + } catch (const EnvoyException& e) { + ENVOY_LOG_MISC(debug, "EnvoyException during validation: {}", e.what()); + return; + } + + NiceMock mock_factory_ctx; + NiceMock filter_callbacks; + + // Mock per route config. + std::unique_ptr route_config; + ON_CALL(filter_callbacks, mostSpecificPerFilterConfig()).WillByDefault(Invoke([&]() { + return route_config.get(); + })); + + std::shared_ptr filter_config; + + try { + filter_config = + std::make_shared(input.filter_config(), mock_factory_ctx.scope_, "stats."); + } catch (const EnvoyException& e) { + ENVOY_LOG_MISC(debug, "EnvoyException during filter config construction: {}", e.what()); + return; + } + + // Simulate multiple calls to execute jwt_cache and jwks_cache codes + auto filter = std::make_unique(filter_config); + filter->setDecoderFilterCallbacks(filter_callbacks); + + HttpFilterFuzzer fuzzer; + fuzzer.runData(static_cast(filter.get()), input.request_data()); + + filter->onDestroy(); + + fuzzer.reset(); +} + +} // namespace +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/api_key_auth/api_key_auth_test.cc b/test/extensions/filters/http/api_key_auth/api_key_auth_test.cc new file mode 100644 index 000000000000..5f941496731f --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/api_key_auth_test.cc @@ -0,0 +1,488 @@ +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" + +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" + +#include "test/mocks/http/mocks.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { + +class FilterTest : public testing::Test { +public: + void setup(const std::string& config_yaml, const std::string& route_config_yaml) { + ApiKeyAuthProto proto_config; + TestUtility::loadFromYaml(config_yaml, proto_config); + config_ = std::make_shared(proto_config, *stats_.rootScope(), "stats."); + + if (!route_config_yaml.empty()) { + ApiKeyAuthPerRouteProto route_config_proto; + TestUtility::loadFromYaml(route_config_yaml, route_config_proto); + route_config_ = std::make_shared(route_config_proto); + } + + filter_ = std::make_shared(config_); + filter_->setDecoderFilterCallbacks(decoder_filter_callbacks_); + ON_CALL(decoder_filter_callbacks_, mostSpecificPerFilterConfig()) + .WillByDefault(Invoke([this]() { return route_config_.get(); })); + } + + Stats::IsolatedStoreImpl stats_; + NiceMock decoder_filter_callbacks_; + FilterConfigSharedPtr config_; + std::shared_ptr route_config_; + std::shared_ptr filter_; +}; + +TEST_F(FilterTest, NoHeaderApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/"}}; + + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, HeaderApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/"}, {"Authorization", "Bearer key1"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); +} + +TEST_F(FilterTest, NoQueryApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - query: "api_key" + )EOF"; + setup(config_yaml, {}); + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, QueryApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - query: "api_key" + )EOF"; + setup(config_yaml, {}); + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path?api_key=key1"}}; + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); +} + +TEST_F(FilterTest, NoCookieApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - cookie: "api_key" + )EOF"; + setup(config_yaml, {}); + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}}; + + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, CookieApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - cookie: "api_key" + )EOF"; + + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}, {"cookie", "api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); +} + +TEST_F(FilterTest, FallbackToQueryApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + - query: "api_key" + )EOF"; + + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path?api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); +} + +TEST_F(FilterTest, FallbackToCookieApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + - query: "api_key" + - cookie: "api_key" + )EOF"; + + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}, {"cookie", "api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); +} + +TEST_F(FilterTest, OrderOfKeySources) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + - query: "api_key" + - cookie: "api_key" + )EOF"; + + setup(config_yaml, {}); + + { + // Header, query, and cookie all have the key. The filter should use the header. + // But the header contains the wrong key. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path?api_key=key1"}, + {"cookie", "api_key=key1"}, + {"Authorization", "Bearer key2"}}; + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); + } + + { + // Header, query, and cookie all have the key. The filter should use the header. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path?api_key=key1"}, + {"cookie", "api_key=key1"}, + {"Authorization", "Bearer key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); + } + + { + // Query and cookie have the key. The filter should use the query. + // But the query contains the wrong key. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path?api_key=key2"}, + {"cookie", "api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 2); + } + + { + // Query and cookie have the key. The filter should use the query. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path?api_key=key1"}, + {"cookie", "api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 2); + } +} + +TEST_F(FilterTest, UnkonwnApiKey) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + setup(config_yaml, {}); + + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key2"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "unkonwn_api_key")); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, RouteConfigOverrideCredentials) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + + const std::string route_config_yaml = R"EOF( + credentials: + - key: key2 + client: user2 + )EOF"; + + setup(config_yaml, route_config_yaml); + + { + // Credentials is overridden and key2 is allowed. + + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key2"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); + } + + { + // Credentials is overridden and key1 is not allowed. + + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key1"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "unkonwn_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); + } +} + +TEST_F(FilterTest, RouteConfigOverrideKeySource) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + + const std::string route_config_yaml = R"EOF( + key_sources: + - query: "api_key" + )EOF"; + + setup(config_yaml, route_config_yaml); + + { + + // Key source is overridden and the filter will use query. + + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path?api_key=key1"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); + } + + { + // Key source is overridden so the filter cannot find the key. + + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key1"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + } +} + +TEST_F(FilterTest, RouteConfigOverrideKeySourceAndCredentials) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + key_sources: + - header: "Authorization" + )EOF"; + const std::string route_config_yaml = R"EOF( + credentials: + - key: key2 + client: user2 + key_sources: + - query: "api_key" + )EOF"; + + setup(config_yaml, route_config_yaml); + + { + // Both key source and credentials are overridden. + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path?api_key=key2"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); + } + + { + // Both key source and credentials are overridden. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key1"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); + } + + { + // Both key source and credentials are overridden. + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path?api_key=key1"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "unkonwn_api_key")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 2); + } +} + +TEST_F(FilterTest, NoCredentials) { + const std::string config_yaml = R"EOF( + key_sources: + - header: "Authorization" + )EOF"; + + setup(config_yaml, {}); + + // No credentials is provided. + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_credentials")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, NoKeySource) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + )EOF"; + setup(config_yaml, {}); + // No key source is provided. + Http::TestRequestHeaderMapImpl request_headers{ + {":authority", "host"}, {":method", "GET"}, {":path", "/path"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Unauthorized, _, _, _, "missing_key_sources")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.unauthorized").value(), 1); +} + +TEST_F(FilterTest, KnownApiKeyButNotAllowed) { + const std::string config_yaml = R"EOF( + credentials: + - key: key1 + client: user1 + - key: key2 + client: user2 + key_sources: + - header: "Authorization" + )EOF"; + const std::string route_config_yaml = R"EOF( + allowed_clients: + - user2 + )EOF"; + + setup(config_yaml, route_config_yaml); + + { + // Known api key but not allowed. + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key1"}}; + EXPECT_CALL(decoder_filter_callbacks_, + sendLocalReply(Http::Code::Forbidden, _, _, _, "client_not_allowed")); + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.forbidden").value(), 1); + } + + { + Http::TestRequestHeaderMapImpl request_headers{{":authority", "host"}, + {":method", "GET"}, + {":path", "/path"}, + {"Authorization", "Bearer key2"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(stats_.counterFromString("stats.api_key_auth.allowed").value(), 1); + } +} + +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/api_key_auth/config_test.cc b/test/extensions/filters/http/api_key_auth/config_test.cc new file mode 100644 index 000000000000..154a01a47ccd --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/config_test.cc @@ -0,0 +1,105 @@ +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" + +#include "source/extensions/filters/http/api_key_auth/api_key_auth.h" +#include "source/extensions/filters/http/api_key_auth/config.h" + +#include "test/mocks/server/mocks.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { +namespace { + +TEST(ApiKeyAuthFilterFactoryTest, DuplicateApiKey) { + const std::string yaml = R"( + credentials: + - key: key1 + client: user1 + - key: key1 + client: user2 + key_sources: + - header: "Authorization + )"; + + ApiKeyAuthProto proto_config; + TestUtility::loadFromYaml(yaml, proto_config); + + ApiKeyAuthFilterFactory factory; + NiceMock context; + + EXPECT_THROW_WITH_MESSAGE( + { auto status_or = factory.createFilterFactoryFromProto(proto_config, "stats", context); }, + EnvoyException, "Duplicate API key."); +} + +TEST(ApiKeyAuthFilterFactoryTest, EmptyKeySource) { + const std::string yaml = R"( + credentials: + - key: key1 + client: user1 + - key: key2 + client: user2 + key_sources: + - header: "" + )"; + + ApiKeyAuthProto proto_config; + TestUtility::loadFromYaml(yaml, proto_config); + + ApiKeyAuthFilterFactory factory; + NiceMock context; + + EXPECT_THROW_WITH_MESSAGE( + { auto status_or = factory.createFilterFactoryFromProto(proto_config, "stats", context); }, + EnvoyException, "One of 'header'/'query'/'cookie' must be set."); +} + +TEST(ApiKeyAuthFilterFactoryTest, NormalFactory) { + const std::string yaml = R"( + credentials: + - key: key1 + client: user1 + - key: key2 + client: user2 + key_sources: + - header: "Authorization" + )"; + + ApiKeyAuthProto proto_config; + TestUtility::loadFromYaml(yaml, proto_config); + + const std::string scope_yaml = R"( + credentials: + - key: key3 + client: user3 + allowed_clients: + - user1 + )"; + ApiKeyAuthPerRouteProto scope_proto_config; + TestUtility::loadFromYaml(scope_yaml, scope_proto_config); + + ApiKeyAuthFilterFactory factory; + NiceMock context; + + auto status_or = factory.createFilterFactoryFromProto(proto_config, "stats", context); + EXPECT_TRUE(status_or.ok()); + + Http::MockFilterChainFactoryCallbacks filter_callback; + EXPECT_CALL(filter_callback, addStreamDecoderFilter(_)); + status_or.value()(filter_callback); + + const auto route_config = + factory.createRouteSpecificFilterConfig(scope_proto_config, context.server_factory_context_, + ProtobufMessage::getNullValidationVisitor()); + EXPECT_TRUE(route_config != nullptr); +} + +} // namespace +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/api_key_auth/integration_test.cc b/test/extensions/filters/http/api_key_auth/integration_test.cc new file mode 100644 index 000000000000..0388d172075a --- /dev/null +++ b/test/extensions/filters/http/api_key_auth/integration_test.cc @@ -0,0 +1,164 @@ +#include + +#include "envoy/config/route/v3/route_components.pb.h" +#include "envoy/extensions/filters/http/api_key_auth/v3/api_key_auth.pb.h" + +#include "test/integration/http_protocol_integration.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace ApiKeyAuth { +namespace { + +const std::string ApiKeyAuthFilterConfig = + R"EOF( +name: envoy.filters.http.api_key_auth +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.api_key_auth.v3.ApiKeyAuth + credentials: + - key: key1 + client: user1 + - key: key2 + client: user2 + key_sources: + - header: "Authorization" +)EOF"; + +const std::string ApiKeyAuthScopeConfig = + R"EOF( +allowed_clients: +- user1 +)EOF"; + +class ApiKeyAuthIntegrationTest : public HttpProtocolIntegrationTest { +public: + void initializeFilter() { + config_helper_.prependFilter(ApiKeyAuthFilterConfig); + initialize(); + } + + void initializeWithPerRouteConfig() { + config_helper_.addConfigModifier( + [](envoy::extensions::filters::network::http_connection_manager::v3::HttpConnectionManager& + cfg) { + envoy::extensions::filters::http::api_key_auth::v3::ApiKeyAuthPerRoute per_route_config; + TestUtility::loadFromYaml(ApiKeyAuthScopeConfig, per_route_config); + + auto* config = cfg.mutable_route_config() + ->mutable_virtual_hosts() + ->Mutable(0) + ->mutable_typed_per_filter_config(); + + (*config)["envoy.filters.http.api_key_auth"].PackFrom(per_route_config); + }); + config_helper_.prependFilter(ApiKeyAuthFilterConfig); + initialize(); + } +}; + +INSTANTIATE_TEST_SUITE_P( + Protocols, ApiKeyAuthIntegrationTest, + testing::ValuesIn(HttpProtocolIntegrationTest::getProtocolTestParamsWithoutHTTP3()), + HttpProtocolIntegrationTest::protocolTestParamsToString); + +// Request with valid credential. +TEST_P(ApiKeyAuthIntegrationTest, ValidCredential) { + initializeFilter(); + + codec_client_ = makeHttpConnection(lookupPort("http")); + { + auto response = codec_client_->makeHeaderOnlyRequest(Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"Authorization", "Bearer key1"}, + }); + + waitForNextUpstreamRequest(); + + upstream_request_->encodeHeaders(Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + ASSERT_TRUE(response->waitForEndStream()); + ASSERT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().getStatusValue()); + } + { + auto response = codec_client_->makeHeaderOnlyRequest(Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"Authorization", "Bearer key2"}, + }); + + waitForNextUpstreamRequest(); + + upstream_request_->encodeHeaders(Http::TestResponseHeaderMapImpl{{":status", "200"}}, true); + ASSERT_TRUE(response->waitForEndStream()); + ASSERT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().getStatusValue()); + } +} + +TEST_P(ApiKeyAuthIntegrationTest, NoCredential) { + initializeFilter(); + codec_client_ = makeHttpConnection(lookupPort("http")); + + auto response = codec_client_->makeHeaderOnlyRequest(Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + }); + + ASSERT_TRUE(response->waitForEndStream()); + ASSERT_TRUE(response->complete()); + EXPECT_EQ("401", response->headers().getStatusValue()); + EXPECT_EQ("Client authentication failed.", response->body()); +} + +TEST_P(ApiKeyAuthIntegrationTest, InvalidCredentical) { + initializeFilter(); + codec_client_ = makeHttpConnection(lookupPort("http")); + + auto response = codec_client_->makeHeaderOnlyRequest(Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"Authorization", "Bearer key3"}, + }); + + ASSERT_TRUE(response->waitForEndStream()); + ASSERT_TRUE(response->complete()); + EXPECT_EQ("401", response->headers().getStatusValue()); + EXPECT_EQ("Client authentication failed.", response->body()); +} + +TEST_P(ApiKeyAuthIntegrationTest, NotAllowdClientOfPerRouteOverride) { + initializeWithPerRouteConfig(); + codec_client_ = makeHttpConnection(lookupPort("http")); + + auto response = codec_client_->makeHeaderOnlyRequest(Http::TestRequestHeaderMapImpl{ + {":method", "GET"}, + {":path", "/"}, + {":scheme", "http"}, + {":authority", "host"}, + {"Authorization", "Bearer key2"}, + }); + + ASSERT_TRUE(response->waitForEndStream()); + ASSERT_TRUE(response->complete()); + EXPECT_EQ("403", response->headers().getStatusValue()); + EXPECT_EQ("Client is forbidden.", response->body()); +} + +} // namespace +} // namespace ApiKeyAuth +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/code_format/config.yaml b/tools/code_format/config.yaml index fe97c500701a..19640df2a3db 100644 --- a/tools/code_format/config.yaml +++ b/tools/code_format/config.yaml @@ -168,6 +168,7 @@ paths: - source/extensions/compression/zstd/common/dictionary_manager.h - source/extensions/filters/http/adaptive_concurrency/controller - source/extensions/filters/http/admission_control + - source/extensions/filters/http/api_key_auth - source/extensions/filters/http/aws_lambda - source/extensions/filters/http/bandwidth_limit - source/extensions/filters/http/basic_auth