Skip to content
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

Add request signature validation #3

Merged
merged 11 commits into from
Jul 31, 2024
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
Loading