feat: add branches feature, security audit, and multi-branch support
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
# Аудит безопасности и антиспама — WhatIDo
|
||||
|
||||
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
|
||||
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
|
||||
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
|
||||
|
||||
### 1. Админ-токен в открытом виде + уязвимость по времени
|
||||
**Файл:** `server.js:52-56`
|
||||
```js
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
|
||||
next();
|
||||
}
|
||||
```
|
||||
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
|
||||
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
|
||||
- Реальный пароль лежит в `.env` на диске.
|
||||
|
||||
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
|
||||
|
||||
---
|
||||
|
||||
### 2. Файлы доступны анонимно по токену
|
||||
**Файлы:** `server.js:877-885`, `server.js:49`
|
||||
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
|
||||
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
|
||||
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
|
||||
|
||||
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
|
||||
|
||||
---
|
||||
|
||||
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
|
||||
**Файл:** `server.js:480-531`
|
||||
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
|
||||
- Нет срока действия ссылки (`expires_at`).
|
||||
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
|
||||
|
||||
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
|
||||
|
||||
---
|
||||
|
||||
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
|
||||
|
||||
### 4. Нет IP-based rate limit на публичный POST
|
||||
**Файл:** `server.js:969`
|
||||
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
|
||||
- Бот может менять имена — ограничение обходится.
|
||||
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
|
||||
|
||||
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Нет honeypot / CAPTCHA
|
||||
**Файл:** `public/index.html:305`
|
||||
- Форма отправки полностью открыта для ботов.
|
||||
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
|
||||
|
||||
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
|
||||
|
||||
---
|
||||
|
||||
### 6. Restore бэкапа загружает 300 МБ в память
|
||||
**Файл:** `server.js:191-194`
|
||||
```js
|
||||
const uploadBackup = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 300 * 1024 * 1024 },
|
||||
});
|
||||
```
|
||||
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
|
||||
- Архив не проверяется на содержимое до распаковки.
|
||||
|
||||
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
|
||||
|
||||
---
|
||||
|
||||
### 7. CSP отключён
|
||||
**Файл:** `server.js:47`
|
||||
```js
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
```
|
||||
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
|
||||
- Без CSP — риск XSS через инъекции в ошибки/настройки.
|
||||
|
||||
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
|
||||
|
||||
---
|
||||
|
||||
### 8. Сравнение токена без timing-safe
|
||||
**Файл:** `server.js:54`
|
||||
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
|
||||
|
||||
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
|
||||
|
||||
---
|
||||
|
||||
### 9. Поле `files` принимает любые расширения
|
||||
**Файл:** `server.js:83-93`
|
||||
- `fileFilter` проверяет только `photo` (image MIME).
|
||||
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
|
||||
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
|
||||
|
||||
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
|
||||
|
||||
---
|
||||
|
||||
### 10. Нет лимита на размер JSON-body
|
||||
**Файл:** `server.js:48`
|
||||
```js
|
||||
app.use(express.json());
|
||||
```
|
||||
- Нет `limit` — можно слать огромные JSON, забивать память.
|
||||
|
||||
**Рекомендация:** `express.json({ limit: '1mb' })`.
|
||||
|
||||
---
|
||||
|
||||
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
|
||||
|
||||
| # | Проблема | Файл/Место |
|
||||
|---|----------|------------|
|
||||
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
|
||||
| 12 | Нет аудит-лога админ-действий | — |
|
||||
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
|
||||
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
|
||||
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
|
||||
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
|
||||
|
||||
---
|
||||
|
||||
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
|
||||
|
||||
| Мера | Статус | Что нужно |
|
||||
|------|--------|-----------|
|
||||
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
|
||||
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
|
||||
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
|
||||
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
|
||||
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
|
||||
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
|
||||
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
|
||||
|
||||
---
|
||||
|
||||
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
|
||||
|
||||
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
|
||||
- ❌ CORS полностью удалён
|
||||
- ❌ Порт БД 5432 не опубликован, креды из `.env`
|
||||
- ❌ `escapeHtml` исправлен (`&`)
|
||||
- ❌ `express-rate-limit` на API роутах
|
||||
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
|
||||
- ❌ `helmet` + security-заголовки (кроме CSP)
|
||||
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
|
||||
- ❌ Параметризованные запросы (нет SQL-инъекций)
|
||||
- ❌ Токены файлов криптостойкие (16 байт hex)
|
||||
- ❌ Файлы не перезаписываются (случайные имена)
|
||||
- ❌ Multer-лимиты на размеры есть
|
||||
|
||||
---
|
||||
|
||||
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
|
||||
|
||||
### Фаза 1 — Критично (до публичного запуска)
|
||||
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
|
||||
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
|
||||
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
|
||||
4. **Honeypot** в публичной форме
|
||||
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
|
||||
6. **Мин. spam_interval_min = 1** (валидация в settings)
|
||||
|
||||
### Фаза 2 — Укрепление (высокий приоритет)
|
||||
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
|
||||
8. **HSTS** через reverse-proxy (Caddy/nginx)
|
||||
9. **Блок-лист расширений для `files`** + отдача как download
|
||||
10. **JSON body limit** (`1mb`)
|
||||
11. **`expires_at` для share_links** + опция анонимизации
|
||||
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
|
||||
|
||||
### Фаза 3 — Приватность и соответствие
|
||||
13. **Анонимизация share-ссылок** (опция скрыть имена)
|
||||
14. **Политика хранения** — автоудаление старых записей
|
||||
15. **Privacy notice** на публичной форме
|
||||
|
||||
---
|
||||
|
||||
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
|
||||
|
||||
```javascript
|
||||
// 1. Timing-safe админ-проверка (server.js:52-56)
|
||||
const crypto = require('crypto');
|
||||
function requireAdmin(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
const expected = Buffer.from(ADMIN_PASSWORD);
|
||||
const provided = Buffer.from(token || '');
|
||||
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 2. IP rate limit на публичную запись (server.js:969)
|
||||
const entryIpLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
keyGenerator: req => req.ip,
|
||||
message: { error: 'Too many submissions from this IP' }
|
||||
});
|
||||
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
|
||||
|
||||
// 3. Honeypot в форме (index.html) — скрытое поле
|
||||
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
|
||||
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
|
||||
|
||||
// 4. JSON body limit (server.js:48)
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// 5. Блок-лист для project files (server.js:83-93)
|
||||
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
|
||||
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Файлы для доработки (приоритет)
|
||||
|
||||
| Файл | Что править |
|
||||
|------|-------------|
|
||||
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
|
||||
| `server.js:969` | IP rate limiter + honeypot проверка |
|
||||
| `server.js:191-194` | diskStorage для backup restore |
|
||||
| `server.js:83-93` | блок-лист для `files` |
|
||||
| `server.js:48` | `express.json({ limit: '1mb' })` |
|
||||
| `server.js:1004` | валидация `spam_interval_min >= 1` |
|
||||
| `public/index.html` | honeypot input |
|
||||
| `db/init.sql` | добавить `expires_at` в `share_links` |
|
||||
| `docker-compose.yml` | раскомментировать Caddy для TLS |
|
||||
|
||||
---
|
||||
|
||||
*Аудит выполнен: 2026-09-07*
|
||||
*Статус: готово к внедрению Фазы 1*
|
||||
Reference in New Issue
Block a user