docs: add IP-gate access control implementation plan
This commit is contained in:
@@ -0,0 +1,387 @@
|
|||||||
|
# IP-gate Access Control Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Добавить IP-whitelist на уровне Fastify, ограничивающий доступ к сайту на время разработки.
|
||||||
|
|
||||||
|
**Architecture:** Новый плагин `server/src/plugins/ip-gate.js` с `onRequest` хуком. Проверяет `request.ip` против `SITE_ACCESS_IPS` из `.env`. Часть путей (OAuth callbacks, webhook-и) исключены из проверки. При отказе — HTML-страница 403 с информацией о магазине и IP посетителя.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify, Node.js, vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Plugin — create ip-gate.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/src/plugins/ip-gate.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the plugin**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const EXCLUDED_PATHS = [
|
||||||
|
'/api/auth/oauth/vk/callback',
|
||||||
|
'/api/auth/oauth/yandex/callback',
|
||||||
|
'/api/webhooks/yookassa',
|
||||||
|
'/api/admin/notifications/telegram/webhook',
|
||||||
|
]
|
||||||
|
|
||||||
|
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">Сайт находится в разработке<br>и скоро будет доступен</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) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (allowedIps.length === 0) return
|
||||||
|
|
||||||
|
const urlPath = request.url.split('?')[0]
|
||||||
|
|
||||||
|
if (EXCLUDED_PATHS.includes(urlPath)) return
|
||||||
|
|
||||||
|
if (allowedIps.includes(request.ip)) return
|
||||||
|
|
||||||
|
return reply.code(403).type('text/html').send(build403Html(request.ip))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: No tests yet — commit the plugin skeleton**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/plugins/ip-gate.js
|
||||||
|
git commit -m "feat: add ip-gate plugin with env-based IP whitelist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Tests for ip-gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/src/plugins/__tests__/ip-gate.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write all tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { 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()
|
||||||
|
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('блокирует запрос с неразрешённого 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('403-страница показывает "не определён" когда IP отсутствует', 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('не определён')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('пропускает если после трима список 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail (plugin not registered yet in tests)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npx vitest run src/plugins/__tests__/ip-gate.test.js
|
||||||
|
```
|
||||||
|
Expected: all tests pass (plugin is registered in the `beforeEach`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/plugins/__tests__/ip-gate.test.js
|
||||||
|
git commit -m "test: add ip-gate access control tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Register plugin in index.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/index.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add import**
|
||||||
|
|
||||||
|
Add after the existing plugin imports (after line 20):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerIpGate } from './plugins/ip-gate.js'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register plugin before routes**
|
||||||
|
|
||||||
|
Add before `registerAuth(fastify)` (before line 92):
|
||||||
|
|
||||||
|
```js
|
||||||
|
await registerIpGate(fastify)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify server starts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && node --env-file=.env --eval "
|
||||||
|
import('./src/index.js').catch(e => { console.error(e.message); process.exit(1) })
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait ~5 seconds, then Ctrl+C. Expected: no errors in startup logs.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/index.js
|
||||||
|
git commit -m "feat: register ip-gate plugin in server startup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Environment variable docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/.env.example`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add SITE_ACCESS_IPS to .env.example**
|
||||||
|
|
||||||
|
Add after the `CORS_ORIGIN` comment block (after line 18):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена.
|
||||||
|
# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add SITE_ACCESS_IPS to actual .env**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "" >> server/.env
|
||||||
|
echo "# Ограничение доступа по IP. Раскомментируй и укажи свои IP." >> server/.env
|
||||||
|
echo "# SITE_ACCESS_IPS=1.2.3.4" >> server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/.env.example
|
||||||
|
git commit -m "docs: add SITE_ACCESS_IPS env var to .env.example"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
Expected: all tests pass, including ip-gate tests.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run ESLint on new files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npx eslint src/plugins/ip-gate.js src/plugins/__tests__/ip-gate.test.js
|
||||||
|
```
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test end-to-end manually**
|
||||||
|
|
||||||
|
1. Set `SITE_ACCESS_IPS=127.0.0.1` in `server/.env`
|
||||||
|
2. Start server: `cd server && npm run dev`
|
||||||
|
3. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3333/api/catalog`
|
||||||
|
Expected: `200` (127.0.0.1 в списке)
|
||||||
|
4. Test blocked: `curl` from a machine not in the list should get 403
|
||||||
|
5. Remove `SITE_ACCESS_IPS` and verify all requests pass again
|
||||||
Reference in New Issue
Block a user