From 5620d4862d04f06dc02a6aa2b0f88a8052811565 Mon Sep 17 00:00:00 2001 From: Deepesh Pathak Date: Sun, 11 Jan 2026 11:09:34 +0530 Subject: [PATCH] sni: add support for wildcard specifiers anywhere in the pattern This commit adds support for specifying wildcard('*') anywhere in the server names match pattern. This allow users to write more compressed network policies and is inline with what cilium/cilium supports for FQDN match patterns. With this change users can now write allowed server names as: - '**.cilium.io': Existing behavior which matches any number of subdomain levels in the prefix. "test.cilium.io" and "test.app.cilium.io" matches but "cilium.io" does not. - '*.cilium.io': Existing behavior which matches all subdomains of cilium.io on a single level. "test.cilium.io" matches but "test.app.cilium.io" and "cilium.io" do not. - 'sub*.cilium.io': Matches subdomains of cilium.io where the subdomain component begins with "sub"(only one level). "sub.cilium.io" and "subdomain.cilium.io" matches wile "www.cilium.io", "cilium.io" and "test.subdomain.cilium.io" do not. SNI match patterns are now implemented using regular expressions. The required regex is derived and compiled once during xDS configuration update. If the match pattern doesn't contain any wildcard specifier the implementation relies on explicit full string match. Signed-off-by: Deepesh Pathak --- cilium/network_policy.cc | 2 +- cilium/network_policy.h | 129 +++++++++++++++++++++++----- tests/cilium_network_policy_test.cc | 78 ++++++++++++++--- 3 files changed, 174 insertions(+), 35 deletions(-) diff --git a/cilium/network_policy.cc b/cilium/network_policy.cc index 2fa9a16e4..c83f2715c 100644 --- a/cilium/network_policy.cc +++ b/cilium/network_policy.cc @@ -386,7 +386,7 @@ class PortNetworkPolicyRule : public Logger::Loggable { } for (const auto& sni : rule.server_names()) { ENVOY_LOG(trace, "Cilium L7 PortNetworkPolicyRule(): Allowing SNI {} by rule {}", sni, name_); - allowed_snis_.emplace_back(sni); + allowed_snis_.emplace_back(parent.regexEngine(), sni); } if (rule.has_http_rules()) { for (const auto& http_rule : rule.http_rules().http_rules()) { diff --git a/cilium/network_policy.h b/cilium/network_policy.h index 8f0f34820..bf4c728bd 100644 --- a/cilium/network_policy.h +++ b/cilium/network_policy.h @@ -31,6 +31,7 @@ #include "source/common/common/assert.h" #include "source/common/common/logger.h" #include "source/common/common/macros.h" +#include "source/common/common/regex.h" #include "source/common/common/thread.h" #include "source/common/init/target_impl.h" #include "source/common/protobuf/message_validator_impl.h" @@ -42,6 +43,7 @@ #include "absl/container/flat_hash_map.h" #include "absl/status/status.h" #include "absl/strings/ascii.h" +#include "absl/strings/str_replace.h" #include "absl/strings/string_view.h" #include "cilium/accesslog.h" #include "cilium/api/npds.pb.h" @@ -263,6 +265,8 @@ class NetworkPolicyMapImpl : public Envoy::Config::SubscriptionCallbacks, return *transport_factory_context_; } + Regex::Engine& regexEngine() const { return context_.regexEngine(); } + void tlsWrapperMissingPolicyInc() const; private: @@ -371,41 +375,122 @@ class NetworkPolicyMap : public Singleton::Instance, public Logger::Loggable; -struct SniPattern { - std::string pattern; +// SniPattern provides a matcher for allowed SNI patterns. +// SNI match pattern provided by the user only supports one regex character('*'), which +// can be used as a wildcard specifier. +// +// - "**." is a special prefix which matches all multilevel subdomains in the prefix. +// - "*" matches 0 or more DNS valid characters, and may occur anywhere in the pattern. +// +// Additionaly a full wildcard pattern can be specified using "*" that matches all +// names consisting of valid DNS characters. +// +// Examples: +// 1. `*.cilium.io` matches subdomains of cilium at that level +// www.cilium.io and blog.cilium.io match, cilium.io and google.com do not +// 2. `*cilium.io` matches cilium.io and all subdomains ends with "cilium.io" +// except those containing "." separator, subcilium.io and sub-cilium.io match, +// www.cilium.io and blog.cilium.io does not +// 3. `sub*.cilium.io` matches subdomains of cilium where the subdomain component +// begins with "sub". sub.cilium.io and subdomain.cilium.io match while www.cilium.io, +// blog.cilium.io, cilium.io and google.com do not +// 4. `**.cilium.io` matches all multilevel subdomains of cilium.io. +// "app.cilium.io" and "test.app.cilium.io" match but not "cilium.io" +class SniPattern : public Logger::Loggable { +public: + static const re2::RE2& getValidNonRegexCharsRE() { + // Regular expression to match a SNI pattern without any regex characters('*'). + // If the input sni pattern matches this, we can simply rely on explicit full string match. + CONSTRUCT_ON_FIRST_USE(re2::RE2, "^[-a-zA-Z0-9_.]*$"); + } + + static const re2::RE2& getFullWildcardMatchRE() { + // Explicit '*' is a special case which implies a full wildcard for matching. + CONSTRUCT_ON_FIRST_USE(re2::RE2, "^[*]{1,}$"); + } + + static const re2::RE2& getSubdomainWildcardSpecifierPrefixRE() { + // Regular expression to match subdomain wildcard prefix in sni patterns. + // This regex will match prefixes like '**[.]', '****[.]' and so on. + CONSTRUCT_ON_FIRST_USE(re2::RE2, "^[*]{2,}\\[\\.\\]"); + } + + static const re2::RE2& getWildcardSpecifierRE() { + // Regular expression to match wildcard in SNI pattern. + // Given this match is a superset of subdomain wildcard prefix specifier it should + // be used once the regex is resolved for the former. + CONSTRUCT_ON_FIRST_USE(re2::RE2, "[*]{1,}"); + } + + // Constructs SniPattern with the provided regex engine for input match pattern. + explicit SniPattern(const Regex::Engine& engine, const absl::string_view& sni) { + if (re2::RE2::FullMatch(sni, getValidNonRegexCharsRE())) { + match_name_ = absl::AsciiStrToLower(sni); + return; + } + + std::string regex_expr; + if (re2::RE2::FullMatch(sni, getFullWildcardMatchRE())) { + // For a full wildcard match pattern replace with static wildcard pattern for DNS characters. + regex_expr = "([-a-zA-Z0-9_]+([.][-a-zA-Z0-9_]+){0,})"; + } else { + // Convert '.' to regex literal '[.]' + regex_expr = absl::StrReplaceAll(absl::AsciiStrToLower(sni), {{".", "[.]"}}); + + // Replace subdomain wildcard specifier prefix with multilevel subdomain match. + // The replaced regex pattern matches one or more entire DNS labels, for example: + // * + // * .. + re2::RE2::GlobalReplace(®ex_expr, getSubdomainWildcardSpecifierPrefixRE(), + "([-a-zA-Z0-9_]+([.][-a-zA-Z0-9_]+){0,})[.]"); + + // Replace wildcard specifier '*' with regex for any number of valid DNS characters within + // subdomain boundry(doesn't include '.' literal). + re2::RE2::GlobalReplace(®ex_expr, getWildcardSpecifierRE(), "[-a-zA-Z0-9_]*"); + } + + // Anchor the final regular expression. + auto regex_matcher = engine.matcher(fmt::format("^{}$", regex_expr)); + if (!regex_matcher.ok()) { + ENVOY_LOG(error, + "Cilium SNI: Failed to create pattern for SNI {} " + "[Status: {}]", + sni, regex_matcher.status().ToString()); + return; + } - explicit SniPattern(const std::string& p) : pattern(absl::AsciiStrToLower(p)) {} + matcher_ = std::move(regex_matcher.value()); + } bool matches(const absl::string_view sni) const { - if (pattern.empty() || sni.empty()) { + if (!isExplicitFullMatch() && !matcher_) { return false; } auto const lower_sni = absl::AsciiStrToLower(sni); - // Perform lower case exact match if there is no wildcard prefix - if (!pattern.starts_with("*")) { - return pattern == lower_sni; + if (isExplicitFullMatch()) { + return match_name_ == lower_sni; } - // Pattern is "**." - if (pattern.starts_with("**.")) { - return lower_sni.ends_with(pattern.substr(2)); - } + return matcher_->match(lower_sni); + } - // Pattern is "*." - if (pattern.starts_with("*.")) { - auto const sub_pattern = pattern.substr(1); - if (!lower_sni.ends_with(sub_pattern)) { - return false; + void toString(std::string& res) const { + if (isExplicitFullMatch()) { + res.append(fmt::format("\"{}\"", match_name_)); + } else { + if (matcher_) { + res.append(fmt::format("\"{}\"", matcher_->pattern())); + } else { + res.append("\"\""); } - auto const prefix = lower_sni.substr(0, sni.size() - sub_pattern.size()); - // Make sure that only and exactly one label is before the wildcard - return !prefix.empty() && prefix.find_first_of('.') == std::string::npos; } - - return false; } - void toString(std::string& res) const { res.append(fmt::format("\"{}\"", pattern)); } +private: + bool isExplicitFullMatch() const { return !match_name_.empty(); } + + std::string match_name_; + std::unique_ptr matcher_; }; } // namespace Cilium diff --git a/tests/cilium_network_policy_test.cc b/tests/cilium_network_policy_test.cc index 76dd34313..7ddeecdec 100644 --- a/tests/cilium_network_policy_test.cc +++ b/tests/cilium_network_policy_test.cc @@ -1896,8 +1896,10 @@ TEST_F(CiliumNetworkPolicyTest, EmptyRulesAllow) { } TEST_F(CiliumNetworkPolicyTest, SNIPatternMatching) { + Regex::GoogleReEngine engine; + // Test empty pattern - SniPattern empty(""); + SniPattern empty(engine, ""); EXPECT_FALSE(empty.matches("example.com")); EXPECT_FALSE(empty.matches("EXAMPLE.COM")); EXPECT_FALSE(empty.matches("www.example.com")); @@ -1905,15 +1907,33 @@ TEST_F(CiliumNetworkPolicyTest, SNIPatternMatching) { EXPECT_FALSE(empty.matches("")); // Test exact matches - SniPattern exact("example.com"); + SniPattern exact(engine, "example.com"); EXPECT_TRUE(exact.matches("example.com")); EXPECT_TRUE(exact.matches("EXAMPLE.COM")); EXPECT_FALSE(exact.matches("www.example.com")); EXPECT_FALSE(exact.matches("notexample.com")); EXPECT_FALSE(exact.matches("")); + SniPattern exact_with_subdomain(engine, "foo.bar.example.com"); + EXPECT_TRUE(exact_with_subdomain.matches("foo.bar.example.com")); + EXPECT_TRUE(exact_with_subdomain.matches("foo.BAR.example.COM")); + EXPECT_FALSE(exact_with_subdomain.matches("bar.example.com")); + EXPECT_FALSE(exact_with_subdomain.matches("foo.bar.example.org")); + EXPECT_FALSE(exact_with_subdomain.matches("")); + + SniPattern full_wildcard(engine, "*"); + EXPECT_TRUE(full_wildcard.matches("localhost")); + EXPECT_TRUE(full_wildcard.matches("example.com")); + EXPECT_TRUE(full_wildcard.matches("foo.007.example.com")); + EXPECT_TRUE(full_wildcard.matches("foo.bar.example.com")); + EXPECT_TRUE(full_wildcard.matches("foo.BAR.example.COM")); + EXPECT_TRUE(full_wildcard.matches("foo-bar.example.com")); + EXPECT_FALSE(full_wildcard.matches("example.com.")); + EXPECT_FALSE(full_wildcard.matches("ex@mple.com")); + EXPECT_FALSE(full_wildcard.matches("")); + // Test wildcard matches - SniPattern wild("*.example.com"); + SniPattern wild(engine, "*.example.com"); EXPECT_TRUE(wild.matches("foo.example.com")); EXPECT_TRUE(wild.matches("bar.example.com")); EXPECT_TRUE(wild.matches("FOO.EXAMPLE.COM")); @@ -1922,31 +1942,65 @@ TEST_F(CiliumNetworkPolicyTest, SNIPatternMatching) { EXPECT_FALSE(wild.matches("fooexample.com")); EXPECT_FALSE(wild.matches("")); + // Wildcard with hyphen + SniPattern wildcard_hyphen(engine, "sub.example-*.com"); + EXPECT_TRUE(wildcard_hyphen.matches("sub.example-foo.com")); + EXPECT_TRUE(wildcard_hyphen.matches("sub.example-007.com")); + EXPECT_TRUE(wildcard_hyphen.matches("sub.example-foo-bar.com")); + EXPECT_FALSE(wildcard_hyphen.matches("sub.example.com")); + EXPECT_FALSE(wildcard_hyphen.matches("sub.example-foo.bar.com")); + // Test subdomain wildcard matches - SniPattern subwild("*.sub.example.com"); + SniPattern subwild(engine, "*.sub.example.com"); EXPECT_TRUE(subwild.matches("foo.sub.example.com")); EXPECT_TRUE(subwild.matches("bar.sub.example.com")); + EXPECT_TRUE(subwild.matches("007.sub.example.com")); EXPECT_FALSE(subwild.matches("sub.example.com")); EXPECT_FALSE(subwild.matches("foo.example.com")); EXPECT_FALSE(subwild.matches("foo.bar.sub.example.com")); // Test subdomain double wildcard matches - SniPattern double_wildcard("**.sub.example.com"); + SniPattern double_wildcard(engine, "**.sub.example.com"); EXPECT_TRUE(double_wildcard.matches("foo.sub.example.com")); EXPECT_TRUE(double_wildcard.matches("bar.sub.example.com")); EXPECT_FALSE(double_wildcard.matches("sub.example.com")); EXPECT_FALSE(double_wildcard.matches("foo.example.com")); EXPECT_TRUE(double_wildcard.matches("foo.bar.sub.example.com")); - // Test with unsupported wildcard label - SniPattern wildcard_label("*example.com"); - EXPECT_FALSE(wildcard_label.matches("foo.example.com")); - EXPECT_FALSE(wildcard_label.matches("bar.example.com")); - EXPECT_FALSE(wildcard_label.matches("FOO.EXAMPLE.COM")); - EXPECT_FALSE(wildcard_label.matches("example.com")); - EXPECT_FALSE(wildcard_label.matches("foo.bar.example.com")); + // Test wildcard label in between the subdomains + SniPattern wildcard_label(engine, "sub.*.com"); + EXPECT_TRUE(wildcard_label.matches("sub.foo.com")); + EXPECT_TRUE(wildcard_label.matches("sub.bar.com")); + EXPECT_TRUE(wildcard_label.matches("sub.foobar.COM")); + EXPECT_FALSE(wildcard_label.matches("test.sub.example.com")); + EXPECT_FALSE(wildcard_label.matches("sub.com")); EXPECT_FALSE(wildcard_label.matches("fooexample.com")); EXPECT_FALSE(wildcard_label.matches("")); + + // Test wildcard label in between name + SniPattern mixed_wildcard_label(engine, "sub.ex*e.com"); + EXPECT_TRUE(mixed_wildcard_label.matches("sub.exe.com")); + EXPECT_TRUE(mixed_wildcard_label.matches("sub.example.com")); + EXPECT_FALSE(mixed_wildcard_label.matches("sub.foobar.COM")); + EXPECT_FALSE(mixed_wildcard_label.matches("sub.someexe.com")); + EXPECT_FALSE(mixed_wildcard_label.matches("")); + + // Multiple wildcard labels + SniPattern multi_wildcard_labels(engine, "sub.*.*.example.com"); + EXPECT_TRUE(multi_wildcard_labels.matches("sub.foo.bar.example.com")); + EXPECT_FALSE(multi_wildcard_labels.matches("sub.foo.example.com")); + EXPECT_FALSE(multi_wildcard_labels.matches("sub.example.com")); + EXPECT_FALSE(multi_wildcard_labels.matches("")); + + // Multiple wildcard labels with multilevel subdomain prefix wildcard. + SniPattern all_wildcard_labels(engine, "**.sub.*.ex*e.com"); + EXPECT_TRUE(all_wildcard_labels.matches("foo.sub.bar.example.com")); + EXPECT_TRUE(all_wildcard_labels.matches("test.foo.sub.bar.example.com")); + EXPECT_TRUE(all_wildcard_labels.matches("test.foo.sub.bar.exe.com")); + EXPECT_FALSE(all_wildcard_labels.matches("test.sub.foobar.com")); + EXPECT_FALSE(all_wildcard_labels.matches("test.sub.example.com")); + EXPECT_FALSE(all_wildcard_labels.matches("sub.test.example.com")); + EXPECT_FALSE(all_wildcard_labels.matches("")); } TEST_F(CiliumNetworkPolicyTest, OrderedRules) {