From cc6ceac3a06a6a7aec09310deeb7aa094c5c8342 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 3 Jun 2026 13:16:57 +0500 Subject: [PATCH] add diaposine --- server/.env.example | 3 + server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/plugins/__tests__/auth.test.js | 296 ++++++++++++++++++++++ server/src/plugins/auth.js | 18 ++ server/src/plugins/ip-gate.js | 6 +- 5 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 server/src/plugins/__tests__/auth.test.js 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 eb83ee91c6f93b97a57de7a1dc525344080c8254..abbf38c9638f82f6636739c1296b79e5d92e32e4 100644 GIT binary patch delta 4110 zcma)8du&tJ8Nb)}-Z*w*AA2;R222t{S+dOZ`t4hqU24)*SxE>ftOCZ88~Zwb#ElQ8=dLoCah<34kQi&}?W z&R}Y$o#*OYtj%e4*jx^mbw13u>eg51rHvF%9>1wD%=~Fr@4T+>7rf$qc8%X)WXLpxUW@D#_rmnDj^6yersto1Nv#c*7cmjlHJr(sm(lxwJD~Z(YXP zU7p`Ece(ittvC;6-oMg_KN)AtbB5Db>E;^g#+1aLcWcRa$$H82*9bjgtI6wiijP75 zS`*o#qYxplWXW!j!vVMYadMa~qrTy=*B^Fx;^c`Jb4wz2X_WMYWA|a|$2$Dz|1j~? zW}0UTOo6dcB+hE_JK`Bzr(p4nL^v#OS*d76Tq;>!AR9wm4$L(YH3z+6(IqkMR3mwk zUCZEa`TFhTA<$IRbvN1fWN%k@?^6SPPweY?vUiVUMn9a}MXr4eG4a$ox`V04gk&2K zNmb?k^3H`mvfy2?guVC=0GD#)CduF6bNuBakpKJ`*~Y46hl6wA+`K;aGXQ%4t%Oq_ zkoz|y7ls8vGTdwRq>~AoKN3up0)0t~ekgPwXyK*Hq=23JE_7cZ58|BwPhKGf8T=GL zKLq|83A`50-6R_~A)al>ZLxTY3t|}~5%ajkHk=WI!e~N)sK)&Do1}5DR?^Q6-1GYF z4glQ)iUEv20AEKt*`9NG^`PQYfIl1I^{B#l53xCglzrRa+@r{=68GXalwn~)p&O*t z->Sbw#(bg99jMXr4F*&o^c11u8Kw~2Av4m_j>}P!b%mS}Yt)Sjh+Iz&93=m~SV+3c zvL%J|D|tmy4>UAXg8Q(WF}RCMB$C6M0K{Ljw z6j6FFw{fN}0r=%EO08DW`ZX#^&xJmUF4SlUPHSD@gyy2=;{5LBJ!IZ5P&p+Ms5Inn z_^BIoZm>qhKifbwA@d87v_+6jEVe0?LWMgUH9t^M--9Z@kQS1HAjA`r$CdKh$Ef(- z$i$L_VIM9REG`6WZY(|Sum+uyi2HT}4EAYSUI~IsPvTm(f@$+&%y7WtNRGKeVkYf; zpvIBIouf+Qz;ZshEU!qK>R`DrUmWKni}TTu5d)!=-}azA1|wdqmsbS&cFt1{JZU7= zQZy!+VmN|PNftK}^=8Ga)jR4Nm3);Dlcv}hYGy%FHH*eybwak0Y*+O>;|7W^G@mLSDcLdy=1}=P3Ew9r)!!BJCR}{ZXZ@@S z57mY-`z6f&`(P5cI#gA3bvO{F9b?ABffSbUvPo6mFVbQ{ua(z@37XLR+H*y#AMBgod#1VfT1q8PWdN1(<1^i2Jx(jK9e_6Vx9cR8Ah`>Crl z8%z6zjIp>s;!|yaZ@x;4jT%|{kZ-6+o7*$hmfw7WzN){+1bdd=kH?fM4qsTL$yuN| zMbPzM*MO(A8O>t-*T{2M>fy~1<_}F^D`DVy=h_Bh@|jvGVPXkq#^YlrEp7EY0e=@} zHo;q6^j7%lB-05Ghne*-5@8nGP=cb1JigddxEN0bqkeYMx&>V<5#aOz+5l%G%+dVq zC{w31DIOu#5PK=`wD!2>eGOgjsvA+CRDGt>*1DC^nm^Xi%r-ho{Z{b_xr^94v%7h7 zzJG%Gn8XkCG;Dv4d8=l!uNAA-@f=dEV+XwjtM&`cdpJn5(Mk6vOkDRoA`ZUDd>;-R zV!Gj9pJ&>c?iNh>cR3}?{ID5+ymV+4`}SWmt?>7U88u^R!sN4ui(Wuzolrc&v@+I> ynDWvQL_yYB;ou?WaR{PJJ=}4Wd5GyVV)9RpBC@2TbWtL|yo0vpxnDAWR{jsW2EF(I delta 2618 zcmah~eN0=|6@T}=`wTYrySNE%orai?0*M-^ZHzynt|ksNq6x;NAyJcN#(**Yei#Ub zZUPd>$GQz8OI$Td6WS<6?WQ>`i{GkEBkD$`No_)#Rm-Xn@=+<%l8G#nwm^hNm>cQ<}uHKs&L18_<5(I`fi7j79o840^rZFwh~2zV1P< z$1|u;ZnE3*EH<0fX0%#rEXJ9zy)^&X@5aJH&JHG*SZ(t6P0pFMuAiU@mt3Xbw4lxF ziB%eThM76y3NDV#r(79u4!GxV>3$UnJy@1Xm-gO{7uC({P65#L}8lwtzFp!=fi>tM>k-R35OUGa8k zNm&a)`YJGHBHn!2)u+wV{sS zQ@h_}1@bPhYjW0;_lcw|?E;h~bc;c^C>vK1zBL3&$jLPZU85&oAc;h`QLk9x8_mtA z#Wx7yLtG9{Tn78_*W*AV&~kCY0)tMYmxKkv_+Ac z&~&DDXqxzXD_cCAS^}C9+UJzrSf4H6AteY!41-3KFYXO^x_ek!Tj|acC~PI=cQEK3 zfN0d=ga1uF@0UuM7YB(LfiIcdJK#(3Lu3`+oM|rF1EekwHK5jiI_ecD_eov?;lljlG>+1B-G@3)l38SxNI!ay;Gle@y zd8(t~gTYwbU>WT1>g4hCWl&g}srYhj+WQGePittl4FvoKzlo!5h!aCl3u!;B<|xIW zt0+Y{I)iX(oZAE|xE6VGKJ{EW!9miNc|Jz<^E{uVyw6Rk){Zs5Knx z=fN}cAI+bepZ$Dz{@mxoB>hCXo2+DtA2tCIvAOd(Fo^b2c@ld`S~jX-y{2BXA=uGJ z)oUNknp&g8YN1xqU^4gi*aoR=y|`kf`d34(e!pm|>9bg}oIExoldUWiAUeK0&FDA^ zKIA`l9r^8jdp>x|ZL&M4#qa6h_es-SV$dZj;oQ)a z@2k?PJF2f#an-A;DwSOMwQ^Q@LfNNu5k>h|9+m&H<$u7X;_{Wh!KbB}9lmiA!tB|^ z*oETdUH{*UmUmXOF1t(INN&jN@LO-gKK$-!xIx-{3SLz|L9l1xz9;08z5u}#B2+@< z0~x9k;;L!o0}|?@5-)}LU&%&xY*Z3CxESV3G7X3C*TE|M_6YwyjF0eI9E$Lt$VPUS zQ&3}c1$6TWUyVq&Or0N-pF&a`woM@*l%L za}T(+Y#C@`T9VC0&q=$6`71y+;xDC+7Y-4}jfeOjVev4(MSk|grz87IsNvk ({ 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