diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 8011cc5..49341f0 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -320,6 +320,8 @@ const DEFAULT_SETTINGS = [ { key: 'spectral_scan_interval_hours', value: 24 }, { key: 'ap_scan_enabled', value: false }, { key: 'ap_scan_interval_hours', value: 24 }, + { key: 'login_rate_limit_window_sec', value: 60 }, + { key: 'login_rate_limit_max', value: 10 }, ]; export async function runMigrations(): Promise { diff --git a/backend/src/index.ts b/backend/src/index.ts index 83db2de..56db5aa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -45,6 +45,8 @@ import networkServicesRoutes from './routes/networkServices'; import credentialPresetsRoutes from './routes/credentialPresets'; const app = express(); +// nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP +app.set('trust proxy', 1); const httpServer = createServer(app); const PORT = parseInt(process.env.PORT || '3001', 10); diff --git a/backend/src/middleware/rateLimitRedis.ts b/backend/src/middleware/rateLimitRedis.ts index 13de4b6..389b17a 100644 --- a/backend/src/middleware/rateLimitRedis.ts +++ b/backend/src/middleware/rateLimitRedis.ts @@ -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 => { - 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 => { + 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(); + }; +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index a5140fd..b189e2a 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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) { diff --git a/frontend/src/components/SecurityBanners.tsx b/frontend/src/components/SecurityBanners.tsx new file mode 100644 index 0000000..4cf2c0f --- /dev/null +++ b/frontend/src/components/SecurityBanners.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { AlertTriangle, ShieldAlert } from 'lucide-react'; +import { authApi } from '../services/api'; + +interface SecurityStatus { + warnings: string[]; +} + +export default function SecurityBanners() { + const [status, setStatus] = useState(null); + + useEffect(() => { + authApi.securityStatus() + .then((r) => setStatus(r.data)) + .catch(() => {}); + }, []); + + if (!status || status.warnings.length === 0) return null; + + const hasSecretWarning = + status.warnings.includes('jwt_secret_default') || + status.warnings.includes('encryption_key_default'); + const hasAdminWarning = status.warnings.includes('admin_password_default'); + + const secretNames = [ + status.warnings.includes('jwt_secret_default') && 'JWT_SECRET', + status.warnings.includes('encryption_key_default') && 'ENCRYPTION_KEY', + ] + .filter(Boolean) + .join(' and '); + + return ( +
+ {hasSecretWarning && ( +
+ + + Security warning:{' '} + {secretNames}{' '} + {status.warnings.filter((w) => + ['jwt_secret_default', 'encryption_key_default'].includes(w) + ).length > 1 + ? 'are' + : 'is'}{' '} + using default values. Set strong secrets in your{' '} + .env file before + exposing this to any network. + +
+ )} + {hasAdminWarning && ( +
+
+ + + Critical: The{' '} + admin account is + using the default password. Change it immediately. + +
+ + Change password + +
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index ea80202..364ba48 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import Sidebar from './Sidebar'; import TopBar from './TopBar'; +import SecurityBanners from '../SecurityBanners'; export default function AppLayout() { const [sidebarOpen, setSidebarOpen] = useState(false); @@ -20,6 +21,7 @@ export default function AppLayout() {
setSidebarOpen((o) => !o)} /> +
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2b7f864..5be6f44 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -366,6 +366,38 @@ export default function SettingsPage() {
+ {/* Login Rate Limit */} +
+

Login Rate Limiting

+

+ Limits login attempts per IP address to protect against brute-force attacks. +

+
+ {[ + { key: 'login_rate_limit_max', label: 'Max attempts', unit: 'attempts' }, + { key: 'login_rate_limit_window_sec', label: 'Time window', unit: 'sec' }, + ].map(({ key, label, unit }) => ( +
+
+
{label}
+
{unit}
+
+ + updateSettingsMutation.mutate({ [key]: parseInt(e.target.value) }) + } + min="1" + step="1" + disabled={!isAdmin} + /> +
+ ))} +
+
+ {/* MAC Scan */}

MAC Scan

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 05ac4f5..58b9554 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -49,6 +49,8 @@ export const authApi = { me: () => api.get('/auth/me'), changePassword: (currentPassword: string, newPassword: string) => api.put('/auth/password', { currentPassword, newPassword }), + securityStatus: () => + api.get<{ warnings: string[] }>('/auth/security-status'), }; // ─── Devices ──────────────────────────────────────────────────────────────────