- Add honeypot field to public submission form + server-side check - Serve shared files only in context of a valid share link (/api/share/:shareToken/files/:fileToken) - Switch backup restore upload to diskStorage (50MB) with temp-dir cleanup - Limit JSON body to 1mb - Document fixed audit items
248 lines
12 KiB
Markdown
248 lines
12 KiB
Markdown
# Аудит безопасности и антиспама — 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. Файлы доступны анонимно по токену
|
||
**Статус:** 🟢 Частично исправлено (share-файлы привязаны к ссылке)
|
||
- ✅ Публичные share-файлы теперь отдаются **только** через `GET /api/share/:shareToken/files/:fileToken`, где проверяется принадлежность файла к записям активной ссылки (expiry, пароль, фильтры группы/имени/периода) — `server.js:612-647`.
|
||
- ⚠️ `GET /api/files/:token` и статика `/uploads/*` по-прежнему доступны по токену (нужны для админ-панели). Токены криптостойкие (16 байт hex).
|
||
|
||
**Файлы:** `server.js:612-647`, `public/share.html:106-121`
|
||
|
||
---
|
||
|
||
### 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* |