perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy

This commit is contained in:
Denozordec committed 2026-10-23 17:22:23 +07:00
1 parent 08be310e6b
commit 1a68597d48
31 files changed
+1313 -158

No files matched your search

+6 -5
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1",
@@ -22,22 +23,22 @@
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^11.10.0",
"@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@types/node": "^22.15.32",
"@vitest/coverage-v8": "4.1.10",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+26 -2
View File
@@ -1,5 +1,6 @@
import type { FastifyError, FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
@@ -51,6 +52,9 @@ import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
function boolFlag(v: string): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
@@ -167,14 +171,34 @@ export async function buildApp(opts: {
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
if (boolFlag(config.enableCompress)) {
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
}
await app.register(formbody)
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(helmet, {
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
await app.register(rateLimit, {
max: 200,
max: config.rateLimitMax,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
+6
View File
@@ -39,6 +39,10 @@ export const configSchema = z.object({
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
enableCompress: z.string().default('false'),
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
rateLimitMax: z.coerce.number().int().positive().default(200),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
@@ -121,6 +125,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
+19
View File
@@ -36,6 +36,18 @@ describe('Dockerfile', () => {
expect(dockerfile).not.toMatch(/gzip -k/)
})
it('preview-маршруты дизайна вырезаются до сборки web', () => {
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
const build = dockerfile.indexOf('pnpm turbo build')
expect(rm).toBeGreaterThan(0)
expect(rm).toBeLessThan(build)
})
it('базовый образ закреплён по digest', () => {
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
@@ -59,6 +71,13 @@ describe.each(Object.entries(composes))('%s', (_name, text) => {
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
+52
View File
@@ -178,3 +178,55 @@ describe('CORS', () => {
expect(all('https://anything.example')).toBe(true)
})
})
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
async function withBigRoute(env: NodeJS.ProcessEnv) {
app = await buildTestApp(env)
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
app.get('/__small', async () => ({ ok: true }))
return app
}
it('по умолчанию выключено', async () => {
const a = await withBigRoute({})
const res = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(res.headers['content-encoding']).toBeUndefined()
})
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
const big = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(big.headers['content-encoding']).toBe('gzip')
expect(big.rawPayload.length).toBeLessThan(500)
const small = await a.inject({
method: 'GET',
url: '/__small',
headers: { 'accept-encoding': 'gzip' },
})
expect(small.headers['content-encoding']).toBeUndefined()
const none = await a.inject({ method: 'GET', url: '/__big' })
expect(none.headers['content-encoding']).toBeUndefined()
})
});
describe('CSP', () => {
it('отдаётся жёсткая политика без внешних источников', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/health' })
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
expect(csp).not.toContain('http:')
})
})