add diaposine
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user