From 2640b6e557ece292e323f00d1a20cdec5bc7f9b4 Mon Sep 17 00:00:00 2001 From: Philip Paetz Date: Mon, 22 Jul 2024 18:02:42 +0200 Subject: [PATCH] feat: added request validation (outcommented for the moment) can't really test it until an actual proposal comes in, so we'll test this out in prod with the monitoring events from sorted oracles once that's ready --- .env.example | 9 +++--- infra/cloud_function.tf | 9 +++--- infra/secret-manager.tf | 17 ++++++++-- src/config.ts | 3 ++ src/get-quicknode-security-token.ts | 28 +++++++++++++++++ src/index.ts | 5 +++ src/validate-request-origin.ts | 48 +++++++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 src/get-quicknode-security-token.ts create mode 100644 src/validate-request-origin.ts diff --git a/.env.example b/.env.example index 3a86c41..c53404f 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,11 @@ # Get it via `gcloud projects list --filter="name:governance-watchdog*" --format="value(projectId)")` GCP_PROJECT_ID= -# Required for the function to be able to look up the Discord Webhook URL and Telegram Bot Token in GCP Secret Manager. -# Get it via `gcloud secrets list` -DISCORD_WEBHOOK_URL_SECRET_ID= -TELEGRAM_BOT_TOKEN_SECRET_ID= +# Required for the function to be able to look up secrets in GCP Secret Manager. +# Get them via `gcloud secrets list` +DISCORD_WEBHOOK_URL_SECRET_ID=discord-webhook-url +QUICKNODE_SECURITY_TOKEN_SECRET_ID=quicknode-security-token +TELEGRAM_BOT_TOKEN_SECRET_ID=telegram-bot-token # Get it via 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 diff --git a/infra/cloud_function.tf b/infra/cloud_function.tf index de6a4e9..a83a980 100644 --- a/infra/cloud_function.tf +++ b/infra/cloud_function.tf @@ -34,10 +34,11 @@ 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 # 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/secret-manager.tf b/infra/secret-manager.tf index ba0af8c..5e3db6b 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 = "telegram-bot-token" @@ -31,3 +29,18 @@ 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 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/src/config.ts b/src/config.ts index 855cd9f..7544401 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ 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; TELEGRAM_BOT_TOKEN_SECRET_ID: string; TELEGRAM_CHAT_ID: string; } @@ -12,12 +13,14 @@ const schema: JSONSchemaType = { required: [ "GCP_PROJECT_ID", "DISCORD_WEBHOOK_URL_SECRET_ID", + "QUICKNODE_SECURITY_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" }, TELEGRAM_BOT_TOKEN_SECRET_ID: { type: "string" }, TELEGRAM_CHAT_ID: { type: "string" }, }, diff --git a/src/get-quicknode-security-token.ts b/src/get-quicknode-security-token.ts new file mode 100644 index 0000000..6f104bd --- /dev/null +++ b/src/get-quicknode-security-token.ts @@ -0,0 +1,28 @@ +import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; +import config from "./config.js"; + +/** + * Get the Quicknode Security Token from GCloud Secret Manager + * + * NOTE: This will fail locally because the local function will lack the necessary permissions to access the secret. + * That's why read the webhook URL from our .env file when running locally. We could probably make it work by having + * the local function impersonate the service account used by the function in GCP, but that was a rabbit hole I didn't + * want to go down when a simple .env approach also works for local testing. + */ +export default async function getQuicknodeSecurityToken(): Promise { + const secretManager = new SecretManagerServiceClient(); + const secretFullResourceName = `projects/${config.GCP_PROJECT_ID}/secrets/${config.QUICKNODE_SECURITY_TOKEN_SECRET_ID}/versions/latest`; + const [version] = await secretManager.accessSecretVersion({ + name: secretFullResourceName, + }); + + const securityToken = version.payload?.data?.toString(); + + if (!securityToken) { + throw new Error( + "Failed to retrieve Quicknode security token from secret manager", + ); + } + + return securityToken; +} diff --git a/src/index.ts b/src/index.ts index c6348a8..8c59336 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,17 @@ import type { import parseTransactionReceipts from "./parse-transaction-receipts"; import sendDiscordNotification from "./send-discord-notification"; import sendTelegramNotification from "./send-telegram-notification"; +// import validateRequestOrigin from "./validate-request-origin"; export const watchdogNotifier: HttpFunction = async ( req: Request, res: Response, ) => { try { + // TODO: Activate this after we've verified it's working via SortedOracle events + // if (process.env.NODE_ENV !== "development") { + // await validateRequestOrigin(req); + // } const parsedEvents = parseTransactionReceipts(req.body); for (const parsedEvent of parsedEvents) { diff --git a/src/validate-request-origin.ts b/src/validate-request-origin.ts new file mode 100644 index 0000000..cc4500c --- /dev/null +++ b/src/validate-request-origin.ts @@ -0,0 +1,48 @@ +import type { Request } from "@google-cloud/functions-framework"; +import crypto from "crypto"; +import getQuicknodeSecurityToken from "./get-quicknode-security-token"; + +export default async function validateRequestOrigin(req: Request) { + const quicknodeSecurityToken = await getQuicknodeSecurityToken(); + 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") { + console.error("No valid quicknode nonce found in request headers:", nonce); + return; + } + + if (!contentHash || typeof contentHash !== "string") { + console.error( + "No valid quicknode content hash found in request headers:", + contentHash, + ); + return; + } + + if (!timestamp || typeof timestamp !== "string") { + console.error( + "No valid quicknode timestamp found in request headers:", + contentHash, + ); + return; + } + + const hmac = crypto.createHmac("sha256", quicknodeSecurityToken); + hmac.update(`${nonce}${contentHash}${timestamp}`); + + const expectedSignature = hmac.digest("base64"); + + if (givenSignature === expectedSignature) { + console.log( + "The signature given matches the expected signature and is valid.", + ); + return; + } else { + throw new Error( + "Signature validation failed for the request's Quicknode signature header", + ); + } +}