diff --git a/server/.env.example b/server/.env.example index ee5c05c..78f725a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -20,6 +20,9 @@ JWT_SECRET=замените-на-секрет-jwt # Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена. # SITE_ACCESS_IPS=1.2.3.4,5.6.7.8,192.168.1.0/24 +# Ограничение доступа к админ-роутам по IP (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена. +# ADMIN_ACCESS_IPS=1.2.3.4,10.0.0.0/24 + # Публичные URL для OAuth redirect (локально обычно так): SERVER_PUBLIC_URL=http://127.0.0.1:3333 CLIENT_PUBLIC_URL=http://127.0.0.1:5173 diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index eb83ee9..abbf38c 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/plugins/__tests__/auth.test.js b/server/src/plugins/__tests__/auth.test.js new file mode 100644 index 0000000..b1a21a8 --- /dev/null +++ b/server/src/plugins/__tests__/auth.test.js @@ -0,0 +1,296 @@ +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { registerAuth } from '../auth.js' + +const JWT_SECRET = 'test-secret' +const ADMIN_EMAIL = 'admin@test.com' + +async function buildApp() { + const app = Fastify({ logger: false, trustProxy: true }) + await app.register(jwt, { secret: JWT_SECRET }) + registerAuth(app) + app.get('/admin/test', { preHandler: [app.verifyAdmin] }, async () => ({ ok: true })) + await app.ready() + return app +} + +async function signToken(app, email) { + return app.jwt.sign({ sub: 'test-user-id', email }) +} + +describe('verifyAdmin — ADMIN_ACCESS_IPS', () => { + const originalIps = process.env.ADMIN_ACCESS_IPS + const originalEmail = process.env.ADMIN_EMAIL + + beforeEach(() => { + process.env.ADMIN_EMAIL = ADMIN_EMAIL + delete process.env.ADMIN_ACCESS_IPS + }) + + afterEach(async () => { + if (originalIps === undefined) { + delete process.env.ADMIN_ACCESS_IPS + } else { + process.env.ADMIN_ACCESS_IPS = originalIps + } + if (originalEmail === undefined) { + delete process.env.ADMIN_EMAIL + } else { + process.env.ADMIN_EMAIL = originalEmail + } + }) + + it('пропускает если ADMIN_ACCESS_IPS не задан (IP не проверяется)', async () => { + delete process.env.ADMIN_ACCESS_IPS + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + } finally { + await app.close() + } + }) + + it('пропускает если ADMIN_ACCESS_IPS пустой после трима', async () => { + process.env.ADMIN_ACCESS_IPS = ' , , ' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('пропускает с разрешённого IP', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4,5.6.7.8' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '1.2.3.4', + }) + // IP passes, JWT and email match → 200 + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('пропускает с IPv6-mapped разрешённого IP', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '::ffff:1.2.3.4', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('блокирует с неразрешённого IP (403 JSON)', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4' + const app = await buildApp() + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '9.9.9.9', + }) + // IP not allowed — 403 even before JWT check + expect(res.statusCode).toBe(403) + const body = res.json() + expect(body.error).toBe('Доступ с данного IP запрещён') + } finally { + await app.close() + } + }) + + it('тримит пробелы в списке IP', async () => { + process.env.ADMIN_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 ' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '5.6.7.8', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('нормализует IPv6-mapped адреса в whitelist', async () => { + process.env.ADMIN_ACCESS_IPS = '::ffff:1.2.3.4' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '1.2.3.4', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('пропускает запрос с IP в CIDR-диапазоне /24', async () => { + process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '192.168.1.100', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('блокирует запрос с IP вне CIDR-диапазона', async () => { + process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24' + const app = await buildApp() + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '10.0.0.1', + }) + expect(res.statusCode).toBe(403) + } finally { + await app.close() + } + }) + + it('поддерживает микс точных IP и CIDR-диапазонов', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4,10.0.0.0/24' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res1 = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '1.2.3.4', + }) + expect(res1.statusCode).toBe(200) + + const res2 = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '10.0.0.50', + }) + expect(res2.statusCode).toBe(200) + + const res3 = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '9.9.9.9', + }) + expect(res3.statusCode).toBe(403) + } finally { + await app.close() + } + }) + + it('IPv6-mapped адрес в CIDR-диапазоне пропускается', async () => { + process.env.ADMIN_ACCESS_IPS = '192.168.1.0/24' + const app = await buildApp() + const token = await signToken(app, ADMIN_EMAIL) + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + headers: { authorization: `Bearer ${token}` }, + remoteAddress: '::ffff:192.168.1.50', + }) + expect(res.statusCode).toBe(200) + } finally { + await app.close() + } + }) + + it('IP-проверка происходит до JWT (неразрешённый IP → 403, а не 401)', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4' + const app = await buildApp() + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '9.9.9.9', + }) + // Should be 403 from IP check, NOT 401 from missing JWT + expect(res.statusCode).toBe(403) + expect(res.json().error).toBe('Доступ с данного IP запрещён') + } finally { + await app.close() + } + }) + + it('после прохождения IP-проверки всё ещё нужен JWT (разрешённый IP, нет токена → 401)', async () => { + process.env.ADMIN_ACCESS_IPS = '1.2.3.4' + const app = await buildApp() + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '1.2.3.4', + }) + // IP passes, but no JWT → 401 + expect(res.statusCode).toBe(401) + } finally { + await app.close() + } + }) + + it('ADMIN_EMAIL не задан → 503, IP не проверяется', async () => { + delete process.env.ADMIN_EMAIL + process.env.ADMIN_ACCESS_IPS = '1.2.3.4' + const app = await buildApp() + try { + const res = await app.inject({ + method: 'GET', + url: '/admin/test', + remoteAddress: '1.2.3.4', + }) + expect(res.statusCode).toBe(503) + expect(res.json().error).toBe('ADMIN_EMAIL не задан в .env') + } finally { + await app.close() + } + }) +}) diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index 794e925..4fe9231 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -1,3 +1,5 @@ +import { normalizeIp, cidrMatch } from './ip-gate.js' + export function registerAuth(fastify) { function normalizeEmail(email) { return String(email || '') @@ -11,6 +13,22 @@ export function registerAuth(fastify) { return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' }) } + const adminIps = process.env.ADMIN_ACCESS_IPS + if (adminIps) { + const allowedList = adminIps + .split(',') + .map((s) => normalizeIp(s.trim())) + .filter(Boolean) + + if (allowedList.length > 0) { + const reqIp = normalizeIp(request.ip) + const isAllowed = allowedList.includes(reqIp) || allowedList.some((entry) => cidrMatch(reqIp, entry)) + if (!isAllowed) { + return reply.code(403).send({ error: 'Доступ с данного IP запрещён' }) + } + } + } + try { await request.jwtVerify() } catch (err) { diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js index 808b83e..735d985 100644 --- a/server/src/plugins/ip-gate.js +++ b/server/src/plugins/ip-gate.js @@ -5,14 +5,14 @@ const EXCLUDED_PATHS = [ '/api/admin/notifications/telegram/webhook', ] -function normalizeIp(ip) { +export function normalizeIp(ip) { if (ip && ip.startsWith('::ffff:')) { return ip.slice(7) } return ip } -function ipToInt(ip) { +export function ipToInt(ip) { const parts = ip.split('.') if (parts.length !== 4) return null return parts.reduce((acc, octet) => { @@ -22,7 +22,7 @@ function ipToInt(ip) { }, 0) } -function cidrMatch(ip, cidr) { +export function cidrMatch(ip, cidr) { const slashIdx = cidr.indexOf('/') if (slashIdx === -1) return false