initial: server + shared

This commit is contained in:
admin
2026-06-11 13:41:38 +05:00
commit 65da047e7c
148 changed files with 15900 additions and 0 deletions
+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()
}
})
})
+259
View File
@@ -0,0 +1,259 @@
import Fastify from 'fastify'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { build403Html, registerIpGate } from '../ip-gate.js'
function buildApp() {
const app = Fastify({ logger: false, trustProxy: true })
app.get('/test', async () => ({ ok: true }))
app.get('/api/webhooks/yookassa', async () => ({ ok: true }))
app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true }))
app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true }))
app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true }))
return app
}
describe('registerIpGate', () => {
let app
const originalIps = process.env.SITE_ACCESS_IPS
beforeEach(async () => {
app = buildApp()
await registerIpGate(app)
await app.ready()
})
afterEach(async () => {
await app.close()
if (originalIps === undefined) {
delete process.env.SITE_ACCESS_IPS
} else {
process.env.SITE_ACCESS_IPS = originalIps
}
})
it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => {
delete process.env.SITE_ACCESS_IPS
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true })
})
it('пропускает запрос с разрешённого IP', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' })
expect(res.statusCode).toBe(200)
})
it('пропускает запрос с IPv6-mapped разрешённого IP', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' })
expect(res.statusCode).toBe(200)
})
it('блокирует запрос с неразрешённого IP (403)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' })
expect(res.statusCode).toBe(403)
expect(res.headers['content-type']).toMatch(/text\/html/)
expect(res.body).toContain('Любимый Креатив')
expect(res.body).toContain('9.9.9.9')
})
it('build403Html показывает "не определён" когда IP не передан', () => {
const html = build403Html()
expect(html).toContain('не определён')
expect(html).toContain('Любимый Креатив')
})
it('build403Html показывает переданный IP', () => {
const html = build403Html('9.9.9.9')
expect(html).toContain('9.9.9.9')
expect(html).not.toContain('не определён')
})
it('build403Html с пустой строкой показывает "не определён"', () => {
const html = build403Html('')
expect(html).toContain('не определён')
})
it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({ method: 'GET', url: '/test' })
expect(res.statusCode).toBe(403)
expect(res.body).toContain('127.0.0.1')
})
it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (vk callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/vk/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (yandex callback)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/auth/oauth/yandex/callback',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает исключённые пути с любым IP (telegram webhook)', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/admin/notifications/telegram/webhook',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('корректно тримит пробелы в списке IP', async () => {
process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '5.6.7.8',
})
expect(res.statusCode).toBe(200)
})
it('нормализует IPv6-mapped адреса в whitelist', async () => {
process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '1.2.3.4',
})
expect(res.statusCode).toBe(200)
})
it('пропускает если после трима список IP пуст', async () => {
process.env.SITE_ACCESS_IPS = ' , , '
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('путь с query-параметрами проверяется корректно', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/test?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(403)
})
it('исключённый путь с query-параметрами тоже пропускается', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4'
const res = await app.inject({
method: 'GET',
url: '/api/webhooks/yookassa?foo=bar',
remoteAddress: '9.9.9.9',
})
expect(res.statusCode).toBe(200)
})
it('пропускает запрос с IP в CIDR-диапазоне /24', async () => {
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '192.168.1.100',
})
expect(res.statusCode).toBe(200)
})
it('блокирует запрос с IP вне CIDR-диапазона', async () => {
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '10.0.0.1',
})
expect(res.statusCode).toBe(403)
})
it('пропускает IP в CIDR /32 (эквивалент одного IP)', async () => {
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '10.0.0.5',
})
expect(res.statusCode).toBe(200)
})
it('блокирует IP рядом с CIDR /32', async () => {
process.env.SITE_ACCESS_IPS = '10.0.0.5/32'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '10.0.0.6',
})
expect(res.statusCode).toBe(403)
})
it('пропускает любой IP в CIDR /0', async () => {
process.env.SITE_ACCESS_IPS = '0.0.0.0/0'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '1.2.3.4',
})
expect(res.statusCode).toBe(200)
})
it('поддерживает микс точных IP и CIDR-диапазонов', async () => {
process.env.SITE_ACCESS_IPS = '1.2.3.4,10.0.0.0/24'
const res1 = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '1.2.3.4',
})
expect(res1.statusCode).toBe(200)
const res2 = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '10.0.0.50',
})
expect(res2.statusCode).toBe(200)
const res3 = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '9.9.9.9',
})
expect(res3.statusCode).toBe(403)
})
it('IPv6-mapped адрес в CIDR-диапазоне', async () => {
process.env.SITE_ACCESS_IPS = '192.168.1.0/24'
const res = await app.inject({
method: 'GET',
url: '/test',
remoteAddress: '::ffff:192.168.1.50',
})
expect(res.statusCode).toBe(200)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { normalizeIp, cidrMatch } from './ip-gate.js'
export function registerAuth(fastify) {
function normalizeEmail(email) {
return String(email || '')
.trim()
.toLowerCase()
}
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!adminEmail || !adminEmail.includes('@')) {
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) {
request.log.error({ err }, '[auth] verifyAdmin failed')
return reply.code(401).send({ error: 'Не авторизован' })
}
const userEmail = normalizeEmail(request.user?.email)
if (userEmail !== adminEmail) {
return reply.code(403).send({ error: 'Недостаточно прав' })
}
})
}
+132
View File
@@ -0,0 +1,132 @@
const EXCLUDED_PATHS = [
'/api/auth/oauth/vk/callback',
'/api/auth/oauth/yandex/callback',
'/api/webhooks/yookassa',
'/api/admin/notifications/telegram/webhook',
]
export function normalizeIp(ip) {
if (ip && ip.startsWith('::ffff:')) {
return ip.slice(7)
}
return ip
}
export function ipToInt(ip) {
const parts = ip.split('.')
if (parts.length !== 4) return null
return parts.reduce((acc, octet) => {
const num = parseInt(octet, 10)
if (isNaN(num) || num < 0 || num > 255) return null
return acc !== null ? (acc << 8) + num : null
}, 0)
}
export function cidrMatch(ip, cidr) {
const slashIdx = cidr.indexOf('/')
if (slashIdx === -1) return false
const baseIp = cidr.slice(0, slashIdx)
const prefix = parseInt(cidr.slice(slashIdx + 1), 10)
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false
const ipInt = ipToInt(normalizeIp(ip))
const baseInt = ipToInt(normalizeIp(baseIp))
if (ipInt === null || baseInt === null) return false
const mask = prefix === 0 ? 0 : ~(2 ** (32 - prefix) - 1) >>> 0
return (ipInt & mask) === (baseInt & mask)
}
export function build403Html(ip) {
const safeIp = ip || 'не определён'
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Любимый Креатив</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #faf8f5;
color: #3d322b;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
.card {
max-width: 520px;
width: 100%;
background: #fff;
border: 1px solid #e8e0d8;
border-radius: 16px;
padding: 48px 40px;
text-align: center;
box-shadow: 0 2px 16px rgb(0 0 0 / 4%);
}
.card h1 {
font-size: 24px;
font-weight: 600;
letter-spacing: -0.3px;
color: #4a3a2e;
margin-bottom: 8px;
}
.card .tagline {
font-size: 14px;
color: #8c8177;
margin-bottom: 32px;
line-height: 1.5;
}
.card .status {
font-size: 16px;
color: #6b5e52;
margin-bottom: 24px;
line-height: 1.6;
}
.card .ip {
font-size: 12px;
color: #b8a99b;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
</style>
</head>
<body>
<div class="card">
<h1>Любимый Креатив</h1>
<p class="tagline">Изделия ручной работы: вещи с характером и вниманием к деталям</p>
<p class="status">Сайт находится в разработке и скоро будет доступен</p>
<p class="ip">Ваш IP: ${safeIp}</p>
</div>
</body>
</html>`
}
export async function registerIpGate(fastify) {
fastify.addHook('onRequest', async (request, reply) => {
const allowed = process.env.SITE_ACCESS_IPS
if (!allowed) return
const allowedIps = allowed
.split(',')
.map((s) => normalizeIp(s.trim()))
.filter(Boolean)
if (allowedIps.length === 0) return
const urlPath = request.url.split('?')[0]
if (EXCLUDED_PATHS.includes(urlPath)) return
const normalizedIp = normalizeIp(request.ip)
if (allowedIps.includes(normalizedIp)) return
const isInCidr = allowedIps.some((entry) => cidrMatch(normalizedIp, entry))
if (isInCidr) return
return reply.code(403).type('text/html').send(build403Html(request.ip))
})
}
+24
View File
@@ -0,0 +1,24 @@
export async function registerSecurityHeaders(fastify) {
fastify.addHook('onSend', async (request, reply) => {
reply.header('X-Content-Type-Options', 'nosniff')
reply.header('X-Frame-Options', 'DENY')
reply.header('X-XSS-Protection', '0')
reply.header('Referrer-Policy', 'strict-origin-when-cross-origin')
reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
const cspDirectives = [
"default-src 'self'",
"script-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob: https://tile.openstreetmap.org https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://*.yookassa.ru https://*.vk.com https://oauth.yandex.ru",
'frame-src https://*.yookassa.ru',
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
reply.header('Content-Security-Policy', cspDirectives)
})
}