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 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-17 20:14:08 -04:00
parent 423dc89c94
commit 34983c44e2
2 changed files with 53 additions and 15 deletions

View File

@@ -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

View File

@@ -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: {
</html>`;
}
// ── Rate limiting ───────────────────────────────────────────────────────────
async function checkRateLimit(req: express.Request, res: express.Response, max: number, windowSec: number): Promise<boolean> {
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<string, string>;
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<string, string>;
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' });