From 34ffb1df2953fb81ad507250146df48a67151a50 Mon Sep 17 00:00:00 2001 From: chapati Date: Wed, 31 Jul 2024 17:53:18 +0200 Subject: [PATCH] feat: add request signature validation (#3) --- .env.example | 3 ++- README.md | 7 ++++++ get-logs.sh | 9 ++++--- infra/cloud_function.tf | 10 ++++---- infra/local-dotenv-file.tf | 2 ++ infra/secret-manager.tf | 32 +++++++++++++++++++++++-- infra/variables.tf | 19 +++++++++++++++ src/config.ts | 6 +++++ src/get-discord-webhook-url.ts | 24 ------------------- src/get-secret.ts | 31 ++++++++++++++++++++++++ src/get-telegram-bot-token.ts | 24 ------------------- src/index.ts | 13 ++++++++++ src/send-discord-notification.ts | 5 ++-- src/send-telegram-notification.ts | 4 ++-- src/validate-request-origin.ts | 40 +++++++++++++++++++++++++++++++ test-deployed-function.sh | 2 ++ 16 files changed, 167 insertions(+), 64 deletions(-) delete mode 100644 src/get-discord-webhook-url.ts create mode 100644 src/get-secret.ts delete mode 100644 src/get-telegram-bot-token.ts create mode 100644 src/validate-request-origin.ts diff --git a/.env.example b/.env.example index 8288728..f4f6dea 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +TELEGRAM_CHAT_ID= diff --git a/README.md b/README.md index 5b73adb..21536f3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/get-logs.sh b/get-logs.sh index 435b2c1..7958ad7 100755 --- a/get-logs.sh +++ b/get-logs.sh @@ -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' diff --git a/infra/cloud_function.tf b/infra/cloud_function.tf index 037e17a..12534ae 100644 --- a/infra/cloud_function.tf +++ b/infra/cloud_function.tf @@ -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 diff --git a/infra/local-dotenv-file.tf b/infra/local-dotenv-file.tf index edde379..a68213a 100644 --- a/infra/local-dotenv-file.tf +++ b/infra/local-dotenv-file.tf @@ -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 } diff --git a/infra/secret-manager.tf b/infra/secret-manager.tf index 37971c5..add1cf3 100644 --- a/infra/secret-manager.tf +++ b/infra/secret-manager.tf @@ -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 @@ -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 +} diff --git a/infra/variables.tf b/infra/variables.tf index 410067f..49ad196 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -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" diff --git a/src/config.ts b/src/config.ts index 855cd9f..d6938d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; } @@ -12,12 +14,16 @@ const schema: JSONSchemaType = { 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" }, }, diff --git a/src/get-discord-webhook-url.ts b/src/get-discord-webhook-url.ts deleted file mode 100644 index 20cb3dc..0000000 --- a/src/get-discord-webhook-url.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import config from "./config.js"; - -/** - * Get the Discord webhook URL from GCloud Secret Manager. When ran locally, it assumes that the user is - * authenticated in the gcloud cli and has the necessary permissions to access the secret. - */ -export default async function getDiscordWebhookUrl(): Promise { - const secretManager = new SecretManagerServiceClient(); - const secretFullResourceName = `projects/${config.GCP_PROJECT_ID}/secrets/${config.DISCORD_WEBHOOK_URL_SECRET_ID}/versions/latest`; - const [version] = await secretManager.accessSecretVersion({ - name: secretFullResourceName, - }); - - const webhookUrl = version.payload?.data?.toString(); - - if (!webhookUrl) { - throw new Error( - "Failed to retrieve discord webhook url from secret manager", - ); - } - - return webhookUrl; -} diff --git a/src/get-secret.ts b/src/get-secret.ts new file mode 100644 index 0000000..d75d726 --- /dev/null +++ b/src/get-secret.ts @@ -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 { + 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; + } +} diff --git a/src/get-telegram-bot-token.ts b/src/get-telegram-bot-token.ts deleted file mode 100644 index 9b617ab..0000000 --- a/src/get-telegram-bot-token.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; -import config from "./config.js"; - -/** - * Get the Telegram Bot Token from GCloud Secret Manager. When ran locally, it assumes that the user is - * authenticated in the gcloud cli and has the necessary permissions to access the secret. - */ -export default async function getTelegramBotToken(): Promise { - const secretManager = new SecretManagerServiceClient(); - const secretFullResourceName = `projects/${config.GCP_PROJECT_ID}/secrets/${config.TELEGRAM_BOT_TOKEN_SECRET_ID}/versions/latest`; - const [version] = await secretManager.accessSecretVersion({ - name: secretFullResourceName, - }); - - const botToken = version.payload?.data?.toString(); - - if (!botToken) { - throw new Error( - "Failed to retrieve telegram bot token from secret manager", - ); - } - - return botToken; -} diff --git a/src/index.ts b/src/index.ts index e08370e..4074d1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) { diff --git a/src/send-discord-notification.ts b/src/send-discord-notification.ts index 883a7bc..ee7a234 100644 --- a/src/send-discord-notification.ts +++ b/src/send-discord-notification.ts @@ -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( @@ -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({ diff --git a/src/send-telegram-notification.ts b/src/send-telegram-notification.ts index 348f45a..bd38043 100644 --- a/src/send-telegram-notification.ts +++ b/src/send-telegram-notification.ts @@ -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 { diff --git a/src/validate-request-origin.ts b/src/validate-request-origin.ts new file mode 100644 index 0000000..77df180 --- /dev/null +++ b/src/validate-request-origin.ts @@ -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 { + 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 { + const authToken = req.headers["x-auth-token"]; + const expectedAuthToken = await getSecret(config.X_AUTH_TOKEN_SECRET_ID); + + return authToken === expectedAuthToken; +} diff --git a/test-deployed-function.sh b/test-deployed-function.sh index a159d5f..5dd89e9 100755 --- a/test-deployed-function.sh +++ b/test-deployed-function.sh @@ -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