diff --git a/plugins/README.md b/plugins/README.md index 696721b2..24e89622 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -139,6 +139,8 @@ for your own plugin. Extend them to fit your particular use case. initial numbers will be masked. * [Validate client token on query string using HMAC](samples/hmac_authtoken): Check the client request URL for a valid token signed using HMAC. +* [Validate client token using HMAC with cookie](samples/hmac_authcookie): Check + the client request for a valid token signed using HMAC provided via a cookie. * [Rewrite domains in html response body](samples/html_domain_rewrite/): Parse html in response body chunks and replace insances of "foo.com" with "bar.com" in ``. diff --git a/plugins/samples/hmac_authcookie/BUILD b/plugins/samples/hmac_authcookie/BUILD new file mode 100644 index 00000000..cdbe0534 --- /dev/null +++ b/plugins/samples/hmac_authcookie/BUILD @@ -0,0 +1,28 @@ +load("//:plugins.bzl", "proxy_wasm_plugin_cpp", "proxy_wasm_plugin_rust", "proxy_wasm_tests") + +licenses(["notice"]) # Apache 2 + +proxy_wasm_plugin_cpp( + name = "plugin_cpp.wasm", + srcs = ["plugin.cc"], + deps = [ + "@com_google_absl//absl/strings", + "@com_google_absl//absl/time", + "@com_google_re2//:re2", + "@boringssl//:ssl", + ], + linkopts = [ + # To avoid the error: + # library_pthread.js:26: #error "STANDALONE_WASM does not support shared memories yet". + # Disabling the pthreads avoids the inclusion of the library_pthread.js. + "-sUSE_PTHREADS=0", + ], +) + +proxy_wasm_tests( + name = "tests", + plugins = [ + ":plugin_cpp.wasm", + ], + tests = ":tests.textpb", +) \ No newline at end of file diff --git a/plugins/samples/hmac_authcookie/plugin.cc b/plugins/samples/hmac_authcookie/plugin.cc new file mode 100644 index 00000000..e123e934 --- /dev/null +++ b/plugins/samples/hmac_authcookie/plugin.cc @@ -0,0 +1,178 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// [START serviceextensions_plugin_hmac_authcookie] +#include + +#include + +#include "absl/strings/escaping.h" +#include "absl/strings/str_split.h" +#include "absl/time/clock.h" +#include "absl/time/time.h" +#include "proxy_wasm_intrinsics.h" +#include "re2/re2.h" + +// Replace with your desired secret key. +const std::string kSecretKey = "your_secret_key"; + +class MyRootContext : public RootContext { + public: + explicit MyRootContext(uint32_t id, std::string_view root_id) + : RootContext(id, root_id) {} + + bool onConfigure(size_t) override { + // Regex for matching IPs on format like 127.0.0.1. + ip_match.emplace("^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$"); + return ip_match->ok(); + } + + std::optional ip_match; +}; + +// Validates the HMAC HTTP cookie performing the following steps: +// +// 1. Obtains the client IP address and rejects the request if it is not +// present. +// 2. Obtains the HTTP cookie and rejects the request if it is not present. +// 3. Verifies that the HMAC hash of the cookie matches its payload, rejecting +// the request if there is no match. +// 4. Ensures that the client IP address matches the IP in the cookie payload, +// and that the current time is earlier than the expiration time specified in +// the cookie payload. +class MyHttpContext : public Context { + public: + explicit MyHttpContext(uint32_t id, RootContext* root) + : Context(id, root), root_(static_cast(root)) {} + + FilterHeadersStatus onRequestHeaders(uint32_t headers, + bool end_of_stream) override { + const std::optional ip = getClientIp(); + if (!ip.has_value()) { + LOG_INFO("Access forbidden - missing client IP."); + sendLocalResponse(403, "", "Access forbidden - missing client IP.\n", {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + const std::optional token = getAuthorizationCookie(); + if (!token.has_value()) { + LOG_INFO("Access forbidden - missing HMAC cookie."); + sendLocalResponse(403, "", "Access forbidden - missing HMAC cookie.\n", + {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + const std::optional> payload_and_hash = + parseAuthorizationCookie(token.value()); + if (!payload_and_hash.has_value()) { + LOG_INFO("Access forbidden - invalid HMAC cookie."); + sendLocalResponse(403, "", "Access forbidden - invalid HMAC cookie.\n", + {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + if (computeHmacSignature(payload_and_hash->first) != + payload_and_hash->second) { + LOG_INFO("Access forbidden - invalid HMAC hash."); + sendLocalResponse(403, "", "Access forbidden - invalid HMAC hash.\n", {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + const std::pair + client_ip_and_expiration_timestamp = + absl::StrSplit(payload_and_hash->first, ','); + if (ip != client_ip_and_expiration_timestamp.first) { + LOG_INFO("Access forbidden - invalid client IP."); + sendLocalResponse(403, "", "Access forbidden - invalid client IP.\n", {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + if (!isHashTimestampValid(client_ip_and_expiration_timestamp.second)) { + LOG_INFO("Access forbidden - hash expired."); + sendLocalResponse(403, "", "Access forbidden - hash expired.\n", {}); + return FilterHeadersStatus::ContinueAndEndStream; + } + + return FilterHeadersStatus::Continue; + } + + private: + const MyRootContext* root_; + + // Check if the current time is earlier than cookie payload expiration. + bool isHashTimestampValid(std::string_view expiration_timestamp) { + const int64_t unix_now = absl::ToUnixNanos(absl::Now()); + int64_t parsed_expiration_timestamp; + return absl::SimpleAtoi(expiration_timestamp, + &parsed_expiration_timestamp) && + unix_now <= parsed_expiration_timestamp; + } + + // Try to get the client IP from the X-Forwarded-For header. + std::optional getClientIp() { + const std::string ips = getRequestHeader("X-Forwarded-For")->toString(); + for (absl::string_view ip : absl::StrSplit(ips, ',')) { + if (re2::RE2::FullMatch(ip, *root_->ip_match)) { + return std::string(ip); + } + } + + return std::nullopt; + } + + // Try to get the HMAC auth token from the Cookie header. + std::optional getAuthorizationCookie() { + const std::string cookies = getRequestHeader("Cookie")->toString(); + std::map m; + for (absl::string_view sp : absl::StrSplit(cookies, "; ")) { + const std::pair cookie = + absl::StrSplit(sp, absl::MaxSplits('=', 1)); + if (cookie.first == "Authorization") { + return cookie.second; + } + } + + return std::nullopt; + } + + // Try to parse the authorization cookie in the format + // "base64(payload)" + "." + "base64(HMAC(payload))". + std::optional> parseAuthorizationCookie( + std::string_view cookie) { + std::pair payload_and_hash = + absl::StrSplit(cookie, "."); + std::string payload; + std::string hash; + if (!payload_and_hash.second.empty() && + absl::Base64Unescape(payload_and_hash.first, &payload) && + absl::Base64Unescape(payload_and_hash.second, &hash)) { + return std::pair{payload, hash}; + } + return std::nullopt; + } + + // Function to compute the HMAC signature. + std::string computeHmacSignature(std::string_view data) { + unsigned char result[EVP_MAX_MD_SIZE]; + unsigned int len; + HMAC(EVP_sha256(), kSecretKey.c_str(), kSecretKey.length(), + reinterpret_cast(std::string{data}.c_str()), + data.length(), result, &len); + return absl::BytesToHexString(std::string(result, result + len)); + } +}; + +static RegisterContextFactory register_StaticContext( + CONTEXT_FACTORY(MyHttpContext), ROOT_FACTORY(MyRootContext)); +// [END serviceextensions_plugin_hmac_authcookie] \ No newline at end of file diff --git a/plugins/samples/hmac_authcookie/tests.textpb b/plugins/samples/hmac_authcookie/tests.textpb new file mode 100644 index 00000000..e5938d96 --- /dev/null +++ b/plugins/samples/hmac_authcookie/tests.textpb @@ -0,0 +1,143 @@ +env { + time_secs: 1735614000 # Tue Dec 31 2024 03:00:00 GMT+0000 +} +# No client X-Forwarded-For header set, forbidden request. +test { + name: "NoXForwardedForHeader" + request_headers { + input { + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=48277f04685e364e0e3f3c4bfa78cb91293d304bbf196829334cb1c4a741d6b0" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - missing client IP.\n" } + log { regex: ".*Access forbidden - missing client IP.$" } + } + } +} +# No client ip set, forbidden request. +test { + name: "NoClientIP" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=48277f04685e364e0e3f3c4bfa78cb91293d304bbf196829334cb1c4a741d6b0" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - missing client IP.\n" } + log { regex: ".*Access forbidden - missing client IP.$" } + } + } +} +# With a valid hash, request allowed. +# expiration_timestamp_nanos: 1735700400000000000 - Wed Jan 01 2025 03:00:00 GMT+0000 +# client_ip: 127.0.0.1 +# payload: client_ip,expiration_timestamp_nanos +# Authorization="Base64(payload)" + "." + "Base64(HMAC(payload))" +test { + name: "WithValidHMACHash" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.1," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=MTI3LjAuMC4xLDE3MzU3MDA0MDAwMDAwMDAwMDA.MThmNzliYzBhMzA3YzhiMmI4OTFiMTQ0NzNhMmFhNjljYWVkNGVmMzYwY2NiNTRjZTU3YWY0MTczZGMwMGZkNA" } + } + result { + has_header { key: ":path" value: "/somepage/otherpage" } + } + } +} +# With an expired hash, forbidden request. +# expiration_timestamp_nanos: 1735527600000000000 - Mon Dec 30 2024 03:00:00 GMT+0000 +# client_ip: 127.0.0.1 +# payload: client_ip,expiration_timestamp_nanos +# Authorization="Base64(payload)" + "." + "Base64(HMAC(payload))" +test { + name: "WithExpiredHMACHash" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.1," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=MTI3LjAuMC4xLDE3MzU1Mjc2MDAwMDAwMDAwMDA.NWFlODcwYjBlMGNmM2JmODM1NjQwNjgyZjZhNWUyZTI4MDc5MGQ3ODgwMjBmOWI5NGQwYThhYzIxODc3YWM1Yg" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - hash expired.\n" } + log { regex: ".*Access forbidden - hash expired.$" } + } + } +} +# With an invalid client IP, forbidden request. +# expiration_timestamp_nanos: 1735700400000000000 - Wed Jan 01 2025 03:00:00 GMT+0000 +# client_ip: 127.0.0.1 +# payload: client_ip,expiration_timestamp_nanos +# Authorization="Base64(payload)" + "." + "Base64(HMAC(payload))" +test { + name: "WithInvalidClientIp" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.2," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=MTI3LjAuMC4xLDE3MzU3MDA0MDAwMDAwMDAwMDA.MThmNzliYzBhMzA3YzhiMmI4OTFiMTQ0NzNhMmFhNjljYWVkNGVmMzYwY2NiNTRjZTU3YWY0MTczZGMwMGZkNA" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - invalid client IP.\n" } + log { regex: ".*Access forbidden - invalid client IP.$" } + } + } +} +# With an invalid hash, forbidden request. +# expiration_timestamp_nanos: 1735700400000000000 - Wed Jan 01 2025 03:00:00 GMT+0000 +# client_ip: 127.0.0.1 +# payload: client_ip,expiration_timestamp_nanos +# Authorization="Base64(payload)" + "." + "Base64(HMAC(payload))" +test { + name: "WithInvalidHMACHash" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.1," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=MTI3LjAuMC4xLDE3MzU3MDA0MDAwMDAwMDAwMDA.MTI3LjAuMC4xLDE3MzU3MDA0MDAwMDAwMDAwMDA" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - invalid HMAC hash.\n" } + log { regex: ".*Access forbidden - invalid HMAC hash.$" } + } + } +} +# No cookie set, forbidden request. +test { + name: "NoCookie" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.1," } + header { key: ":path" value: "/admin" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - missing HMAC cookie.\n" } + log { regex: ".*Access forbidden - missing HMAC cookie.$" } + } + } +} +# invalid cookie, forbidden request. +test { + name: "InvalidCookie" + request_headers { + input { + header { key: "X-Forwarded-For" value: ",127.0.0.1," } + header { key: ":path" value: "/somepage/otherpage" } + header { key: "Cookie" value: "SomeCookie=SomeValue; Authorization=48277f04685e364e0e3f3c4bfa78cb91293d304bbf196829334cb1c4a741d6b0" } + } + result { + immediate { http_status: 403 details: "" } + body { exact: "Access forbidden - invalid HMAC cookie.\n" } + log { regex: ".*Access forbidden - invalid HMAC cookie.$" } + } + } +} \ No newline at end of file