security: trust proxy fix, login rate limiting, default secret banners (#33)
* fix(security): default-secret banners, login rate limiting, real-IP passthrough
- app.set('trust proxy', 1) so req.ip resolves to real client IP behind nginx
- loginRateLimit() middleware on POST /api/auth/login; limits read from
app_settings (login_rate_limit_max / login_rate_limit_window_sec) with
60-second cache; falls back to 10/60s when Redis/DB unavailable
- GET /api/auth/security-status returns warnings for default JWT_SECRET,
ENCRYPTION_KEY, and admin/admin credential — non-breaking for dev
- SecurityBanners component in AppLayout: yellow banner for default secrets,
red banner + change-password link for default admin password
- Login rate limit settings exposed in Settings > General tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): rate-limit /security-status, suppress CodeQL false positive on /login
- Add allMethods option to rateLimitRedis so it can apply to GET routes
- Apply rateLimitRedis (10 req/60s per user) to GET /auth/security-status
to prevent bcrypt-based CPU exhaustion by authenticated users
- Add lgtm suppression comment on POST /login — CodeQL does not recognize
our custom loginRateLimit() middleware; rate limiting is in place
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Denis Evers <git@evers.sh>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Denis Evers
parent
0350a76232
commit
a336b0e207
@@ -2,10 +2,40 @@ import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const DEFAULT_JWT_SECRET = 'changeme';
|
||||
const DEFAULT_ENCRYPTION_KEY = 'defaultkey32byteslongencryptkey!';
|
||||
|
||||
router.get(
|
||||
'/security-status',
|
||||
requireAuth,
|
||||
rateLimitRedis({ windowSec: 60, max: 10, keyPrefix: 'security-status', allMethods: true }),
|
||||
async (_req: Request, res: Response) => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEFAULT_JWT_SECRET) {
|
||||
warnings.push('jwt_secret_default');
|
||||
}
|
||||
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY === DEFAULT_ENCRYPTION_KEY) {
|
||||
warnings.push('encryption_key_default');
|
||||
}
|
||||
|
||||
const adminUser = await queryOne<{ password_hash: string }>(
|
||||
`SELECT password_hash FROM users WHERE username = 'admin' LIMIT 1`
|
||||
);
|
||||
if (adminUser && (await bcrypt.compare('admin', adminUser.password_hash))) {
|
||||
warnings.push('admin_password_default');
|
||||
}
|
||||
|
||||
return res.json({ warnings });
|
||||
}
|
||||
);
|
||||
|
||||
// lgtm[js/missing-rate-limiting] - loginRateLimit() middleware handles per-IP rate limiting
|
||||
router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
|
||||
Reference in New Issue
Block a user