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:
2GT-Rich
2026-07-14 12:42:46 -07:00
co-authored by Claude Opus 4.8
parent 27560fa1d6
commit 9e962939ea
20 changed files with 656 additions and 70 deletions
+16 -15
View File
@@ -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`,
+8 -2
View File
@@ -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]);
+2 -2
View File
@@ -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';