Files
shop-server/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md
T

11 KiB
Raw Blame History

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

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
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

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)
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
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):

import { registerIpGate } from './plugins/ip-gate.js'
  • Step 2: Register plugin before routes

Add before registerAuth(fastify) (before line 92):

await registerIpGate(fastify)
  • Step 3: Verify server starts
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
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
echo "" >> server/.env
echo "# Ограничение доступа по IP. Раскомментируй и укажи свои IP." >> server/.env
echo "# SITE_ACCESS_IPS=1.2.3.4" >> server/.env
  • Step 3: Commit
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
cd server && npm test

Expected: all tests pass, including ip-gate tests.

  • Step 2: Run ESLint on new files
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