fix(security): self-healing secrets + auth/SSRF/header hardening (v0.16.10-beta)
Follow-up to the /terminal authorization fix: a full-codebase security review surfaced several more issues, fixed here. All changes are designed to roll out with no interruption to existing deployments — no forced migration, no manual re-key, no boot failure. Self-healing secret management (utils/secrets.ts): - JWT_SECRET / ENCRYPTION_KEY auto-generate to strong values on first boot and persist to the app_data volume (0600, outside the DB) when not set to a strong value in the environment. Replaces the prior behavior of running on the public repo defaults. - decrypt() tries the current key plus every legacy/default key, so existing device credentials keep decrypting after a key change; a startup sweep re-encrypts stale rows forward (db/reencryptCredentials.ts). - verifyToken() accepts the current signing secret plus prior *strong* secrets (never the public defaults), so rotation doesn't invalidate live sessions; sessions signed with a default secret require one re-login. - Dockerfile pre-creates /app/data as the app user so the non-root process can persist to a fresh named volume; app_data volume added to both compose files; compose secret defaults changed to empty (auto-gen). Authorization / input: - Default Socket.IO namespace now requires a valid JWT (was unauthenticated and broadcasting fleet-activity events); frontend sends the token and drops the shared socket on logout. - Webhook delivery blocks SSRF to loopback/private/link-local/CGNAT ranges and pins the request to the validated IP (WebhookService.ts). - Rate limit added to /auth/totp/verify; global mutation backstop on /api. - Password policy (>=10 chars, letter+digit) enforced on set/change/create. - system.ts used verifyToken as middleware (always 500); now requireAuth. Hardening: - CSP + HSTS/nosniff/frame-deny headers in nginx, scoped to allow the OpenStreetMap tile/geocoding hosts the maps use. Docs/tests: - README secret section rewritten for the self-healing model. - New unit tests for secret resolution, persistence, rotation, and legacy-key decryption (97 tests pass). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
27560fa1d6
commit
9e962939ea
+16
-15
@@ -2,18 +2,14 @@ import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as qrcode from 'qrcode';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { signToken, requireAuth } from '../middleware/auth';
|
||||
import { signToken, signRawToken, verifyRawToken, requireAuth } from '../middleware/auth';
|
||||
import { getSecretsInfo } from '../utils/secrets';
|
||||
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
||||
import { validatePassword } from '../utils/passwordPolicy';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULT_JWT_SECRET = 'changeme';
|
||||
const DEFAULT_ENCRYPTION_KEY = 'defaultkey32byteslongencryptkey!';
|
||||
|
||||
router.get(
|
||||
'/security-status',
|
||||
requireAuth,
|
||||
@@ -21,11 +17,12 @@ router.get(
|
||||
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');
|
||||
// Secrets self-heal to strong auto-generated values, so the only remaining
|
||||
// risk is an auto-generated secret that couldn't be persisted (it would
|
||||
// churn on restart) — surface that rather than "default".
|
||||
const secrets = getSecretsInfo();
|
||||
if (secrets.ephemeral) {
|
||||
warnings.push('secrets_not_persisted');
|
||||
}
|
||||
|
||||
const adminUser = await queryOne<{ password_hash: string }>(
|
||||
@@ -60,7 +57,7 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
if (user.totp_enabled) {
|
||||
const totpToken = jwt.sign({ userId: user.id, partial: true }, JWT_SECRET, { expiresIn: '5m' });
|
||||
const totpToken = signRawToken({ userId: user.id, partial: true }, { expiresIn: '5m' });
|
||||
return res.json({ requires_totp: true, totp_token: totpToken });
|
||||
}
|
||||
|
||||
@@ -69,14 +66,14 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Exchange partial TOTP token + code for a full session token
|
||||
router.post('/totp/verify', async (req: Request, res: Response) => {
|
||||
router.post('/totp/verify', rateLimitRedis({ windowSec: 60, max: 5, keyPrefix: 'totp-verify', allMethods: true }), async (req: Request, res: Response) => {
|
||||
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
|
||||
if (!totp_token || !code) {
|
||||
return res.status(400).json({ error: 'totp_token and code are required' });
|
||||
}
|
||||
let payload: { userId: number; partial: boolean };
|
||||
try {
|
||||
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
|
||||
payload = verifyRawToken<{ userId: number; partial: boolean }>(totp_token);
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
|
||||
}
|
||||
@@ -178,6 +175,10 @@ router.put('/password', requireAuth, async (req: Request, res: Response) => {
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current and new password required' });
|
||||
}
|
||||
const pwError = validatePassword(newPassword);
|
||||
if (pwError) {
|
||||
return res.status(400).json({ error: pwError });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ id: number; password_hash: string }>(
|
||||
`SELECT id, password_hash FROM users WHERE id = $1`,
|
||||
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||
import { validatePassword } from '../utils/passwordPolicy';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -49,6 +50,10 @@ router.post('/users', requireAdmin, async (req: Request, res: Response) => {
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
}
|
||||
const pwError = validatePassword(password);
|
||||
if (pwError) {
|
||||
return res.status(400).json({ error: pwError });
|
||||
}
|
||||
const validRoles = ['admin', 'operator', 'viewer'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
|
||||
@@ -94,8 +99,9 @@ router.put('/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||||
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
const pwError = validatePassword(password);
|
||||
if (pwError) {
|
||||
return res.status(400).json({ error: pwError });
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { verifyToken } from '../middleware/auth';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { query } from '../config/database';
|
||||
|
||||
const router = Router();
|
||||
router.use(verifyToken);
|
||||
router.use(requireAuth);
|
||||
|
||||
const CURRENT_VERSION = process.env.npm_package_version || '0.0.0';
|
||||
const RAW_URL = 'https://raw.githubusercontent.com/2GT-Media-Group-LLC/mikrotik-manager/main/frontend/package.json';
|
||||
|
||||
Reference in New Issue
Block a user