diff --git a/server/.env.example b/server/.env.example index a364b23..ee5c05c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,8 +17,8 @@ JWT_SECRET=замените-на-секрет-jwt # Разрешённый Origin фронта (через запятую при нескольких) # CORS_ORIGIN=http://127.0.0.1:5173 -# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена. -# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 +# Ограничение доступа по IP на время разработки (через запятую). Поддерживает точные IP и CIDR-диапазоны. Не задано — защита отключена. +# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8,192.168.1.0/24 # Публичные URL для OAuth redirect (локально обычно так): SERVER_PUBLIC_URL=http://127.0.0.1:3333 diff --git a/server/package-lock.json b/server/package-lock.json index 1e6e3e7..006bcbc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,6 +15,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "@rollup/rollup-linux-x64-gnu": "^4.61.0", "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", @@ -191,7 +192,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -463,6 +463,29 @@ "@dicebear/core": "^9.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1762,15 +1785,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "optional": true, "os": [ "linux" ] @@ -2376,7 +2400,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3027,7 +3050,6 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3084,7 +3106,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3632,7 +3653,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4286,7 +4306,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4431,7 +4450,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4462,7 +4480,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -4645,6 +4662,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/rollup/node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5175,7 +5209,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -5229,7 +5262,6 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/server/package.json b/server/package.json index e784264..538f5c1 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "@rollup/rollup-linux-x64-gnu": "^4.61.0", "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", diff --git a/server/src/plugins/__tests__/ip-gate.test.js b/server/src/plugins/__tests__/ip-gate.test.js index 251842a..2710460 100644 --- a/server/src/plugins/__tests__/ip-gate.test.js +++ b/server/src/plugins/__tests__/ip-gate.test.js @@ -172,4 +172,88 @@ describe('registerIpGate', () => { }) 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) + }) }) diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js index 3da7dc0..808b83e 100644 --- a/server/src/plugins/ip-gate.js +++ b/server/src/plugins/ip-gate.js @@ -12,6 +12,32 @@ function normalizeIp(ip) { return ip } +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) +} + +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 ` @@ -95,7 +121,11 @@ export async function registerIpGate(fastify) { if (EXCLUDED_PATHS.includes(urlPath)) return - if (allowedIps.includes(normalizeIp(request.ip))) 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)) })