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:
@@ -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
|
||||
|
||||
64
src/index.ts
64
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: {
|
||||
</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' });
|
||||
|
||||
Reference in New Issue
Block a user