Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href=***>`.
Expand Down
28 changes: 28 additions & 0 deletions plugins/samples/hmac_authcookie/BUILD
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",
)
178 changes: 178 additions & 0 deletions plugins/samples/hmac_authcookie/plugin.cc
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();
Copy link
Collaborator

Choose a reason for hiding this comment

The 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());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: can dereference using *token instead of token.value(), since has_value() has been checked earlier. *token is shorter and doesn't have behavior that depends on build mode (i.e. whether or not to throw an exception)

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]
143 changes: 143 additions & 0 deletions plugins/samples/hmac_authcookie/tests.textpb
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.$" }
}
}
}