fix(api): защита секретов в production, redact логов, bootstrap без полного скана users
This commit is contained in:
1 parent
8cba7a5c41
commit
22dd6f9937
5 files changed
+202
-10
No files matched your search
+23
-7
@@ -17,6 +17,8 @@ import {
|
|||||||
createDb,
|
createDb,
|
||||||
getAppSwitcherConfig,
|
getAppSwitcherConfig,
|
||||||
getUserApps,
|
getUserApps,
|
||||||
|
hasAnyUser,
|
||||||
|
listAdmins,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
migrateSchema,
|
migrateSchema,
|
||||||
setUserAccess,
|
setUserAccess,
|
||||||
@@ -26,7 +28,11 @@ import {
|
|||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
|
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
|
||||||
import { randomUUID } from 'node:crypto'
|
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 { authRoutes } from './routes/auth.js'
|
||||||
import { adminRoutes } from './routes/admin.js'
|
import { adminRoutes } from './routes/admin.js'
|
||||||
import { auditAdminRoutes } from './routes/audit.js'
|
import { auditAdminRoutes } from './routes/audit.js'
|
||||||
@@ -56,9 +62,9 @@ declare module 'fastify' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
||||||
const existing = app.db.select().from(users).all()
|
if (!hasAnyUser(app.db)) {
|
||||||
if (existing.length === 0) {
|
assertBootstrapPassword(app.config)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const passwordHash = await hashPassword(app.config.adminPassword)
|
const passwordHash = await hashPassword(app.config.adminPassword)
|
||||||
const id = randomUUID()
|
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
|
// Ensure existing admins have app tiles if access was never set
|
||||||
for (const user of existing) {
|
for (const user of listAdmins(app.db)) {
|
||||||
if (!user.isAdmin) continue
|
|
||||||
const apps = getUserApps(app.db, user.id)
|
const apps = getUserApps(app.db, user.id)
|
||||||
if (apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
|
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
|
||||||
@@ -109,7 +114,18 @@ export async function buildApp(opts: {
|
|||||||
migrateSchema(sqlite)
|
migrateSchema(sqlite)
|
||||||
|
|
||||||
const app = Fastify({
|
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),
|
trustProxy: parseTrustProxy(config.trustProxy),
|
||||||
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||||
keepAliveTimeout: 65_000,
|
keepAliveTimeout: 65_000,
|
||||||
|
|||||||
+30
-1
@@ -58,6 +58,33 @@ export const configSchema = z.object({
|
|||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>
|
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 {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
const isProd = env.NODE_ENV === 'production'
|
const isProd = env.NODE_ENV === 'production'
|
||||||
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
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 issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||||
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||||
|
|
||||||
return configSchema.parse({
|
const config = configSchema.parse({
|
||||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
|
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',
|
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
|
||||||
webauthnOrigins: origins,
|
webauthnOrigins: origins,
|
||||||
})
|
})
|
||||||
|
assertProductionSecrets(config)
|
||||||
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
function webauthnFromIssuer(
|
function webauthnFromIssuer(
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { Writable } from 'node:stream'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
|
||||||
|
import { buildApp } from '../src/app.js'
|
||||||
|
|
||||||
|
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('значения по умолчанию для новых параметров', () => {
|
||||||
|
const c = loadConfig({ ...base, NODE_ENV: 'test' })
|
||||||
|
expect(c.shutdownTimeoutMs).toBe(8000)
|
||||||
|
expect(c.sessionRetentionDays).toBe(7)
|
||||||
|
expect(c.argon2Concurrency).toBe(2)
|
||||||
|
expect(c.backupKeep).toBe(7)
|
||||||
|
expect(c.backupIntervalHours).toBe(0)
|
||||||
|
expect(c.corsOrigins).toBe('')
|
||||||
|
expect(c.trustProxy).toContain('10.0.0.0/8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('читает переопределения из env', () => {
|
||||||
|
const c = loadConfig({
|
||||||
|
...base,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
SHUTDOWN_TIMEOUT_MS: '1500',
|
||||||
|
SESSION_RETENTION_DAYS: '30',
|
||||||
|
ARGON2_CONCURRENCY: '4',
|
||||||
|
BACKUP_KEEP: '3',
|
||||||
|
BACKUP_INTERVAL_HOURS: '12',
|
||||||
|
CORS_ORIGINS: 'https://a.example',
|
||||||
|
TRUST_PROXY: 'false',
|
||||||
|
})
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
shutdownTimeoutMs: 1500,
|
||||||
|
sessionRetentionDays: 30,
|
||||||
|
argon2Concurrency: 4,
|
||||||
|
backupKeep: 3,
|
||||||
|
backupIntervalHours: 12,
|
||||||
|
corsOrigins: 'https://a.example',
|
||||||
|
trustProxy: 'false',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['SHUTDOWN_TIMEOUT_MS', '0'],
|
||||||
|
['ARGON2_CONCURRENCY', '-1'],
|
||||||
|
['BACKUP_KEEP', 'abc'],
|
||||||
|
['BACKUP_INTERVAL_HOURS', '-5'],
|
||||||
|
])('невалидный %s=%s → ошибка', (key, value) => {
|
||||||
|
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('production guard', () => {
|
||||||
|
it.each(['change-me-in-production', 'dev-secret-change-me', 'short-secret'])(
|
||||||
|
'слабый JWT_SECRET "%s" отклоняется',
|
||||||
|
(secret) => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
|
||||||
|
).toThrow(/JWT_SECRET/)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('нормальный секрет в production принимается; в test слабый разрешён', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
|
||||||
|
).not.toThrow()
|
||||||
|
expect(() =>
|
||||||
|
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('пароль первого админа: слабый запрещён только в production', () => {
|
||||||
|
const prod = (pw?: string) =>
|
||||||
|
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
|
||||||
|
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
|
||||||
|
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
|
||||||
|
expect(() =>
|
||||||
|
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('buildApp в production без нормального пароля падает при создании админа', async () => {
|
||||||
|
await expect(
|
||||||
|
buildApp({
|
||||||
|
config: loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
...base,
|
||||||
|
ADMIN_PASSWORD: 'admin',
|
||||||
|
DATABASE_URL: 'sqlite::memory:',
|
||||||
|
}),
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/ADMIN_PASSWORD/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redact в логах', () => {
|
||||||
|
let app: ReturnType<typeof Fastify> | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authorization и cookie не попадают в лог', async () => {
|
||||||
|
const lines: string[] = []
|
||||||
|
const stream = new Writable({
|
||||||
|
write(chunk, _enc, cb) {
|
||||||
|
lines.push(String(chunk))
|
||||||
|
cb()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Тот же набор путей, что и в buildApp.
|
||||||
|
app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: 'info',
|
||||||
|
stream,
|
||||||
|
redact: {
|
||||||
|
paths: ['req.headers.authorization', 'req.headers.cookie'],
|
||||||
|
censor: '[redacted]',
|
||||||
|
},
|
||||||
|
serializers: {
|
||||||
|
req: (req) => ({ method: req.method, headers: req.headers }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
app.get('/x', async () => ({ ok: true }))
|
||||||
|
await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/x',
|
||||||
|
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
|
||||||
|
})
|
||||||
|
const joined = lines.join('')
|
||||||
|
expect(joined).toContain('[redacted]')
|
||||||
|
expect(joined).not.toContain('SECRET')
|
||||||
|
})
|
||||||
|
})
|
||||||
+2
-2
@@ -6,7 +6,7 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:/data/app.db
|
DATABASE_URL: sqlite:/data/app.db
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env (min 16 chars)}
|
||||||
JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1}
|
JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1}
|
||||||
REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14}
|
REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14}
|
||||||
ISSUER: ${ISSUER:-https://auth.shnt.top}
|
ISSUER: ${ISSUER:-https://auth.shnt.top}
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
# Опционально: PKCS8 PEM RSA. Иначе ключ генерируется и хранится в SQLite (/data).
|
# Опционально: PKCS8 PEM RSA. Иначе ключ генерируется и хранится в SQLite (/data).
|
||||||
OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-}
|
OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env (min 8 chars)}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||||
RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top,localhost,private}
|
RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top,localhost,private}
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export function listUsers(db: AppDb): UserRow[] {
|
|||||||
return db.select().from(users).all()
|
return db.select().from(users).all()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Есть ли хотя бы один пользователь (LIMIT 1 вместо загрузки всей таблицы). */
|
||||||
|
export function hasAnyUser(db: AppDb): boolean {
|
||||||
|
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdmins(db: AppDb): UserRow[] {
|
||||||
|
return db.select().from(users).where(eq(users.isAdmin, true)).all()
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserById(db: AppDb, id: string): UserRow | undefined {
|
export function getUserById(db: AppDb, id: string): UserRow | undefined {
|
||||||
return db.select().from(users).where(eq(users.id, id)).get()
|
return db.select().from(users).where(eq(users.id, id)).get()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user