Skip to content

Commit

Permalink
feat: add request signature validation (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
chapati23 committed Aug 6, 2024
1 parent 8d9944e commit a0e08bd
Show file tree
Hide file tree
Showing 16 changed files with 167 additions and 64 deletions.
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ GCP_PROJECT_ID=
# You can check it manually via `gcloud secrets list`
DISCORD_WEBHOOK_URL_SECRET_ID=
TELEGRAM_BOT_TOKEN_SECRET_ID=
QUICKNODE_SECURITY_TOKEN_SECRET_ID=

# You can check it manually either via
# a) `terraform state show "google_cloudfunctions2_function.watchdog_notifications" | grep TELEGRAM_CHAT_ID | awk -F '= ' '{print $2}' | tr -d '"'`
# OR
# b) inviting @MissRose_bot to the telegram group and then using the `/id` command (please remove the bot after you're done)
TELEGRAM_CHAT_ID=
TELEGRAM_CHAT_ID=
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ A monorepo for our governance watchdog, a system that monitors Mento Governance
echo "telegram_bot_token = \"$(gcloud secrets versions access latest --secret telegram-bot-token)\"" >> terraform.tfvars
```

1. Add the secret auth token from Google Cloud Secret Manager to your local `terraform.tfvars`:

```sh
# You need the "Secret Manager Secret Accessor" IAM role for this command to succeed
echo "x_auth_token = \"$(gcloud secrets versions access latest --secret x-auth-token)\"" >> terraform.tfvars
```

1. [Get our QuickNode API key from the QuickNode dashboard](https://dashboard.quicknode.com/api-keys) and add it to your local `terraform.tfvars`:

```sh
Expand Down
9 changes: 4 additions & 5 deletions get-logs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ raw_logs=$(gcloud functions logs read "${function_name}" \
--sort-by TIME_UTC)

# Format logs
printf "\n\n"
echo "${raw_logs}" | jq -r '.[] | if .level == "E" then
"\u001b[31m[\(.level)]\u001b[0m \u001b[33m\(.time_utc)\u001b[0m: \(.log)"
else
"[\(.level)] \u001b[33m\(.time_utc)\u001b[0m: \(.log)"
echo "${raw_logs}" | jq -r '.[] | if .level == "E" then
"\u001b[31m[\(.level)]\u001b[0m \u001b[33m\(.time_utc)\u001b[0m: \(.log)"
else
"[\(.level)] \u001b[33m\(.time_utc)\u001b[0m: \(.log)"
end'
10 changes: 6 additions & 4 deletions infra/cloud_function.tf
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ resource "google_cloudfunctions2_function" "watchdog_notifications" {

environment_variables = {
# Necessary for the function to be able to find the secrets in Secret Manager
GCP_PROJECT_ID = module.bootstrap.seed_project_id
DISCORD_WEBHOOK_URL_SECRET_ID = google_secret_manager_secret.discord_webhook_url.secret_id
TELEGRAM_BOT_TOKEN_SECRET_ID = google_secret_manager_secret.telegram_bot_token.secret_id
TELEGRAM_CHAT_ID = var.telegram_chat_id
GCP_PROJECT_ID = module.bootstrap.seed_project_id
DISCORD_WEBHOOK_URL_SECRET_ID = google_secret_manager_secret.discord_webhook_url.secret_id
TELEGRAM_BOT_TOKEN_SECRET_ID = google_secret_manager_secret.telegram_bot_token.secret_id
TELEGRAM_CHAT_ID = var.telegram_chat_id
QUICKNODE_SECURITY_TOKEN_SECRET_ID = google_secret_manager_secret.quicknode_security_token.secret_id
X_AUTH_TOKEN_SECRET_ID = google_secret_manager_secret.x_auth_token.secret_id

# Logs execution ID for easier debugging => https://cloud.google.com/functions/docs/monitoring/logging#viewing_runtime_logs
LOG_EXECUTION_ID = true
Expand Down
2 changes: 2 additions & 0 deletions infra/local-dotenv-file.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ resource "local_file" "env_file" {
DISCORD_WEBHOOK_URL_SECRET_ID=${var.discord_webhook_url_secret_id}
TELEGRAM_BOT_TOKEN_SECRET_ID=${var.telegram_bot_token_secret_id}
TELEGRAM_CHAT_ID=${var.telegram_chat_id}
QUICKNODE_SECURITY_TOKEN_SECRET_ID=${var.quicknode_security_token_secret_id}
X_AUTH_TOKEN_SECRET_ID=${var.x_auth_token_secret_id}
EOT
}
32 changes: 30 additions & 2 deletions infra/secret-manager.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ resource "google_secret_manager_secret_version" "discord_webhook_url" {
}

# Creates a new secret for the Telegram Bot Token.
# Terraform will try to look up the webhook URL from terraform.tfvars,
# and if it can't find it locally it will prompt the user to enter it manually.
resource "google_secret_manager_secret" "telegram_bot_token" {
project = module.bootstrap.seed_project_id
secret_id = var.telegram_bot_token_secret_id
Expand All @@ -31,3 +29,33 @@ resource "google_secret_manager_secret_version" "telegram_bot_token" {
secret = google_secret_manager_secret.telegram_bot_token.id
secret_data = var.telegram_bot_token
}

# Creates a new secret for the x-auth-token header, which is used to authenticate requests of origin other than Quicknode.
resource "google_secret_manager_secret" "x_auth_token" {
project = module.bootstrap.seed_project_id
secret_id = var.x_auth_token_secret_id

replication {
auto {}
}
}

resource "google_secret_manager_secret_version" "x_auth_token" {
secret = google_secret_manager_secret.x_auth_token.id
secret_data = var.x_auth_token
}

# Creates a new secret for the Quicknode Security Token which is used to verify that requests to the Cloud Function are coming from Quicknode.
resource "google_secret_manager_secret" "quicknode_security_token" {
project = module.bootstrap.seed_project_id
secret_id = "quicknode-security-token"

replication {
auto {}
}
}

resource "google_secret_manager_secret_version" "quicknode_security_token" {
secret = google_secret_manager_secret.quicknode_security_token.id
secret_data = quicknode_destination.destination.token
}
19 changes: 19 additions & 0 deletions infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ variable "quicknode_api_key" {
sensitive = true
}

# You can look this up via:
# `gcloud secrets list`
variable "quicknode_security_token_secret_id" {
type = string
default = "quicknode-security-token"
}

# You can look this up via:
# `gcloud secrets list`
variable "x_auth_token_secret_id" {
type = string
default = "x-auth-token"
}

variable "x_auth_token" {
type = string
sensitive = true
}

variable "function_name" {
type = string
default = "watchdog-notifications"
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { JSONSchemaType, envSchema } from "env-schema";
export interface Env {
GCP_PROJECT_ID: string;
DISCORD_WEBHOOK_URL_SECRET_ID: string;
QUICKNODE_SECURITY_TOKEN_SECRET_ID: string;
X_AUTH_TOKEN_SECRET_ID: string;
TELEGRAM_BOT_TOKEN_SECRET_ID: string;
TELEGRAM_CHAT_ID: string;
}
Expand All @@ -12,12 +14,16 @@ const schema: JSONSchemaType<Env> = {
required: [
"GCP_PROJECT_ID",
"DISCORD_WEBHOOK_URL_SECRET_ID",
"QUICKNODE_SECURITY_TOKEN_SECRET_ID",
"X_AUTH_TOKEN_SECRET_ID",
"TELEGRAM_BOT_TOKEN_SECRET_ID",
"TELEGRAM_CHAT_ID",
],
properties: {
GCP_PROJECT_ID: { type: "string" },
DISCORD_WEBHOOK_URL_SECRET_ID: { type: "string" },
QUICKNODE_SECURITY_TOKEN_SECRET_ID: { type: "string" },
X_AUTH_TOKEN_SECRET_ID: { type: "string" },
TELEGRAM_BOT_TOKEN_SECRET_ID: { type: "string" },
TELEGRAM_CHAT_ID: { type: "string" },
},
Expand Down
24 changes: 0 additions & 24 deletions src/get-discord-webhook-url.ts

This file was deleted.

31 changes: 31 additions & 0 deletions src/get-secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import config from "./config.js";

/**
* Load a secret from Secret Manager
*/
export default async function getSecret(secretId: string): Promise<string> {
try {
const secretManager = new SecretManagerServiceClient();
const secretFullResourceName = `projects/${config.GCP_PROJECT_ID}/secrets/${secretId}/versions/latest`;
const [version] = await secretManager.accessSecretVersion({
name: secretFullResourceName,
});

const secret = version.payload?.data?.toString();

if (!secret) {
throw new Error(
`Secret '${secretId}' is empty or undefined. Please check the secret in Secret Manager.`,
);
}

return secret;
} catch (error) {
console.error(
`Failed to retrieve secret '${secretId}' from secret manager:`,
error,
);
throw error;
}
}
24 changes: 0 additions & 24 deletions src/get-telegram-bot-token.ts

This file was deleted.

13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,25 @@ import { EventType } from "./types.js";
import parseTransactionReceipts from "./parse-transaction-receipts";
import sendDiscordNotification from "./send-discord-notification";
import sendTelegramNotification from "./send-telegram-notification";
import { isFromQuicknode, hasAuthToken } from "./validate-request-origin";

export const watchdogNotifier: HttpFunction = async (
req: Request,
res: Response,
) => {
const isProduction = process.env.NODE_ENV !== "development";
try {
if (isProduction) {
const isAuthorized =
(await isFromQuicknode(req)) || (await hasAuthToken(req));

if (!isAuthorized) {
console.error("Origin validation failed for request.");
res.status(401).send("Unauthorized");
return;
}
}

const parsedEvents = parseTransactionReceipts(req.body);

for (const parsedEvent of parsedEvents) {
Expand Down
5 changes: 3 additions & 2 deletions src/send-discord-notification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EmbedBuilder, WebhookClient } from "discord.js";
import getDiscordWebhookUrl from "./get-discord-webhook-url.js";
import config from "./config";
import getSecret from "./get-secret.js";
import type { ProposalCreatedEvent } from "./types";

export default async function sendDiscordNotification(
Expand Down Expand Up @@ -30,7 +31,7 @@ export default async function sendDiscordNotification(
.setColor(0xa6e5f6);

const discordWebhookClient = new WebhookClient({
url: await getDiscordWebhookUrl(),
url: await getSecret(config.DISCORD_WEBHOOK_URL_SECRET_ID),
});

await discordWebhookClient.send({
Expand Down
4 changes: 2 additions & 2 deletions src/send-telegram-notification.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import config from "./config.js";
import getTelegramBotToken from "./get-telegram-bot-token";
import getSecret from "./get-secret.js";
import { ProposalCreatedEvent } from "./types";

export default async function sendTelegramNotification(
event: ProposalCreatedEvent,
txHash: string,
) {
const botToken = await getTelegramBotToken();
const botToken = await getSecret(config.TELEGRAM_BOT_TOKEN_SECRET_ID);
const botUrl = `https://api.telegram.org/bot${botToken}/sendMessage`;

const { title, description } = JSON.parse(event.args.description) as {
Expand Down
40 changes: 40 additions & 0 deletions src/validate-request-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Request } from "@google-cloud/functions-framework";
import crypto from "crypto";
import config from "./config";
import getSecret from "./get-secret";

export async function isFromQuicknode(req: Request): Promise<boolean> {
const quicknodeSecurityToken = await getSecret(
config.QUICKNODE_SECURITY_TOKEN_SECRET_ID,
);
const givenSignature = req.headers["x-qn-signature"];
const nonce = req.headers["x-qn-nonce"];
const contentHash = req.headers["x-qn-content-hash"];
const timestamp = req.headers["x-qn-timestamp"];

if (!nonce || typeof nonce !== "string") {
return false;
}

if (!contentHash || typeof contentHash !== "string") {
return false;
}

if (!timestamp || typeof timestamp !== "string") {
return false;
}

const hmac = crypto.createHmac("sha256", quicknodeSecurityToken);
hmac.update(`${nonce}${contentHash}${timestamp}`);

const expectedSignature = hmac.digest("base64");

return givenSignature === expectedSignature;
}

export async function hasAuthToken(req: Request): Promise<boolean> {
const authToken = req.headers["x-auth-token"];
const expectedAuthToken = await getSecret(config.X_AUTH_TOKEN_SECRET_ID);

return authToken === expectedAuthToken;
}
2 changes: 2 additions & 0 deletions test-deployed-function.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ set -u # Treat unset variables as an error when substituting
# This only works if the function has been deployed and your `terraform` can access the state backend
raw_function_url=$(terraform -chdir=infra output -json function_uri)
function_url=$(echo "${raw_function_url}" | jq -r)
auth_token=$(gcloud secrets versions access latest --secret x-auth-token)

curl "${function_url}" \
-H "Content-Type: application/json" \
-H "X-AUTH-TOKEN: ${auth_token}" \
-d @src/proposal-created.fixture.json

0 comments on commit a0e08bd

Please sign in to comment.