fix(api): CORS через isReturnToAllowed, мягкая проверка JWT_SECRET, 404 для /assets, better-sqlite3 в api, healthcheck на /ready

This commit is contained in:
Denozordec committed 2026-10-23 17:13:16 +07:00
1 parent 5cbf4d29d7
commit 08be310e6b
12 files changed
+93 -91

No files matched your search

+1
View File
@@ -22,6 +22,7 @@
"@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",
"fastify": "^5.4.0",
"jose": "^6.2.8",
+1 -1
View File
@@ -50,7 +50,7 @@ import {
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known']
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
+9 -3
View File
@@ -65,12 +65,18 @@ const WEAK_JWT_SECRETS = new Set([
'changeme',
])
/** В production запрещены известные/слабые значения JWT_SECRET. */
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret) || config.jwtSecret.length < 16) {
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
throw new Error(
'JWT_SECRET: в production нужен уникальный секрет длиной не менее 16 символов',
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
)
}
if (config.jwtSecret.length < 16) {
// Не роняем старые установки с коротким, но уникальным секретом.
process.emitWarning(
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
)
}
}
+5 -12
View File
@@ -1,3 +1,4 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
@@ -11,8 +12,8 @@ function originOf(url: string): string | null {
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и хосты из RETURN_TO_ALLOWLIST
* (".suffix" — суффикс хоста, "localhost", точные хосты). '*' в CORS_ORIGINS —
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
@@ -34,21 +35,13 @@ export function createOriginChecker(
const o = originOf(u)
if (o) explicit.add(o)
}
const tokens = config.returnToAllowlist
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean)
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
const host = new URL(o).hostname.toLowerCase()
for (const t of tokens) {
if (t === 'private') continue
if (t.startsWith('.') ? host.endsWith(t) : host === t) return true
}
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
+4 -4
View File
@@ -216,10 +216,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
app.get('/api/v1/app-switcher', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=30')
return publicAppSwitcherConfig(getAppSwitcherConfig(app.db))
})
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
)
app.get(
'/api/v1/auth/me',
+16 -4
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
@@ -52,8 +52,8 @@ describe('loadConfig', () => {
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me', 'short-secret'])(
'слабый JWT_SECRET "%s" отклоняется',
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
@@ -61,13 +61,25 @@ describe('production guard', () => {
},
)
it('нормальный секрет в production принимается; в test слабый разрешён', () => {
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
+40 -5
View File
@@ -35,11 +35,11 @@ describe('статика', () => {
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → не отдаёт index.html как JS (404 JSON или html, но не 200 js)', async () => {
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
// SPA-fallback допустим только как html; кэшироваться как immutable он не должен
expect(res.headers['cache-control']).not.toContain('immutable')
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
@@ -57,10 +57,10 @@ describe('статика', () => {
})
describe('кэш-заголовки API', () => {
it('app-switcher и auth/config кэшируются коротко', async () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBe('public, max-age=30')
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
@@ -122,6 +122,41 @@ describe('CORS', () => {
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',