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
@@ -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<SecurityStatus | null>(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 (
|
||||
<div>
|
||||
{hasSecretWarning && (
|
||||
<div className="flex items-center gap-2 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800 px-4 py-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
<span className="font-semibold">Security warning:</span>{' '}
|
||||
{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{' '}
|
||||
<code className="font-mono text-xs">.env</code> file before
|
||||
exposing this to any network.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasAdminWarning && (
|
||||
<div className="flex items-center justify-between gap-4 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800 px-4 py-2 text-sm text-red-800 dark:text-red-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
<span className="font-semibold">Critical:</span> The{' '}
|
||||
<code className="font-mono text-xs">admin</code> account is
|
||||
using the default password. Change it immediately.
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="shrink-0 bg-red-600 hover:bg-red-700 text-white text-xs px-3 py-1 rounded font-medium"
|
||||
>
|
||||
Change password
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
|
||||
<SecurityBanners />
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user