From 9e962939eae4e3f435bacb88fc664e3cbdc0078d Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:42:46 -0700 Subject: [PATCH] fix(security): self-healing secrets + auth/SSRF/header hardening (v0.16.10-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 +- backend/Dockerfile | 6 +- backend/package.json | 2 +- backend/src/db/reencryptCredentials.ts | 57 +++++ backend/src/index.ts | 49 +++++ backend/src/middleware/auth.ts | 37 +++- backend/src/routes/auth.ts | 31 +-- backend/src/routes/settings.ts | 10 +- backend/src/routes/system.ts | 4 +- backend/src/services/WebhookService.ts | 83 ++++++- backend/src/utils/__tests__/secrets.test.ts | 83 +++++++ backend/src/utils/crypto.ts | 52 +++-- backend/src/utils/passwordPolicy.ts | 12 + backend/src/utils/secrets.ts | 232 ++++++++++++++++++++ docker-compose.ghcr.yml | 8 +- docker-compose.yml | 8 +- frontend/package.json | 2 +- frontend/src/hooks/useSocket.ts | 10 +- frontend/src/version.ts | 2 +- nginx/nginx.conf | 18 +- 20 files changed, 656 insertions(+), 70 deletions(-) create mode 100644 backend/src/db/reencryptCredentials.ts create mode 100644 backend/src/utils/__tests__/secrets.test.ts create mode 100644 backend/src/utils/passwordPolicy.ts create mode 100644 backend/src/utils/secrets.ts diff --git a/README.md b/README.md index e86ef8d..856ed02 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.16.8_Beta-blue) +![Version](https://img.shields.io/badge/version-0.16.10_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -448,8 +448,8 @@ All configuration is done via environment variables in `.env`: | Variable | Default | Description | |---|---|---| -| `JWT_SECRET` | `changeme_use_a_long_random_secret_at_least_32_chars` | Secret for signing JWT tokens. **Change this.** | -| `ENCRYPTION_KEY` | `changeme32byteslongencryptionkey` | 32-character key for encrypting device passwords at rest. **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` | *(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. | | `DB_PASSWORD` | `mikrotik_secure_pw` | PostgreSQL password | | `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) | | `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. -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. +**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. --- diff --git a/backend/Dockerfile b/backend/Dockerfile index c3ea130..a578647 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,8 +27,10 @@ RUN apk add --no-cache arp-scan libcap openssl \ # Copy built files COPY --from=builder /app/dist ./dist -# Create backups directory -RUN mkdir -p /app/backups +# Create writable data directories. Pre-creating them (owned by appuser after +# 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 RUN addgroup -S appgroup && adduser -S appuser -G appgroup diff --git a/backend/package.json b/backend/package.json index a19faa0..fd22951 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.8-beta", + "version": "0.16.10-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/reencryptCredentials.ts b/backend/src/db/reencryptCredentials.ts new file mode 100644 index 0000000..bf2d455 --- /dev/null +++ b/backend/src/db/reencryptCredentials.ts @@ -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 { + let rewritten = 0; + + for (const table of TABLES) { + const rows = await query( + `SELECT id, ${COLUMNS.join(', ')} FROM ${table}` + ).catch(() => [] as EncryptedRow[]); + + for (const row of rows) { + const updates: Record = {}; + 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; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 43f4f82..a5af0a7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -25,6 +25,9 @@ import { } from './services/DeviceBulkAddWorker'; import { netflowCollector } from './services/netflow/NetflowCollector'; 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 { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins'; @@ -59,6 +62,27 @@ import credentialPresetsRoutes from './routes/credentialPresets'; import systemRoutes from './routes/system'; 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(); // 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); @@ -71,6 +95,19 @@ const io = new SocketServer(httpServer, { 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) => { console.log(`Socket connected: ${socket.id}`); socket.on('disconnect', () => { @@ -249,6 +286,10 @@ if (process.env.NODE_ENV !== 'test') { } 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 ───────────────────────────────────────────────────────────── app.get('/api/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); @@ -288,6 +329,8 @@ app.use(errorHandler); // ─── Startup ───────────────────────────────────────────────────────────────── async function start(): Promise { + provisionSecrets(); + // Wait for DB to be ready for (let i = 0; i < 10; i++) { try { @@ -303,6 +346,12 @@ async function start(): Promise { // Run migrations 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, // so they get re-resolved by the new local OUI database. await query(`UPDATE clients SET vendor = NULL WHERE vendor = ''`).catch(() => {}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index af4083f..b9cc989 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -2,6 +2,7 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { createHash } from 'crypto'; import { query } from '../config/database'; +import { jwtSigningSecret, jwtVerifierSecrets } from '../utils/secrets'; export interface AuthPayload { userId: number; @@ -20,14 +21,42 @@ declare global { } } -const JWT_SECRET = process.env.JWT_SECRET || 'changeme'; - 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 { - 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(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 diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 36703a3..b856645 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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`, diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index f669b02..08a0620 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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]); diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts index b954c70..e30c9fa 100644 --- a/backend/src/routes/system.ts +++ b/backend/src/routes/system.ts @@ -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'; diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index 94532bd..1366be7 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -5,9 +5,63 @@ import { createHmac } from 'crypto'; import * as https from 'https'; import * as http from 'http'; +import * as dns from 'dns'; +import { isIP } from 'net'; import { URL } from 'url'; 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 { + // 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 = [ 'device_offline', 'device_online', 'log_error', 'log_warning', 'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered', @@ -21,15 +75,30 @@ interface WebhookRow { events: string[]; enabled: boolean; } -function postJson(urlStr: string, body: string, headers: Record): Promise { +async function postJson(urlStr: string, body: string, headers: Record): Promise { + 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) => { - let u: URL; - try { u = new URL(urlStr); } catch { reject(new Error('Invalid URL')); return; } - if (u.protocol !== 'http:' && u.protocol !== 'https:') { reject(new Error('URL must be http(s)')); return; } - const lib = u.protocol === 'https:' ? https : http; - const req = lib.request(u, { + const lib = isHttps ? https : http; + const req = lib.request({ + host: pinnedAddress, // connect to the validated IP, not a re-resolved host + port, + path: `${u.pathname}${u.search}`, 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, }, (res) => { res.resume(); // drain diff --git a/backend/src/utils/__tests__/secrets.test.ts b/backend/src/utils/__tests__/secrets.test.ts new file mode 100644 index 0000000..d499e6a --- /dev/null +++ b/backend/src/utils/__tests__/secrets.test.ts @@ -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 + }); +}); diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 58fdfef..626bb8b 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -7,22 +7,13 @@ * "Credential encryption (ENCRYPTION_KEY)". */ import * as crypto from 'crypto'; +import { encryptionKey, decryptionKeys } from './secrets'; const ALGORITHM = 'aes-256-gcm'; 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 { - const key = getKey(); + const key = encryptionKey(); const iv = crypto.randomBytes(IV_LENGTH); 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(':'); } +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 { - const key = getKey(); const parts = encryptedText.split(':'); if (parts.length !== 3) { throw new Error('Invalid encrypted text format'); } - const iv = Buffer.from(parts[0], 'hex'); const tag = Buffer.from(parts[1], 'hex'); const encrypted = Buffer.from(parts[2], 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(tag); - - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - return decrypted.toString('utf8'); + // Try the current key first, then every legacy key, so ciphertext written + // under an older (or default) key keeps decrypting after a key rotation. + let lastErr: unknown; + for (const key of decryptionKeys()) { + 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; + } } diff --git a/backend/src/utils/passwordPolicy.ts b/backend/src/utils/passwordPolicy.ts new file mode 100644 index 0000000..9b77215 --- /dev/null +++ b/backend/src/utils/passwordPolicy.ts @@ -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; +} diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts new file mode 100644 index 0000000..73f0f0a --- /dev/null +++ b/backend/src/utils/secrets.ts @@ -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(); + 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; +} diff --git a/docker-compose.ghcr.yml b/docker-compose.ghcr.yml index 4de8a5a..5c8ef82 100644 --- a/docker-compose.ghcr.yml +++ b/docker-compose.ghcr.yml @@ -29,8 +29,10 @@ services: INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789} INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} - JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} + # Leave unset to auto-generate & persist strong secrets on first boot + # (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} PORT: 3001 depends_on: @@ -43,6 +45,7 @@ services: volumes: - backups_data:/app/backups - certs_data:/certs + - app_data:/app/data cap_add: - NET_RAW restart: unless-stopped @@ -114,6 +117,7 @@ volumes: influxdb_data: backups_data: certs_data: + app_data: networks: app-network: diff --git a/docker-compose.yml b/docker-compose.yml index 95053bc..6fe9683 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,10 @@ services: INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-mytoken123456789} INFLUXDB_ORG: ${INFLUXDB_ORG:-mikrotik-manager} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-metrics} - JWT_SECRET: ${JWT_SECRET:-changeme_use_a_long_random_secret} - ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey} + # Leave unset to auto-generate & persist strong secrets on first boot + # (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} PORT: 3001 NETFLOW_BIND_PORT: 2055 @@ -49,6 +51,7 @@ services: volumes: - backups_data:/app/backups - certs_data:/certs + - app_data:/app/data cap_add: - NET_RAW restart: unless-stopped @@ -121,6 +124,7 @@ volumes: influxdb_data: backups_data: certs_data: + app_data: networks: app-network: diff --git a/frontend/package.json b/frontend/package.json index 408a0f9..ee6da2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.8-beta", + "version": "0.16.10-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index 588997a..9edbec7 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -16,11 +16,19 @@ export function useSocket( useLayoutEffect(() => { eventsRef.current = events; }); 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) { globalSocket = io('/', { path: '/socket.io', + auth: { token: useAuthStore.getState().token }, transports: ['websocket'], reconnection: true, reconnectionDelay: 2000, diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 8998c8f..c0ee880 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.16.8 Beta'; +export const APP_VERSION = 'v0.16.10 Beta'; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 5f7daf9..22e1a4c 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -19,6 +19,15 @@ server { ssl_session_cache shared:SSL:10m; 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 on; gzip_vary on; @@ -56,10 +65,17 @@ server { 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 / { try_files $uri $uri/ /index.html; 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