From 08be310e6b4de4d2b82b3202087e91671140ec15 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 17:13:16 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20CORS=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20isReturnToAllowed,=20=D0=BC=D1=8F=D0=B3=D0=BA=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20JWT?= =?UTF-8?q?=5FSECRET,=20404=20=D0=B4=D0=BB=D1=8F=20/assets,=20better-sqlit?= =?UTF-8?q?e3=20=D0=B2=20api,=20healthcheck=20=D0=BD=D0=B0=20/ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 1 + apps/api/src/app.ts | 2 +- apps/api/src/config.ts | 12 +++-- apps/api/src/lib/cors.ts | 17 ++----- apps/api/src/routes/auth.ts | 8 ++-- apps/api/test/config.test.ts | 20 ++++++-- apps/api/test/http-layer.test.ts | 45 ++++++++++++++++-- deploy/docker-compose.traefik.yml | 2 +- deploy/docker/authportal/Dockerfile | 2 +- docker-compose.yml | 2 +- packages/db/src/index.ts | 2 + pnpm-lock.yaml | 71 +++++------------------------ 12 files changed, 93 insertions(+), 91 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 7cc791d..e5093e4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d3d639e..7285bca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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$/ diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 37b33da..6df2abb 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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 символов — смените на более длинный (смена разлогинит пользователей)', ) } } diff --git a/apps/api/src/lib/cors.ts b/apps/api/src/lib/cors.ts index 097551e..0bd5855 100644 --- a/apps/api/src/lib/cors.ts +++ b/apps/api/src/lib/cors.ts @@ -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 } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 9de692c..330a342 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -216,10 +216,10 @@ export async function authRoutes(app: FastifyInstance): Promise { ) /** 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', diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts index aaaf7c5..b679c16 100644 --- a/apps/api/test/config.test.ts +++ b/apps/api/test/config.test.ts @@ -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', () => { diff --git a/apps/api/test/http-layer.test.ts b/apps/api/test/http-layer.test.ts index 8423243..ff9dcb0 100644 --- a/apps/api/test/http-layer.test.ts +++ b/apps/api/test/http-layer.test.ts @@ -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('') }) 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', diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index 4750566..7d70b51 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -105,7 +105,7 @@ services: - traefik.http.middlewares.auth-portal-compress.compress=true - traefik.http.routers.auth-portal.middlewares=auth-portal-compress healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"] interval: 30s timeout: 5s retries: 3 diff --git a/deploy/docker/authportal/Dockerfile b/deploy/docker/authportal/Dockerfile index ea26725..3d978cd 100644 --- a/deploy/docker/authportal/Dockerfile +++ b/deploy/docker/authportal/Dockerfile @@ -69,5 +69,5 @@ STOPSIGNAL SIGTERM VOLUME ["/data"] # busybox wget вместо запуска второго процесса node каждые 30 с. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"] CMD ["node", "dist/server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 8af57ee..8bc0a4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: security_opt: - no-new-privileges:true healthcheck: - test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/health"] + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"] interval: 30s timeout: 5s retries: 3 diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9472029..d6311b3 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -36,6 +36,8 @@ export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } { } /** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */ +// ВАЖНО: любое изменение baseMigrate()/migrateV1() требует увеличить SCHEMA_VERSION +// (+ новая migrateVN), иначе существующие БД со скипом по user_version его не применят. export const SCHEMA_VERSION = 1 export function migrateSchema(sqlite: Sqlite): void { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c109489..02baa99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@simplewebauthn/server': specifier: ^13.3.2 version: 13.3.2 + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 fastify: specifier: ^5.4.0 version: 5.10.0 @@ -126,7 +129,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) apps/web: dependencies: @@ -302,7 +305,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages/shared: dependencies: @@ -6813,7 +6816,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/expect@4.1.10': dependencies: @@ -6824,21 +6827,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) - - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: @@ -9302,7 +9297,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): + vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -9311,20 +9306,6 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 - esbuild: 0.28.1 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.23.1 - - vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.19 - rolldown: 1.1.5 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.13.3 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 @@ -9344,10 +9325,10 @@ snapshots: jiti: 2.7.0 tsx: 4.23.1 - vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9364,7 +9345,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 @@ -9372,34 +9353,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.13.3 - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - transitivePeerDependencies: - - msw - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10