adamp 39b2ce73da Fix critical security vulnerabilities and data integrity issues
- Use timing-safe comparisons for HMAC verification and password checks
- Add login rate limiting (5 attempts/minute per IP)
- Lock down CORS to Vite dev origin only (not needed in production)
- Derive signing key from APP_PASSWORD instead of using it directly
- Replace hand-rolled cookie parsing with cookie-parser middleware
- Wrap all order mutations in SQLite transactions
- Fix TOCTOU race on stock with atomic UPDATE...WHERE quantity >= ?
- Fix APP_SECERT typo in .env (gitignored, local fix only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:04:24 -06:00

89 lines
2.3 KiB
JavaScript

const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const {
COOKIE_NAME,
verify,
createSessionCookie,
clearSessionCookie,
} = require('../middleware/auth');
// In-memory rate limiter for login attempts
const loginAttempts = new Map();
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 5;
function isRateLimited(ip) {
const now = Date.now();
const attempts = loginAttempts.get(ip);
if (!attempts) return false;
// Remove expired entries
const recent = attempts.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
if (recent.length === 0) {
loginAttempts.delete(ip);
return false;
}
loginAttempts.set(ip, recent);
return recent.length >= RATE_LIMIT_MAX;
}
function recordAttempt(ip) {
const now = Date.now();
const attempts = loginAttempts.get(ip) || [];
attempts.push(now);
loginAttempts.set(ip, attempts);
}
router.post('/login', (req, res) => {
const password = process.env.APP_PASSWORD;
if (!password) {
return res.status(200).json({ ok: true });
}
const ip = req.ip;
if (isRateLimited(ip)) {
return res.status(429).json({ error: 'Too many login attempts. Try again later.' });
}
const submitted = req.body?.password || '';
const submittedBuf = Buffer.from(String(submitted));
const passwordBuf = Buffer.from(password);
let match = false;
if (submittedBuf.length === passwordBuf.length) {
match = crypto.timingSafeEqual(submittedBuf, passwordBuf);
}
if (!match) {
recordAttempt(ip);
return res.status(401).json({ error: 'Invalid password' });
}
const session = createSessionCookie();
if (!session) {
return res.status(500).json({ error: 'Auth not configured' });
}
const cookieOpts = { ...session.opts };
if (cookieOpts.secure && !req.secure) cookieOpts.secure = false;
res.cookie(COOKIE_NAME, session.token, cookieOpts);
res.json({ ok: true });
});
router.post('/logout', (req, res) => {
res.cookie(COOKIE_NAME, '', clearSessionCookie());
res.status(204).send();
});
router.get('/me', (req, res) => {
if (!process.env.APP_PASSWORD) {
return res.status(200).json({ ok: true });
}
const token = req.cookies?.[COOKIE_NAME] || null;
if (!token || !verify(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
res.json({ ok: true });
});
module.exports = router;