add diaposine
This commit is contained in:
@@ -20,6 +20,9 @@ JWT_SECRET=замените-на-секрет-jwt
|
|||||||
# Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
# Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена.
|
||||||
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8,192.168.1.0/24
|
# 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 (локально обычно так):
|
# Публичные URL для OAuth redirect (локально обычно так):
|
||||||
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
SERVER_PUBLIC_URL=http://127.0.0.1:3333
|
||||||
CLIENT_PUBLIC_URL=http://127.0.0.1:5173
|
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) {
|
export function registerAuth(fastify) {
|
||||||
function normalizeEmail(email) {
|
function normalizeEmail(email) {
|
||||||
return String(email || '')
|
return String(email || '')
|
||||||
@@ -11,6 +13,22 @@ export function registerAuth(fastify) {
|
|||||||
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
|
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 {
|
try {
|
||||||
await request.jwtVerify()
|
await request.jwtVerify()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ const EXCLUDED_PATHS = [
|
|||||||
'/api/admin/notifications/telegram/webhook',
|
'/api/admin/notifications/telegram/webhook',
|
||||||
]
|
]
|
||||||
|
|
||||||
function normalizeIp(ip) {
|
export function normalizeIp(ip) {
|
||||||
if (ip && ip.startsWith('::ffff:')) {
|
if (ip && ip.startsWith('::ffff:')) {
|
||||||
return ip.slice(7)
|
return ip.slice(7)
|
||||||
}
|
}
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipToInt(ip) {
|
export function ipToInt(ip) {
|
||||||
const parts = ip.split('.')
|
const parts = ip.split('.')
|
||||||
if (parts.length !== 4) return null
|
if (parts.length !== 4) return null
|
||||||
return parts.reduce((acc, octet) => {
|
return parts.reduce((acc, octet) => {
|
||||||
@@ -22,7 +22,7 @@ function ipToInt(ip) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cidrMatch(ip, cidr) {
|
export function cidrMatch(ip, cidr) {
|
||||||
const slashIdx = cidr.indexOf('/')
|
const slashIdx = cidr.indexOf('/')
|
||||||
if (slashIdx === -1) return false
|
if (slashIdx === -1) return false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user