From 163f038f84622941326ddfb048abff561dd06244 Mon Sep 17 00:00:00 2001 From: Gabo Esquivel Date: Thu, 22 Aug 2024 23:16:54 -0600 Subject: [PATCH] chore(indexer): security improvements --- apps/indexer/src/routes/alchemy.ts | 54 +++++++++++++++ apps/indexer/src/routes/alchemy/helpers.ts | 77 ---------------------- apps/indexer/src/routes/alchemy/index.ts | 31 --------- apps/indexer/src/routes/index.ts | 45 ++++++++----- apps/trigger/src/config.ts | 19 +++++- apps/trigger/src/trigger/activity.ts | 3 +- 6 files changed, 100 insertions(+), 129 deletions(-) create mode 100644 apps/indexer/src/routes/alchemy.ts delete mode 100644 apps/indexer/src/routes/alchemy/helpers.ts delete mode 100644 apps/indexer/src/routes/alchemy/index.ts diff --git a/apps/indexer/src/routes/alchemy.ts b/apps/indexer/src/routes/alchemy.ts new file mode 100644 index 000000000..35557e3ac --- /dev/null +++ b/apps/indexer/src/routes/alchemy.ts @@ -0,0 +1,54 @@ +import crypto from 'crypto' +import { addressActivityTask } from '@repo/trigger' +import type { Request, Response } from 'express' +import { appConfig } from '~/config' +import { logger } from '~/lib/logger' + +/** + * Handles incoming Alchemy webhook requests. + * Validates the signature, logs the request, and triggers the address activity task. + * @param req - The incoming request object + * @param res - The response object + */ +export function alchemyWebhook(req: Request, res: Response) { + const evt = req.body as AlchemyWebhookEvent + logger.info(`Alchemy webhook received: ${evt.id}`) + if (!validateAlchemySignature(req)) return res.status(401).send('Unauthorized') + logger.info('Validated Alchemy webhook 😀') + // TODO: validate user is whitelisted + + addressActivityTask.trigger(req.body) + + res.status(200).send('Webhook processed') +} + +/** + * Validates the Alchemy webhook signature. + * @param req - The incoming request object + * @returns boolean - True if the signature is valid, false otherwise + */ +function validateAlchemySignature(req: Request): boolean { + const alchemySignature = req.headers['x-alchemy-signature'] as string + const payload = JSON.stringify(req.body) + const hmac = crypto.createHmac( + 'sha256', + appConfig.evm.alchemy.activitySigningKey, + ) + hmac.update(payload) + return alchemySignature === hmac.digest('hex') +} + + + +export interface AlchemyWebhookEvent { + webhookId: string; + id: string; + createdAt: Date; + type: AlchemyWebhookType; + event: Record; +} + +export type AlchemyWebhookType = + | "MINED_TRANSACTION" + | "DROPPED_TRANSACTION" + | "ADDRESS_ACTIVITY"; diff --git a/apps/indexer/src/routes/alchemy/helpers.ts b/apps/indexer/src/routes/alchemy/helpers.ts deleted file mode 100644 index 92ba01dfa..000000000 --- a/apps/indexer/src/routes/alchemy/helpers.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextFunction } from "express"; -import { Request, Response } from "express-serve-static-core"; - -import * as crypto from "crypto"; -import { IncomingMessage, ServerResponse } from "http"; -import { logger } from "~/lib/logger"; - -export interface AlchemyRequest extends Request { - alchemy: { - rawBody: string; - signature: string; - }; -} - -export function isValidSignatureForAlchemyRequest( - request: AlchemyRequest, - signingKey: string -): boolean { - return isValidSignatureForStringBody( - request.alchemy.rawBody, - request.alchemy.signature, - signingKey - ); -} - -export function isValidSignatureForStringBody( - body: string, - signature: string, - signingKey: string -): boolean { - const hmac = crypto.createHmac("sha256", signingKey); // Create a HMAC SHA256 hash using the signing key - hmac.update(body, "utf8"); // Update the token hash with the request body using utf8 - const digest = hmac.digest("hex"); - return signature === digest; -} - -export function addAlchemyContextToRequest( - req: IncomingMessage, - _res: ServerResponse, - buf: Buffer, - encoding: BufferEncoding -): void { - const signature = req.headers["x-alchemy-signature"]; - logger.info(`Alchemy signature: ${signature}`) - // Signature must be validated against the raw string - var body = buf.toString(encoding || "utf8"); - (req as AlchemyRequest).alchemy = { - rawBody: body, - signature: signature as string, - }; -} - -export function validateAlchemySignature(signingKey: string) { - logger.info(`validateAlchemySignature => signing key: ${signingKey}`) - return (req: Request, res: Response, next: NextFunction) => { - if (!isValidSignatureForAlchemyRequest(req as AlchemyRequest, signingKey)) { - const errMessage = "Signature validation failed, unauthorized!"; - res.status(403).send(errMessage); - throw new Error(errMessage); - } else { - next(); - } - }; -} - -export interface AlchemyWebhookEvent { - webhookId: string; - id: string; - createdAt: Date; - type: AlchemyWebhookType; - event: Record; -} - -export type AlchemyWebhookType = - | "MINED_TRANSACTION" - | "DROPPED_TRANSACTION" - | "ADDRESS_ACTIVITY"; diff --git a/apps/indexer/src/routes/alchemy/index.ts b/apps/indexer/src/routes/alchemy/index.ts deleted file mode 100644 index 3d988b1c1..000000000 --- a/apps/indexer/src/routes/alchemy/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import crypto from 'crypto' -import { addressActivityTask } from '@repo/trigger' -import type { Request, Response } from 'express' -import { appConfig } from '~/config' -import { logger } from '~/lib/logger' - -export function alchemyWebhook(req: Request, res: Response) { - logger.info(`Alchemy webhook received: ${JSON.stringify(req.body)}`) - - // TODO: fix alchemy signature validation - // https://git.new/alchemy-hooks-ts - if (!validateAlchemySignature(req)) - return res.status(401).send('Unauthorized') - - // TODO: validate user is whitelisted - - addressActivityTask.trigger(req.body) - - res.status(200).send('Webhook processed') -} - -function validateAlchemySignature(req: Request): boolean { - const alchemySignature = req.headers['x-alchemy-signature'] as string - const payload = JSON.stringify(req.body) - const hmac = crypto.createHmac( - 'sha256', - appConfig.evm.alchemy.activitySigningKey, - ) - hmac.update(payload) - return alchemySignature === hmac.digest('hex') -} diff --git a/apps/indexer/src/routes/index.ts b/apps/indexer/src/routes/index.ts index 77f50c6b8..320435b56 100644 --- a/apps/indexer/src/routes/index.ts +++ b/apps/indexer/src/routes/index.ts @@ -10,8 +10,6 @@ import { logger } from '~/lib/logger' import { setupSentryErrorHandler } from '~/lib/sentry' import { alchemyWebhook } from './alchemy' import { healthcheck } from './healthcheck' -import { addAlchemyContextToRequest, validateAlchemySignature } from './alchemy/helpers' -import { appConfig } from '~/config' export function startExpress() { const app = express() @@ -20,30 +18,43 @@ export function startExpress() { // Trust proxy app.set('trust proxy', 1) - app.use(express.json()) - - // Sentry error handler - setupSentryErrorHandler(app) - // Security Middlewares app.use(helmet()) + app.use(express.json({ limit: '1mb' })) + // Rate limiting app.use( rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // limit each IP to 100 requests per windowMs + standardHeaders: true, + legacyHeaders: false, }), ) - //Logging middleware - app.use(pinoHttp({ - logger, - serializers: { - req: (req) => JSON.stringify(req), - res: (res) => JSON.stringify(res), - err: (err) => JSON.stringify(err) - } - })) + // Sentry error handler + setupSentryErrorHandler(app) + + // Logging middleware + // app.use(pinoHttp({ + // logger, + // serializers: { + // req: (req) => JSON.stringify({ + // method: req.method, + // url: req.url, + // headers: req.headers, + // }), + // res: (res) => JSON.stringify({ + // statusCode: res.statusCode, + // }), + // err: (err) => JSON.stringify({ + // type: err.constructor.name, + // message: err.message, + // stack: err.stack, + // }), + // }, + // })) + // Routes app.get('/', healthcheck) app.post('/alchemy', alchemyWebhook) @@ -81,4 +92,4 @@ export function startExpress() { process.exit(0) }) }) -} +} \ No newline at end of file diff --git a/apps/trigger/src/config.ts b/apps/trigger/src/config.ts index 575b2efed..0c87dfc72 100644 --- a/apps/trigger/src/config.ts +++ b/apps/trigger/src/config.ts @@ -1,9 +1,22 @@ +import { z } from 'zod' import type { Address } from 'viem' import { privateKeyToAccount } from 'viem/accounts' +import { isAddress } from 'viem' + +const envSchema = z.object({ + ISSUER_KEY: z.string().min(1).length(64).regex(/^[a-f0-9]+$/i, 'Invalid issuer key format'), + ISSUER_ADDRESS: z.string().refine((value): value is Address => isAddress(value), 'Invalid issuer address'), +}) + +const parsedEnv = envSchema.safeParse(process.env) +if (!parsedEnv.success) { + console.error(`Environment validation failed: ${JSON.stringify(parsedEnv.error.format())}`) + process.exit(1) +} export const appenv = { eosEvmApi: 'https://api.testnet.evm.eosnetwork.com', - issuerKey: process.env.ISSUER_KEY || '', - issuerAddress: (process.env.ISSUER_ADDRESS || '') as Address, - issuerAccount: privateKeyToAccount(`0x${process.env.ISSUER_KEY}`), + issuerKey: parsedEnv.data.ISSUER_KEY, + issuerAddress: parsedEnv.data.ISSUER_ADDRESS, + issuerAccount: privateKeyToAccount(`0x${parsedEnv.data.ISSUER_KEY}`), } diff --git a/apps/trigger/src/trigger/activity.ts b/apps/trigger/src/trigger/activity.ts index e04915f1f..66baccf97 100644 --- a/apps/trigger/src/trigger/activity.ts +++ b/apps/trigger/src/trigger/activity.ts @@ -1,4 +1,5 @@ import { logger, task } from '@trigger.dev/sdk/v3' +import { getErrorMessage } from 'app-lib' // AlchemyWebhookEvent export const addressActivityTask = task({ @@ -9,7 +10,7 @@ export const addressActivityTask = task({ } catch (error) { logger.error('Error processing address activity', { - error: (error as Error).message, + error: getErrorMessage(error), }) throw error }