fix(api): защита секретов в production, redact логов, bootstrap без полного скана users

This commit is contained in:
Denozordec committed 2026-10-23 17:03:45 +07:00
1 parent 8cba7a5c41
commit 22dd6f9937
5 files changed
+202 -10

No files matched your search

+23 -7
View File
@@ -17,6 +17,8 @@ import {
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
@@ -26,7 +28,11 @@ import {
} from '@authportal/db'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import { parseTrustProxy, type AppConfig } from './config.js'
import {
assertBootstrapPassword,
parseTrustProxy,
type AppConfig,
} from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
@@ -56,9 +62,9 @@ declare module 'fastify' {
}
}
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
const now = new Date().toISOString()
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
@@ -82,8 +88,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
}
// Ensure existing admins have app tiles if access was never set
for (const user of existing) {
if (!user.isAdmin) continue
for (const user of listAdmins(app.db)) {
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
@@ -109,7 +114,18 @@ export async function buildApp(opts: {
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
logger: {
level: config.logLevel,
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
],
censor: '[redacted]',
},
},
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
+30 -1
View File
@@ -58,6 +58,33 @@ export const configSchema = z.object({
export type AppConfig = z.infer<typeof configSchema>
const WEAK_JWT_SECRETS = new Set([
'change-me-in-production',
'dev-secret-change-me',
'secret',
'changeme',
])
/** В production запрещены известные/слабые значения JWT_SECRET. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret) || config.jwtSecret.length < 16) {
throw new Error(
'JWT_SECRET: в production нужен уникальный секрет длиной не менее 16 символов',
)
}
}
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
export function assertBootstrapPassword(config: AppConfig): void {
if (!config.isProd) return
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
throw new Error(
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
)
}
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
@@ -67,7 +94,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({
const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
@@ -99,6 +126,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(