-
Notifications
You must be signed in to change notification settings - Fork 31
feat: create a C++ sample plugin for HMAC cookie authorization. #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
708a76c
77cfbd8
24fb088
cb6633b
d5019ae
5216cf1
2f18e98
0da5257
3977c6f
e986923
87b460d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <openssl/hmac.h> | ||
|
|
||
| #include <string> | ||
|
|
||
| #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<re2::RE2> 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<MyRootContext*>(root)) {} | ||
|
|
||
| FilterHeadersStatus onRequestHeaders(uint32_t headers, | ||
| bool end_of_stream) override { | ||
| const std::optional<std::string> ip = getClientIp(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice structuring of this method! The factoring of checks/parsing into helper methods make it very clear. |
||
| 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<std::string> 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<std::pair<std::string, std::string>> payload_and_hash = | ||
| parseAuthorizationCookie(token.value()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. optional: can dereference using |
||
| 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<std::string, std::string> | ||
| 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<std::string> 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<std::string> getAuthorizationCookie() { | ||
| const std::string cookies = getRequestHeader("Cookie")->toString(); | ||
| std::map<std::string, std::string> m; | ||
| for (absl::string_view sp : absl::StrSplit(cookies, "; ")) { | ||
| const std::pair<std::string, std::string> 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<std::pair<std::string, std::string>> parseAuthorizationCookie( | ||
| std::string_view cookie) { | ||
| std::pair<std::string_view, std::string_view> 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<const unsigned char*>(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] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: "<existing-values>,<not-an-ip>,<not-an-ip-also>" } | ||
| 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: "<existing-values>,127.0.0.1,<load-balancer-ip>" } | ||
| 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: "<existing-values>,127.0.0.1,<load-balancer-ip>" } | ||
| 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: "<existing-values>,127.0.0.2,<load-balancer-ip>" } | ||
| 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: "<existing-values>,127.0.0.1,<load-balancer-ip>" } | ||
| 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: "<existing-values>,127.0.0.1,<load-balancer-ip>" } | ||
| 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: "<existing-values>,127.0.0.1,<load-balancer-ip>" } | ||
| 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.$" } | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.