From 34983c44e2d3c9b342867776b8f3d278fb6dc61d Mon Sep 17 00:00:00 2001 From: Garfield Date: Sun, 17 May 2026 20:14:08 -0400 Subject: [PATCH] security: fix reset token leak, add rate limiting, body limits, webhook HMAC, JWT_SECRET - CRITICAL: forgot-password no longer returns token in response; sends email via info@squaremcp.com instead - Rate limit: login 10/15min, forgot-password 5/hr, chat 30/hr (Redis, per IP) - express.json() capped at 100kb - WhatsApp webhook HMAC verification (activates when WHATSAPP_APP_SECRET is set) - JWT_SECRET now explicitly set in K8s (was falling back to CREDENTIAL_ENCRYPTION_KEY) Co-Authored-By: Claude Sonnet 4.6 --- hermes-k8s.yaml | 4 +++- src/index.ts | 64 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/hermes-k8s.yaml b/hermes-k8s.yaml index bfa8488..cd4d99b 100644 --- a/hermes-k8s.yaml +++ b/hermes-k8s.yaml @@ -22,7 +22,7 @@ spec: fsGroup: 1000 containers: - name: hermes-mcp - image: localhost:32000/hermes-mcp@sha256:c65ffbbf87a8741c1c9d79e1b39be735535871a9968c680c2c8ff3fb108acfb0 + image: localhost:32000/hermes-mcp@sha256:b566707150fb4dd3f566b5c258d6f4d0ed8bf5c4405321268dfc647afa0ddda2 imagePullPolicy: Always securityContext: allowPrivilegeEscalation: false @@ -119,6 +119,8 @@ spec: value: "redis://127.0.0.1:6379" - name: CREDENTIAL_ENCRYPTION_KEY value: "4ef9c48e9f4e5dfa843d4bfcc3a8f69c5ad5738326c8b0e878076853ae4b8416" + - name: JWT_SECRET + value: "7a3f9d2e1c8b5a4f6e0d3c7b9a2e5f8d1c4b7a0e3f6d9c2b5a8e1f4d7c0b3a" - name: OAUTH_CLIENT_ID value: "fecb863c9aa334aba93c9017f4b9bee8" - name: OAUTH_CLIENT_SECRET diff --git a/src/index.ts b/src/index.ts index 17cfbaf..00ad12e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import redis from './redis.js'; import { handleChat, type ChatMessage } from './chat.js'; import { startEmailPoller } from './email-poller.js'; import { sendChatEscalationAlert } from './notifications/slack.js'; +import { sendEmail } from './smtp.js'; process.on('uncaughtException', (err) => { console.error('FATAL uncaughtException:', err); @@ -71,8 +72,8 @@ app.use(cors({ allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'], credentials: true, })); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '100kb' })); +app.use(express.urlencoded({ extended: true, limit: '100kb' })); // ── Static files (videos, assets) ────────────────────────────────────────── app.use('/public', express.static('/vaults/public')); @@ -305,6 +306,23 @@ function renderTikTokCallbackHtml(args: { `; } +// ── Rate limiting ─────────────────────────────────────────────────────────── +async function checkRateLimit(req: express.Request, res: express.Response, max: number, windowSec: number): Promise { + const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'; + const key = `ratelimit:${req.path}:${ip}`; + try { + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, windowSec); + if (count > max) { + res.status(429).json({ error: 'Too many requests. Please try again later.' }); + return false; + } + } catch { + // Redis unavailable — fail open rather than block legitimate users + } + return true; +} + // ── Auth middleware ───────────────────────────────────────────────────────── const API_KEY = process.env.MCP_API_KEY; @@ -1242,7 +1260,23 @@ app.get('/webhook/whatsapp', (req, res) => { }); // WhatsApp webhook delivery (POST) — raw body preserved for HMAC verification +const WHATSAPP_APP_SECRET = process.env.WHATSAPP_APP_SECRET ?? ''; + app.post('/webhook/whatsapp', express.raw({ type: '*/*' }), async (req, res) => { + // Verify Meta HMAC signature when app secret is configured + if (WHATSAPP_APP_SECRET) { + const sig = req.headers['x-hub-signature-256'] as string | undefined; + if (!sig) { + res.status(403).send('Missing signature'); + return; + } + const expected = 'sha256=' + crypto.createHmac('sha256', WHATSAPP_APP_SECRET).update(req.body as Buffer).digest('hex'); + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { + res.status(403).send('Invalid signature'); + return; + } + } + // Always acknowledge immediately to prevent Meta retries (20s window) res.status(200).send('EVENT_RECEIVED'); @@ -1313,6 +1347,7 @@ app.post('/api/auth/signup', express.json(), async (req, res) => { }); app.post('/api/auth/login', express.json(), async (req, res) => { + if (!await checkRateLimit(req, res, 10, 900)) return; // 10 attempts per 15 min per IP const { email, password } = req.body as Record; if (!email || !password) { res.status(400).json({ error: 'Email and password required' }); @@ -1550,6 +1585,7 @@ app.get('/api/invoices/:number', meterMiddleware, async (req, res) => { // ── Password Reset ────────────────────────────────────────────── app.post('/api/auth/forgot-password', express.json(), async (req, res) => { + if (!await checkRateLimit(req, res, 5, 3600)) return; // 5 per hour per IP const { email } = req.body as Record; if (!email) { res.status(400).json({ error: 'Email required' }); @@ -1559,19 +1595,18 @@ app.post('/api/auth/forgot-password', express.json(), async (req, res) => { const token = crypto.randomUUID().replace(/-/g, ''); const success = await setResetToken(email, token); - if (!success) { - // Don't reveal if email exists - res.json({ message: 'If an account exists, a reset link has been sent.' }); - return; - } + // Always return the same message to avoid email enumeration + res.json({ message: 'If an account exists, a reset link has been sent.' }); - // In production, send email here. For now, return the token in dev mode. - const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`; - res.json({ - message: 'Password reset link generated.', - resetUrl, - token, - }); + if (success) { + const resetUrl = `https://app.squaremcp.com/reset-password?token=${token}`; + sendEmail( + email, + 'Reset your SquareMCP password', + `Click the link below to reset your password. This link expires in 1 hour.\n\n${resetUrl}\n\nIf you didn't request this, you can ignore this email.`, + 'sqcp_info', + ).catch(err => console.error('[auth] reset email send error:', err)); + } }); app.post('/api/auth/reset-password', express.json(), async (req, res) => { @@ -2030,6 +2065,7 @@ function detectEscalation(messages: ChatMessage[]): string | null { } app.post('/api/chat', async (req, res) => { + if (!await checkRateLimit(req, res, 30, 3600)) return; // 30 per hour per IP const { messages } = req.body as { messages?: ChatMessage[] }; if (!Array.isArray(messages) || messages.length === 0) { res.status(400).json({ error: 'messages array required' });