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
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { redis } from '../config/redis';
|
||||
import { query } from '../config/database';
|
||||
|
||||
/**
|
||||
* Fixed-window rate limit using Redis INCR (shared across API replicas).
|
||||
@@ -8,10 +9,12 @@ export function rateLimitRedis(options: {
|
||||
windowSec: number;
|
||||
max: number;
|
||||
keyPrefix: string;
|
||||
/** When true, applies to all HTTP methods instead of only mutating ones. */
|
||||
allMethods?: boolean;
|
||||
}): (req: Request, res: Response, next: NextFunction) => void {
|
||||
const { windowSec, max, keyPrefix } = options;
|
||||
const { windowSec, max, keyPrefix, allMethods = false } = options;
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
||||
if (!allMethods && !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -35,3 +38,52 @@ export function rateLimitRedis(options: {
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
let _loginLimitCache: { windowSec: number; max: number; cachedAt: number } | null = null;
|
||||
|
||||
async function getLoginLimits(): Promise<{ windowSec: number; max: number }> {
|
||||
const now = Date.now();
|
||||
if (_loginLimitCache && now - _loginLimitCache.cachedAt < 60_000) {
|
||||
return _loginLimitCache;
|
||||
}
|
||||
try {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings WHERE key IN ('login_rate_limit_window_sec', 'login_rate_limit_max')`
|
||||
);
|
||||
const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
const windowSec = Number(map['login_rate_limit_window_sec']) || 60;
|
||||
const max = Number(map['login_rate_limit_max']) || 10;
|
||||
_loginLimitCache = { windowSec, max, cachedAt: now };
|
||||
return { windowSec, max };
|
||||
} catch {
|
||||
return { windowSec: 60, max: 10 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP rate limiter for the login endpoint. Limits are read from app_settings
|
||||
* (login_rate_limit_window_sec / login_rate_limit_max) with a 60-second cache.
|
||||
*/
|
||||
export function loginRateLimit(): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const ip = req.ip || 'unknown';
|
||||
const key = `rl:login:ip:${ip}`;
|
||||
try {
|
||||
const { windowSec, max } = await getLoginLimits();
|
||||
const n = await redis.incr(key);
|
||||
if (n === 1) {
|
||||
await redis.expire(key, windowSec);
|
||||
}
|
||||
if (n > max) {
|
||||
const ttl = await redis.ttl(key);
|
||||
const retryAfterSec = Math.max(1, ttl > 0 ? ttl : windowSec);
|
||||
res.setHeader('Retry-After', String(retryAfterSec));
|
||||
res.status(429).json({ error: 'Too many login attempts. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loginRateLimit] Redis error, allowing request:', (e as Error).message);
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user