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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -448,8 +448,8 @@ All configuration is done via environment variables in `.env`:
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `JWT_SECRET` | `changeme_use_a_long_random_secret_at_least_32_chars` | Secret for signing JWT tokens. **Change this.** |
|
| `JWT_SECRET` | *(auto-generated & persisted if unset)* | Secret for signing JWT tokens. Leave unset to have a strong one generated on first boot; set it to pin your own value. |
|
||||||
| `ENCRYPTION_KEY` | `changeme32byteslongencryptionkey` | 32-character key for encrypting device passwords at rest. **Change this.** |
|
| `ENCRYPTION_KEY` | *(auto-generated & persisted if unset)* | Key for encrypting device passwords at rest. Leave unset to auto-generate; set it to pin your own value. |
|
||||||
| `CORS_ORIGIN` | *(localhost defaults set by Docker Compose)* | Comma-separated list of browser origins allowed to call the API (e.g. `https://manager.example.com`). **Required in production** — set this to your domain. |
|
| `CORS_ORIGIN` | *(localhost defaults set by Docker Compose)* | Comma-separated list of browser origins allowed to call the API (e.g. `https://manager.example.com`). **Required in production** — set this to your domain. |
|
||||||
| `DB_PASSWORD` | `mikrotik_secure_pw` | PostgreSQL password |
|
| `DB_PASSWORD` | `mikrotik_secure_pw` | PostgreSQL password |
|
||||||
| `INFLUXDB_TOKEN` | `mytoken123456789` | InfluxDB admin token |
|
| `INFLUXDB_TOKEN` | `mytoken123456789` | InfluxDB admin token |
|
||||||
@@ -459,18 +459,16 @@ All configuration is done via environment variables in `.env`:
|
|||||||
| `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) |
|
| `HTTP_PORT` | `80` | Host port for HTTP (redirects to HTTPS) |
|
||||||
| `HTTPS_PORT` | `443` | Host port for HTTPS |
|
| `HTTPS_PORT` | `443` | Host port for HTTPS |
|
||||||
|
|
||||||
### Credential encryption (`ENCRYPTION_KEY`)
|
### Secret management (self-healing)
|
||||||
|
|
||||||
Device API and SSH passwords stored in PostgreSQL are encrypted at rest using **AES-256-GCM** (`backend/src/utils/crypto.ts`). The key material comes from the **`ENCRYPTION_KEY` environment variable** (see table above). If the value is exactly 32 bytes long it is used as-is; otherwise the implementation derives a 32-byte key with SHA-256 (still driven by that env var).
|
`JWT_SECRET` (session signing) and `ENCRYPTION_KEY` (AES-256-GCM credential encryption, `backend/src/utils/crypto.ts`) are managed automatically:
|
||||||
|
|
||||||
**If the key is lost:** ciphertext cannot be decrypted; you must re-enter credentials on each device (or restore a database backup that was encrypted with the old key).
|
- **If set to a strong value in the environment**, that value is used — you stay in control.
|
||||||
|
- **If unset or left at an old default**, the backend generates a strong secret **once**, persists it to the `app_data` volume (`SECRETS_DIR`, default `/app/data`), and reuses it on every boot. Secrets live outside the database so a DB dump alone can't reveal the key protecting the credentials in it.
|
||||||
|
|
||||||
**If the key is rotated (compromise or policy):** there is no automatic bulk re-encryption in the app today. Operational recovery is:
|
Upgrades are non-breaking: on startup, existing ciphertext is decrypted via a **legacy-key fallback** (including previous defaults) and transparently **re-encrypted under the current key** by a background sweep. When rotating the JWT secret off a public default, sessions signed with that default are no longer accepted — users simply log in again once.
|
||||||
|
|
||||||
1. Set a new `ENCRYPTION_KEY` and restart the backend.
|
**Key rotation:** set a new `ENCRYPTION_KEY` (or delete the persisted secret to force regeneration) and restart. Old rows keep decrypting via the legacy fallback and are re-encrypted forward automatically. **If the persisted secret and any prior key are both lost**, ciphertext under that key cannot be recovered — re-enter device credentials or restore a backup.
|
||||||
2. For each managed device, update API/SSH credentials via the UI (or API) so the server stores new ciphertext under the new key. Old rows still hold ciphertext from the previous key until updated.
|
|
||||||
|
|
||||||
For a planned rotation with many devices, restore from backup or script updates against the API using plaintext passwords from your vault.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -27,8 +27,10 @@ RUN apk add --no-cache arp-scan libcap openssl \
|
|||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Create backups directory
|
# Create writable data directories. Pre-creating them (owned by appuser after
|
||||||
RUN mkdir -p /app/backups
|
# the chown below) means a fresh named volume mounted here inherits appuser
|
||||||
|
# ownership, so the non-root process can persist backups and secrets.
|
||||||
|
RUN mkdir -p /app/backups /app/data
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.8-beta",
|
"version": "0.16.10-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* One-shot forward migration of encrypted credentials.
|
||||||
|
*
|
||||||
|
* After a key rotation (including the automatic self-heal off a default key),
|
||||||
|
* existing ciphertext still decrypts via the legacy-key fallback in crypto.ts,
|
||||||
|
* but we want it re-encrypted under the current key so the legacy key can
|
||||||
|
* eventually be retired. This sweep decrypts any stale value and rewrites it.
|
||||||
|
* It is idempotent and best-effort — failures are logged, never fatal.
|
||||||
|
*/
|
||||||
|
import { query } from '../config/database';
|
||||||
|
import { encrypt, decrypt, needsReencryption } from '../utils/crypto';
|
||||||
|
|
||||||
|
interface EncryptedRow {
|
||||||
|
id: number;
|
||||||
|
api_password_encrypted: string | null;
|
||||||
|
ssh_password_encrypted: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLES = ['devices', 'credential_presets'] as const;
|
||||||
|
const COLUMNS = ['api_password_encrypted', 'ssh_password_encrypted'] as const;
|
||||||
|
|
||||||
|
export async function reencryptStaleCredentials(): Promise<number> {
|
||||||
|
let rewritten = 0;
|
||||||
|
|
||||||
|
for (const table of TABLES) {
|
||||||
|
const rows = await query<EncryptedRow>(
|
||||||
|
`SELECT id, ${COLUMNS.join(', ')} FROM ${table}`
|
||||||
|
).catch(() => [] as EncryptedRow[]);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
for (const col of COLUMNS) {
|
||||||
|
const value = row[col];
|
||||||
|
if (!value || !needsReencryption(value)) continue;
|
||||||
|
try {
|
||||||
|
updates[col] = encrypt(decrypt(value));
|
||||||
|
} catch {
|
||||||
|
// Can't decrypt with any known key — leave it untouched.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const keys = Object.keys(updates);
|
||||||
|
if (keys.length === 0) continue;
|
||||||
|
|
||||||
|
const sets = keys.map((k, i) => `${k} = $${i + 2}`).join(', ');
|
||||||
|
await query(
|
||||||
|
`UPDATE ${table} SET ${sets} WHERE id = $1`,
|
||||||
|
[row.id, ...keys.map((k) => updates[k])]
|
||||||
|
).catch(() => {});
|
||||||
|
rewritten += keys.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewritten > 0) {
|
||||||
|
console.log(`[secrets] re-encrypted ${rewritten} credential value(s) under the current key`);
|
||||||
|
}
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ import {
|
|||||||
} from './services/DeviceBulkAddWorker';
|
} from './services/DeviceBulkAddWorker';
|
||||||
import { netflowCollector } from './services/netflow/NetflowCollector';
|
import { netflowCollector } from './services/netflow/NetflowCollector';
|
||||||
import { verifyToken, type AuthPayload } from './middleware/auth';
|
import { verifyToken, type AuthPayload } from './middleware/auth';
|
||||||
|
import { rateLimitRedis } from './middleware/rateLimitRedis';
|
||||||
|
import { initSecrets } from './utils/secrets';
|
||||||
|
import { reencryptStaleCredentials } from './db/reencryptCredentials';
|
||||||
import { decrypt } from './utils/crypto';
|
import { decrypt } from './utils/crypto';
|
||||||
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
||||||
|
|
||||||
@@ -59,6 +62,27 @@ import credentialPresetsRoutes from './routes/credentialPresets';
|
|||||||
import systemRoutes from './routes/system';
|
import systemRoutes from './routes/system';
|
||||||
import { auditMiddleware } from './middleware/auditMiddleware';
|
import { auditMiddleware } from './middleware/auditMiddleware';
|
||||||
|
|
||||||
|
// ─── Secret hygiene ───────────────────────────────────────────────────────────
|
||||||
|
// Self-healing: if JWT_SECRET / ENCRYPTION_KEY aren't set to strong values, we
|
||||||
|
// auto-generate strong ones and persist them, so a deployment is never left on
|
||||||
|
// the public repo defaults and never breaks on upgrade. See utils/secrets.ts.
|
||||||
|
function provisionSecrets(): void {
|
||||||
|
const info = initSecrets();
|
||||||
|
const sourceLine = `secrets: jwt=${info.jwtSource}, encryption=${info.encSource}`;
|
||||||
|
if (info.jwtSource === 'generated' || info.encSource === 'generated') {
|
||||||
|
console.log(`[secrets] auto-generated strong secret(s) (${sourceLine})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[secrets] ${sourceLine}`);
|
||||||
|
}
|
||||||
|
if (info.ephemeral) {
|
||||||
|
console.error(
|
||||||
|
'[secrets] WARNING: generated secrets could not be persisted (SECRETS_DIR not writable). ' +
|
||||||
|
'They will change on restart — users will be logged out and credentials written now may not ' +
|
||||||
|
'decrypt later. Mount a writable volume at SECRETS_DIR (default /app/data) or set JWT_SECRET/ENCRYPTION_KEY.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
// nginx sits exactly one hop in front; trust its X-Forwarded-For so req.ip is the real client IP
|
// 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);
|
app.set('trust proxy', 1);
|
||||||
@@ -71,6 +95,19 @@ const io = new SocketServer(httpServer, {
|
|||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The default namespace broadcasts fleet-activity events (device/client/event
|
||||||
|
// updates); require a valid JWT so unauthenticated clients can't subscribe.
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token = (socket.handshake.auth as { token?: string })?.token;
|
||||||
|
if (!token) return next(new Error('No token'));
|
||||||
|
try {
|
||||||
|
socket.data.user = verifyToken(token);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next(new Error('Invalid or expired token'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
console.log(`Socket connected: ${socket.id}`);
|
console.log(`Socket connected: ${socket.id}`);
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -249,6 +286,10 @@ if (process.env.NODE_ENV !== 'test') {
|
|||||||
}
|
}
|
||||||
app.use(auditMiddleware);
|
app.use(auditMiddleware);
|
||||||
|
|
||||||
|
// Global backstop rate limit on mutating API requests (per user, falling back to
|
||||||
|
// per IP before auth runs). Endpoints with stricter needs add their own limiter.
|
||||||
|
app.use('/api', rateLimitRedis({ windowSec: 60, max: 120, keyPrefix: 'api-global' }));
|
||||||
|
|
||||||
// ─── Health Check ─────────────────────────────────────────────────────────────
|
// ─── Health Check ─────────────────────────────────────────────────────────────
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
@@ -288,6 +329,8 @@ app.use(errorHandler);
|
|||||||
|
|
||||||
// ─── Startup ─────────────────────────────────────────────────────────────────
|
// ─── Startup ─────────────────────────────────────────────────────────────────
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
|
provisionSecrets();
|
||||||
|
|
||||||
// Wait for DB to be ready
|
// Wait for DB to be ready
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -303,6 +346,12 @@ async function start(): Promise<void> {
|
|||||||
// Run migrations
|
// Run migrations
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
|
// Migrate any credentials still encrypted under a legacy/default key forward
|
||||||
|
// to the current key (runs in the background; safe to skip on failure).
|
||||||
|
reencryptStaleCredentials().catch((e) =>
|
||||||
|
console.warn('[secrets] credential re-encryption sweep skipped:', (e as Error).message)
|
||||||
|
);
|
||||||
|
|
||||||
// Reset vendor entries that were previously set to '' due to API rate-limiting,
|
// Reset vendor entries that were previously set to '' due to API rate-limiting,
|
||||||
// so they get re-resolved by the new local OUI database.
|
// so they get re-resolved by the new local OUI database.
|
||||||
await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {});
|
await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { query } from '../config/database';
|
import { query } from '../config/database';
|
||||||
|
import { jwtSigningSecret, jwtVerifierSecrets } from '../utils/secrets';
|
||||||
|
|
||||||
export interface AuthPayload {
|
export interface AuthPayload {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -20,14 +21,42 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
|
||||||
|
|
||||||
export function signToken(payload: AuthPayload): string {
|
export function signToken(payload: AuthPayload): string {
|
||||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' });
|
return jwt.sign(payload, jwtSigningSecret(), { expiresIn: '24h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string): AuthPayload {
|
export function verifyToken(token: string): AuthPayload {
|
||||||
return jwt.verify(token, JWT_SECRET) as AuthPayload;
|
// Accept the current signing secret plus any prior strong secret, so a secret
|
||||||
|
// rotation doesn't invalidate sessions already issued under the previous one.
|
||||||
|
const secrets = jwtVerifierSecrets();
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const secret of secrets) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, secret) as AuthPayload;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error ? lastErr : new Error('invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a short-lived token (e.g. the partial 2FA token) with the current secret. */
|
||||||
|
export function signRawToken(payload: object, options: jwt.SignOptions): string {
|
||||||
|
return jwt.sign(payload, jwtSigningSecret(), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a token that was signed with signRawToken, honoring rotated secrets. */
|
||||||
|
export function verifyRawToken<T>(token: string): T {
|
||||||
|
const secrets = jwtVerifierSecrets();
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const secret of secrets) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, secret) as T;
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error ? lastErr : new Error('invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
// API tokens ("mtm_…") are hashed at rest; scope maps onto the existing role
|
// API tokens ("mtm_…") are hashed at rest; scope maps onto the existing role
|
||||||
|
|||||||
+16
-15
@@ -2,18 +2,14 @@ import { Router, Request, Response } from 'express';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as OTPAuth from 'otpauth';
|
import * as OTPAuth from 'otpauth';
|
||||||
import * as qrcode from 'qrcode';
|
import * as qrcode from 'qrcode';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import { query, queryOne } from '../config/database';
|
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';
|
import { loginRateLimit, rateLimitRedis } from '../middleware/rateLimitRedis';
|
||||||
|
import { validatePassword } from '../utils/passwordPolicy';
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'changeme';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const DEFAULT_JWT_SECRET = 'changeme';
|
|
||||||
const DEFAULT_ENCRYPTION_KEY = 'defaultkey32byteslongencryptkey!';
|
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/security-status',
|
'/security-status',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -21,11 +17,12 @@ router.get(
|
|||||||
async (_req: Request, res: Response) => {
|
async (_req: Request, res: Response) => {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === DEFAULT_JWT_SECRET) {
|
// Secrets self-heal to strong auto-generated values, so the only remaining
|
||||||
warnings.push('jwt_secret_default');
|
// risk is an auto-generated secret that couldn't be persisted (it would
|
||||||
}
|
// churn on restart) — surface that rather than "default".
|
||||||
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY === DEFAULT_ENCRYPTION_KEY) {
|
const secrets = getSecretsInfo();
|
||||||
warnings.push('encryption_key_default');
|
if (secrets.ephemeral) {
|
||||||
|
warnings.push('secrets_not_persisted');
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUser = await queryOne<{ password_hash: string }>(
|
const adminUser = await queryOne<{ password_hash: string }>(
|
||||||
@@ -60,7 +57,7 @@ router.post('/login', loginRateLimit(), async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.totp_enabled) {
|
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 });
|
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
|
// 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 };
|
const { totp_token, code } = req.body as { totp_token?: string; code?: string };
|
||||||
if (!totp_token || !code) {
|
if (!totp_token || !code) {
|
||||||
return res.status(400).json({ error: 'totp_token and code are required' });
|
return res.status(400).json({ error: 'totp_token and code are required' });
|
||||||
}
|
}
|
||||||
let payload: { userId: number; partial: boolean };
|
let payload: { userId: number; partial: boolean };
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(totp_token, JWT_SECRET) as typeof payload;
|
payload = verifyRawToken<{ userId: number; partial: boolean }>(totp_token);
|
||||||
} catch {
|
} catch {
|
||||||
return res.status(401).json({ error: 'Invalid or expired TOTP token' });
|
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) {
|
if (!currentPassword || !newPassword) {
|
||||||
return res.status(400).json({ error: 'Current and new password required' });
|
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 }>(
|
const user = await queryOne<{ id: number; password_hash: string }>(
|
||||||
`SELECT id, password_hash FROM users WHERE id = $1`,
|
`SELECT id, password_hash FROM users WHERE id = $1`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import { query, queryOne } from '../config/database';
|
import { query, queryOne } from '../config/database';
|
||||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||||
|
import { validatePassword } from '../utils/passwordPolicy';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -49,6 +50,10 @@ router.post('/users', requireAdmin, async (req: Request, res: Response) => {
|
|||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: 'username and password required' });
|
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'];
|
const validRoles = ['admin', 'operator', 'viewer'];
|
||||||
if (!validRoles.includes(role)) {
|
if (!validRoles.includes(role)) {
|
||||||
return res.status(400).json({ error: 'Invalid role. Must be admin, operator, or viewer' });
|
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]);
|
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, userId]);
|
||||||
}
|
}
|
||||||
if (password) {
|
if (password) {
|
||||||
if (password.length < 6) {
|
const pwError = validatePassword(password);
|
||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
if (pwError) {
|
||||||
|
return res.status(400).json({ error: pwError });
|
||||||
}
|
}
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
|
await query(`UPDATE users SET password_hash = $1 WHERE id = $2`, [hash, userId]);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { verifyToken } from '../middleware/auth';
|
import { requireAuth } from '../middleware/auth';
|
||||||
import { query } from '../config/database';
|
import { query } from '../config/database';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(verifyToken);
|
router.use(requireAuth);
|
||||||
|
|
||||||
const CURRENT_VERSION = process.env.npm_package_version || '0.0.0';
|
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';
|
const RAW_URL = 'https://raw.githubusercontent.com/2GT-Media-Group-LLC/mikrotik-manager/main/frontend/package.json';
|
||||||
|
|||||||
@@ -5,9 +5,63 @@
|
|||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
|
import * as dns from 'dns';
|
||||||
|
import { isIP } from 'net';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { query } from '../config/database';
|
import { query } from '../config/database';
|
||||||
|
|
||||||
|
const dnsLookup = dns.promises.lookup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if an IP literal is in a range we must never let a webhook reach —
|
||||||
|
* loopback, private, link-local (incl. cloud metadata 169.254.169.254), CGNAT,
|
||||||
|
* or unspecified. Guards against SSRF to internal services.
|
||||||
|
*/
|
||||||
|
export function isBlockedAddress(ip: string): boolean {
|
||||||
|
const v = isIP(ip);
|
||||||
|
if (v === 4) {
|
||||||
|
const o = ip.split('.').map(Number);
|
||||||
|
if (o.length !== 4 || o.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
||||||
|
const [a, b] = o;
|
||||||
|
if (a === 0 || a === 10 || a === 127) return true; // this-net, private, loopback
|
||||||
|
if (a === 169 && b === 254) return true; // link-local + metadata
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
||||||
|
if (a === 192 && b === 168) return true; // private
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||||
|
if (a >= 224) return true; // multicast/reserved/broadcast
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (v === 6) {
|
||||||
|
const lower = ip.toLowerCase();
|
||||||
|
if (lower === '::1' || lower === '::') return true;
|
||||||
|
// IPv4-mapped (::ffff:1.2.3.4) — validate the embedded IPv4
|
||||||
|
const mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (mapped) return isBlockedAddress(mapped[1]);
|
||||||
|
const head = lower.split(':')[0];
|
||||||
|
if (head.startsWith('fe8') || head.startsWith('fe9') || head.startsWith('fea') || head.startsWith('feb')) return true; // fe80::/10
|
||||||
|
if (head.startsWith('fc') || head.startsWith('fd')) return true; // fc00::/7 unique-local
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true; // not a valid IP literal → refuse
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPublicHost(hostname: string): Promise<string> {
|
||||||
|
// Already an IP literal — check directly.
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
if (isBlockedAddress(hostname)) throw new Error('URL resolves to a blocked (private/internal) address');
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
const results = await dnsLookup(hostname, { all: true }).catch(() => {
|
||||||
|
throw new Error('Could not resolve webhook host');
|
||||||
|
});
|
||||||
|
if (results.length === 0) throw new Error('Could not resolve webhook host');
|
||||||
|
for (const r of results) {
|
||||||
|
if (isBlockedAddress(r.address)) throw new Error('URL resolves to a blocked (private/internal) address');
|
||||||
|
}
|
||||||
|
// Pin to the validated address so a re-resolve can't swap in an internal IP.
|
||||||
|
return results[0].address;
|
||||||
|
}
|
||||||
|
|
||||||
export const WEBHOOK_EVENTS = [
|
export const WEBHOOK_EVENTS = [
|
||||||
'device_offline', 'device_online', 'log_error', 'log_warning',
|
'device_offline', 'device_online', 'log_error', 'log_warning',
|
||||||
'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered',
|
'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered',
|
||||||
@@ -21,15 +75,30 @@ interface WebhookRow {
|
|||||||
events: string[]; enabled: boolean;
|
events: string[]; enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function postJson(urlStr: string, body: string, headers: Record<string, string>): Promise<number> {
|
async function postJson(urlStr: string, body: string, headers: Record<string, string>): Promise<number> {
|
||||||
|
let u: URL;
|
||||||
|
try { u = new URL(urlStr); } catch { throw new Error('Invalid URL'); }
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('URL must be http(s)');
|
||||||
|
|
||||||
|
const isHttps = u.protocol === 'https:';
|
||||||
|
const pinnedAddress = await assertPublicHost(u.hostname);
|
||||||
|
const port = u.port ? Number(u.port) : (isHttps ? 443 : 80);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let u: URL;
|
const lib = isHttps ? https : http;
|
||||||
try { u = new URL(urlStr); } catch { reject(new Error('Invalid URL')); return; }
|
const req = lib.request({
|
||||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') { reject(new Error('URL must be http(s)')); return; }
|
host: pinnedAddress, // connect to the validated IP, not a re-resolved host
|
||||||
const lib = u.protocol === 'https:' ? https : http;
|
port,
|
||||||
const req = lib.request(u, {
|
path: `${u.pathname}${u.search}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
|
// Preserve virtual-host routing and TLS cert validation against the real name.
|
||||||
|
servername: isHttps ? u.hostname : undefined,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
Host: u.host,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
}, (res) => {
|
}, (res) => {
|
||||||
res.resume(); // drain
|
res.resume(); // drain
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as os from 'os';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as nodeCrypto from 'crypto';
|
||||||
|
|
||||||
|
// Reproduce ciphertext exactly as legacy code produced it under a given key.
|
||||||
|
function legacyEncrypt(plain: string, material: string): string {
|
||||||
|
const key = material.length === 32
|
||||||
|
? Buffer.from(material, 'utf8')
|
||||||
|
: nodeCrypto.createHash('sha256').update(material).digest();
|
||||||
|
const iv = nodeCrypto.randomBytes(16);
|
||||||
|
const cipher = nodeCrypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
return [iv.toString('hex'), cipher.getAuthTag().toString('hex'), enc.toString('hex')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('self-healing secrets', () => {
|
||||||
|
const OLD_ENV = process.env;
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mtm-secrets-'));
|
||||||
|
process.env = { ...OLD_ENV, SECRETS_DIR: tmpDir };
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.ENCRYPTION_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = OLD_ENV;
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-generates and persists strong secrets when none are set', async () => {
|
||||||
|
const secrets = await import('../secrets');
|
||||||
|
const info = secrets.initSecrets();
|
||||||
|
expect(info.jwtSource).toBe('generated');
|
||||||
|
expect(info.encSource).toBe('generated');
|
||||||
|
expect(info.persisted).toBe(true);
|
||||||
|
expect(info.ephemeral).toBe(false);
|
||||||
|
expect(secrets.jwtSigningSecret().length).toBeGreaterThanOrEqual(32);
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'secrets.json'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the persisted secret across reloads (stable across restarts)', async () => {
|
||||||
|
const first = await import('../secrets');
|
||||||
|
first.initSecrets();
|
||||||
|
const jwt1 = first.jwtSigningSecret();
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
const second = await import('../secrets');
|
||||||
|
second.initSecrets();
|
||||||
|
expect(second.jwtSigningSecret()).toBe(jwt1);
|
||||||
|
expect(second.getSecretsInfo().jwtSource).toBe('persisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a strong env secret over generating one', async () => {
|
||||||
|
process.env.JWT_SECRET = 'a'.repeat(40);
|
||||||
|
const secrets = await import('../secrets');
|
||||||
|
const info = secrets.initSecrets();
|
||||||
|
expect(info.jwtSource).toBe('env');
|
||||||
|
expect(secrets.jwtSigningSecret()).toBe('a'.repeat(40));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never accepts a known-default JWT secret as strong', async () => {
|
||||||
|
process.env.JWT_SECRET = 'changeme_use_a_long_random_secret';
|
||||||
|
const secrets = await import('../secrets');
|
||||||
|
const info = secrets.initSecrets();
|
||||||
|
expect(info.jwtSource).toBe('generated');
|
||||||
|
expect(secrets.jwtVerifierSecrets()).not.toContain('changeme_use_a_long_random_secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypts ciphertext written under the old default key after self-heal', async () => {
|
||||||
|
// Legacy data on disk: encrypted under the old built-in default key.
|
||||||
|
const legacyCipher = legacyEncrypt('router-admin-pw', 'changeme32byteslongencryptionkey');
|
||||||
|
|
||||||
|
// Boot with no env key → auto-generate a new current key.
|
||||||
|
const crypto = await import('../crypto');
|
||||||
|
expect(crypto.decrypt(legacyCipher)).toBe('router-admin-pw'); // legacy fallback works
|
||||||
|
expect(crypto.needsReencryption(legacyCipher)).toBe(true); // flagged for forward migration
|
||||||
|
expect(crypto.needsReencryption(crypto.encrypt('x'))).toBe(false); // current-key data is not
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
-18
@@ -7,22 +7,13 @@
|
|||||||
* "Credential encryption (ENCRYPTION_KEY)".
|
* "Credential encryption (ENCRYPTION_KEY)".
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import { encryptionKey, decryptionKeys } from './secrets';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 16;
|
const IV_LENGTH = 16;
|
||||||
const TAG_LENGTH = 16;
|
|
||||||
|
|
||||||
function getKey(): Buffer {
|
|
||||||
const key = process.env.ENCRYPTION_KEY || 'defaultkey32byteslongencryptkey!';
|
|
||||||
// Ensure exactly 32 bytes via SHA-256 hash if needed
|
|
||||||
if (key.length === 32) {
|
|
||||||
return Buffer.from(key, 'utf8');
|
|
||||||
}
|
|
||||||
return crypto.createHash('sha256').update(key).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encrypt(text: string): string {
|
export function encrypt(text: string): string {
|
||||||
const key = getKey();
|
const key = encryptionKey();
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
@@ -33,20 +24,45 @@ export function encrypt(text: string): string {
|
|||||||
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
|
return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decryptWith(key: Buffer, iv: Buffer, tag: Buffer, encrypted: Buffer): string {
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
export function decrypt(encryptedText: string): string {
|
export function decrypt(encryptedText: string): string {
|
||||||
const key = getKey();
|
|
||||||
const parts = encryptedText.split(':');
|
const parts = encryptedText.split(':');
|
||||||
if (parts.length !== 3) {
|
if (parts.length !== 3) {
|
||||||
throw new Error('Invalid encrypted text format');
|
throw new Error('Invalid encrypted text format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const iv = Buffer.from(parts[0], 'hex');
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
const tag = Buffer.from(parts[1], 'hex');
|
const tag = Buffer.from(parts[1], 'hex');
|
||||||
const encrypted = Buffer.from(parts[2], 'hex');
|
const encrypted = Buffer.from(parts[2], 'hex');
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
// Try the current key first, then every legacy key, so ciphertext written
|
||||||
decipher.setAuthTag(tag);
|
// under an older (or default) key keeps decrypting after a key rotation.
|
||||||
|
let lastErr: unknown;
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
for (const key of decryptionKeys()) {
|
||||||
return decrypted.toString('utf8');
|
try {
|
||||||
|
return decryptWith(key, iv, tag, encrypted);
|
||||||
|
} catch (e) {
|
||||||
|
lastErr = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr instanceof Error ? lastErr : new Error('Unable to decrypt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the ciphertext does NOT decrypt under the current key (i.e. it was
|
||||||
|
* written with a legacy key and should be re-encrypted forward).
|
||||||
|
*/
|
||||||
|
export function needsReencryption(encryptedText: string): boolean {
|
||||||
|
const parts = encryptedText.split(':');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
try {
|
||||||
|
decryptWith(encryptionKey(), Buffer.from(parts[0], 'hex'), Buffer.from(parts[1], 'hex'), Buffer.from(parts[2], 'hex'));
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Minimum password policy applied wherever a password is set or changed.
|
||||||
|
* Returns an error message if the password is unacceptable, or null if it's OK.
|
||||||
|
*/
|
||||||
|
export function validatePassword(password: unknown): string | null {
|
||||||
|
if (typeof password !== 'string') return 'Password must be a string';
|
||||||
|
if (password.length < 10) return 'Password must be at least 10 characters long';
|
||||||
|
if (password.length > 200) return 'Password must be at most 200 characters long';
|
||||||
|
if (!/[A-Za-z]/.test(password)) return 'Password must contain at least one letter';
|
||||||
|
if (!/[0-9]/.test(password)) return 'Password must contain at least one number';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Self-healing secret management for JWT signing and credential encryption.
|
||||||
|
*
|
||||||
|
* Goal: a deployment must never break on upgrade or require a manual migration.
|
||||||
|
* - If JWT_SECRET / ENCRYPTION_KEY are set to strong values in the environment,
|
||||||
|
* those win (operator stays in control).
|
||||||
|
* - Otherwise (unset or a well-known repo default), we auto-generate a strong
|
||||||
|
* secret ONCE and persist it to a file on a durable volume, reusing it on
|
||||||
|
* every subsequent boot so it's stable (no session/credential churn).
|
||||||
|
* - Old data keeps working: decryption tries the current key plus every legacy
|
||||||
|
* key (including the old built-in defaults), and JWTs verify against the
|
||||||
|
* current secret plus any prior *strong* secret. The public defaults are
|
||||||
|
* never accepted as a JWT verifier — that would keep the forgery hole open.
|
||||||
|
*
|
||||||
|
* Secrets live outside the database (in SECRETS_DIR) so a DB dump alone can't
|
||||||
|
* reveal the key that protects the credentials stored in it.
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Publicly known defaults shipped in this repo / compose files.
|
||||||
|
const KNOWN_DEFAULT_JWT = ['changeme', 'changeme_use_a_long_random_secret'];
|
||||||
|
const KNOWN_DEFAULT_ENC = ['defaultkey32byteslongencryptkey!', 'changeme32byteslongencryptionkey'];
|
||||||
|
|
||||||
|
interface PersistedSecrets {
|
||||||
|
jwtSecret?: string;
|
||||||
|
encryptionKey?: string;
|
||||||
|
prevJwtSecrets?: string[];
|
||||||
|
prevEncryptionKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretsInfo {
|
||||||
|
jwtSource: 'env' | 'persisted' | 'generated';
|
||||||
|
encSource: 'env' | 'persisted' | 'generated';
|
||||||
|
persisted: boolean;
|
||||||
|
/** True if a secret was generated but could NOT be persisted (won't survive restart). */
|
||||||
|
ephemeral: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedSecrets {
|
||||||
|
jwtCurrent: string;
|
||||||
|
jwtVerifiers: string[];
|
||||||
|
encCurrent: Buffer;
|
||||||
|
encDecryptors: Buffer[];
|
||||||
|
info: SecretsInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: ResolvedSecrets | null = null;
|
||||||
|
|
||||||
|
function isStrongJwt(v: string | undefined): v is string {
|
||||||
|
return !!v && !KNOWN_DEFAULT_JWT.includes(v) && v.length >= 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveKey(material: string): Buffer {
|
||||||
|
return material.length === 32
|
||||||
|
? Buffer.from(material, 'utf8')
|
||||||
|
: crypto.createHash('sha256').update(material).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeKeys(materials: string[]): Buffer[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Buffer[] = [];
|
||||||
|
for (const m of materials) {
|
||||||
|
if (!m) continue;
|
||||||
|
const key = deriveKey(m);
|
||||||
|
const h = key.toString('hex');
|
||||||
|
if (seen.has(h)) continue;
|
||||||
|
seen.add(h);
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretsDir(): string {
|
||||||
|
return process.env.SECRETS_DIR || '/app/data';
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretsFile(): string {
|
||||||
|
return path.join(secretsDir(), 'secrets.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersisted(): PersistedSecrets {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(secretsFile(), 'utf8');
|
||||||
|
return JSON.parse(raw) as PersistedSecrets;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist secrets with owner-only permissions. Returns whether it succeeded. */
|
||||||
|
function savePersisted(p: PersistedSecrets): boolean {
|
||||||
|
try {
|
||||||
|
const dir = secretsDir();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const file = secretsFile();
|
||||||
|
fs.writeFileSync(file, JSON.stringify(p, null, 2), { mode: 0o600 });
|
||||||
|
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSecrets(): SecretsInfo {
|
||||||
|
if (resolved) return resolved.info;
|
||||||
|
|
||||||
|
const persisted = loadPersisted();
|
||||||
|
const history: PersistedSecrets = {
|
||||||
|
prevJwtSecrets: persisted.prevJwtSecrets ? [...persisted.prevJwtSecrets] : [],
|
||||||
|
prevEncryptionKeys: persisted.prevEncryptionKeys ? [...persisted.prevEncryptionKeys] : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── JWT secret ──────────────────────────────────────────────────────────────
|
||||||
|
const envJwt = process.env.JWT_SECRET;
|
||||||
|
let jwtCurrent: string;
|
||||||
|
let jwtSource: SecretsInfo['jwtSource'];
|
||||||
|
let generatedJwt = false;
|
||||||
|
if (isStrongJwt(envJwt)) {
|
||||||
|
jwtCurrent = envJwt;
|
||||||
|
jwtSource = 'env';
|
||||||
|
} else if (persisted.jwtSecret) {
|
||||||
|
jwtCurrent = persisted.jwtSecret;
|
||||||
|
jwtSource = 'persisted';
|
||||||
|
} else {
|
||||||
|
jwtCurrent = crypto.randomBytes(48).toString('base64url');
|
||||||
|
jwtSource = 'generated';
|
||||||
|
generatedJwt = true;
|
||||||
|
}
|
||||||
|
// Verifiers: current + any prior *strong* secrets (never the public defaults).
|
||||||
|
const jwtVerifiers = [
|
||||||
|
jwtCurrent,
|
||||||
|
...(persisted.jwtSecret ? [persisted.jwtSecret] : []),
|
||||||
|
...(history.prevJwtSecrets || []),
|
||||||
|
].filter((v, i, a) => isStrongJwt(v) && a.indexOf(v) === i);
|
||||||
|
|
||||||
|
// ── Encryption key ──────────────────────────────────────────────────────────
|
||||||
|
const envEnc = process.env.ENCRYPTION_KEY;
|
||||||
|
let encMaterial: string;
|
||||||
|
let encSource: SecretsInfo['encSource'];
|
||||||
|
let generatedEnc = false;
|
||||||
|
if (envEnc && !KNOWN_DEFAULT_ENC.includes(envEnc)) {
|
||||||
|
encMaterial = envEnc;
|
||||||
|
encSource = 'env';
|
||||||
|
} else if (persisted.encryptionKey) {
|
||||||
|
encMaterial = persisted.encryptionKey;
|
||||||
|
encSource = 'persisted';
|
||||||
|
} else {
|
||||||
|
encMaterial = crypto.randomBytes(16).toString('hex'); // 32 chars, used verbatim
|
||||||
|
encSource = 'generated';
|
||||||
|
generatedEnc = true;
|
||||||
|
}
|
||||||
|
// Decryptors: current + prior keys + the known defaults, so any legacy
|
||||||
|
// ciphertext still decrypts. Legacy defaults for decrypt-only are safe.
|
||||||
|
const encDecryptors = dedupeKeys([
|
||||||
|
encMaterial,
|
||||||
|
...(persisted.encryptionKey ? [persisted.encryptionKey] : []),
|
||||||
|
...(history.prevEncryptionKeys || []),
|
||||||
|
...KNOWN_DEFAULT_ENC,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Persist auto-managed secrets so they're stable across restarts ───────────
|
||||||
|
let persistedOk = true;
|
||||||
|
if (generatedJwt || generatedEnc) {
|
||||||
|
// Record any superseded auto-managed secret in history for continuity.
|
||||||
|
if (persisted.jwtSecret && persisted.jwtSecret !== jwtCurrent && isStrongJwt(persisted.jwtSecret)) {
|
||||||
|
history.prevJwtSecrets = [...new Set([...(history.prevJwtSecrets || []), persisted.jwtSecret])];
|
||||||
|
}
|
||||||
|
if (persisted.encryptionKey && persisted.encryptionKey !== encMaterial) {
|
||||||
|
history.prevEncryptionKeys = [...new Set([...(history.prevEncryptionKeys || []), persisted.encryptionKey])];
|
||||||
|
}
|
||||||
|
const toSave: PersistedSecrets = {
|
||||||
|
// Only store secrets we manage ourselves — never write an env-provided secret to disk.
|
||||||
|
jwtSecret: jwtSource === 'env' ? persisted.jwtSecret : jwtCurrent,
|
||||||
|
encryptionKey: encSource === 'env' ? persisted.encryptionKey : encMaterial,
|
||||||
|
prevJwtSecrets: history.prevJwtSecrets,
|
||||||
|
prevEncryptionKeys: history.prevEncryptionKeys,
|
||||||
|
};
|
||||||
|
persistedOk = savePersisted(toSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: SecretsInfo = {
|
||||||
|
jwtSource,
|
||||||
|
encSource,
|
||||||
|
persisted: persistedOk,
|
||||||
|
ephemeral: (generatedJwt || generatedEnc) && !persistedOk,
|
||||||
|
};
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
jwtCurrent,
|
||||||
|
jwtVerifiers,
|
||||||
|
encCurrent: deriveKey(encMaterial),
|
||||||
|
encDecryptors,
|
||||||
|
info,
|
||||||
|
};
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure(): ResolvedSecrets {
|
||||||
|
if (!resolved) initSecrets();
|
||||||
|
return resolved!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current secret used to SIGN new JWTs. */
|
||||||
|
export function jwtSigningSecret(): string {
|
||||||
|
return ensure().jwtCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Secrets a JWT may be verified against (current + prior strong secrets). */
|
||||||
|
export function jwtVerifierSecrets(): string[] {
|
||||||
|
return ensure().jwtVerifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current key used to ENCRYPT new data. */
|
||||||
|
export function encryptionKey(): Buffer {
|
||||||
|
return ensure().encCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All keys a ciphertext may be decrypted with (current + legacy). */
|
||||||
|
export function decryptionKeys(): Buffer[] {
|
||||||
|
return ensure().encDecryptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecretsInfo(): SecretsInfo {
|
||||||
|
return ensure().info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: forget resolved secrets so the next call re-reads env/disk. */
|
||||||
|
export function _resetSecretsForTest(): void {
|
||||||
|
resolved = null;
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ services:
|
|||||||
INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
||||||
INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
||||||
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
||||||
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret}
|
# Leave unset to auto-generate & persist strong secrets on first boot
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
|
# (stored in the app_data volume). Set them to pin your own values.
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -43,6 +45,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- backups_data:/app/backups
|
- backups_data:/app/backups
|
||||||
- certs_data:/certs
|
- certs_data:/certs
|
||||||
|
- app_data:/app/data
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -114,6 +117,7 @@ volumes:
|
|||||||
influxdb_data:
|
influxdb_data:
|
||||||
backups_data:
|
backups_data:
|
||||||
certs_data:
|
certs_data:
|
||||||
|
app_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
|
|||||||
+6
-2
@@ -30,8 +30,10 @@ services:
|
|||||||
INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789}
|
||||||
INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager}
|
||||||
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics}
|
||||||
JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret}
|
# Leave unset to auto-generate & persist strong secrets on first boot
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
|
# (stored in the app_data volume). Set them to pin your own values.
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
NETFLOW_BIND_PORT: 2055
|
NETFLOW_BIND_PORT: 2055
|
||||||
@@ -49,6 +51,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- backups_data:/app/backups
|
- backups_data:/app/backups
|
||||||
- certs_data:/certs
|
- certs_data:/certs
|
||||||
|
- app_data:/app/data
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -121,6 +124,7 @@ volumes:
|
|||||||
influxdb_data:
|
influxdb_data:
|
||||||
backups_data:
|
backups_data:
|
||||||
certs_data:
|
certs_data:
|
||||||
|
app_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.16.8-beta",
|
"version": "0.16.10-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -16,11 +16,19 @@ export function useSocket(
|
|||||||
useLayoutEffect(() => { eventsRef.current = events; });
|
useLayoutEffect(() => { eventsRef.current = events; });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) {
|
||||||
|
// Drop the shared socket on logout so a later login reconnects with a fresh token.
|
||||||
|
if (globalSocket) {
|
||||||
|
globalSocket.disconnect();
|
||||||
|
globalSocket = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!globalSocket) {
|
if (!globalSocket) {
|
||||||
globalSocket = io('/', {
|
globalSocket = io('/', {
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
|
auth: { token: useAuthStore.getState().token },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 2000,
|
reconnectionDelay: 2000,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.16.8 Beta';
|
export const APP_VERSION = 'v0.16.10 Beta';
|
||||||
|
|||||||
+17
-1
@@ -19,6 +19,15 @@ server {
|
|||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 1d;
|
ssl_session_timeout 1d;
|
||||||
|
|
||||||
|
# Security headers. CSP allows self, inline styles (React style props +
|
||||||
|
# runtime-injected stylesheets), data/blob images (QR codes), and the
|
||||||
|
# OpenStreetMap tile + Nominatim geocoding hosts the maps use.
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -56,10 +65,17 @@ server {
|
|||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
}
|
}
|
||||||
|
|
||||||
# React SPA - serve index.html for all non-API routes
|
# React SPA - serve index.html for all non-API routes.
|
||||||
|
# Security headers are repeated here because an add_header in a location
|
||||||
|
# block suppresses inheritance of the server-level ones.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org; connect-src 'self' https://nominatim.openstreetmap.org; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
|
|||||||
Reference in New Issue
Block a user