add diaposine

This commit is contained in:
Kirill
2026-06-03 13:16:57 +05:00
parent b7faf2d891
commit cc6ceac3a0
5 changed files with 320 additions and 3 deletions
+3
View File
@@ -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.
+296
View File
@@ -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()
}
})
})
+18
View File
@@ -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) {
+3 -3
View File
@@ -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