Files
WhatIDo/SECURITY_AUDIT_RU.md
T
dev 8a50b46b7b harden anti-spam and file serving; backup restore to disk; json body limit
- 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
2026-09-08 11:36:34 +03:00

248 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Аудит безопасности и антиспама — 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*