- 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
12 KiB
Аудит безопасности и антиспама — WhatIDo
Приложение предназначено для публичного развёртывания. Ниже — результаты аудита по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет, 🟡 средний приоритет, 🔵 замечания по антиспаму.
🔴 КРИТИЧНО (исправить обязательно перед публикацией)
1. Админ-токен в открытом виде + уязвимость по времени
Файл: server.js:52-56
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
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
});
memoryStorage()— под нагрузкой DoS (OOM killer).- Архив не проверяется на содержимое до распаковки.
Рекомендация: использовать diskStorage во временную директорию, лимит 50 МБ.
7. CSP отключён
Файл: server.js:47
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
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 — Критично (до публичного запуска)
- Хеш админ-токена (bcrypt) +
crypto.timingSafeEqualдля сравнения - Защита файлов — отдача только в контексте валидной share-ссылки или подписанные URL
- IP rate limit на
POST /api/entries(10 req / 15 мин на IP) - Honeypot в публичной форме
- Backup restore на диск (diskStorage, лимит 50 МБ)
- Мин. spam_interval_min = 1 (валидация в settings)
Фаза 2 — Укрепление (высокий приоритет)
- CSP — рефакторинг inline-скриптов → внешние файлы
- HSTS через reverse-proxy (Caddy/nginx)
- Блок-лист расширений для
files+ отдача как download - JSON body limit (
1mb) expires_atдля share_links + опция анонимизации- Аудит-лог админ-действий (логин, CRUD, backup/restore)
Фаза 3 — Приватность и соответствие
- Анонимизация share-ссылок (опция скрыть имена)
- Политика хранения — автоудаление старых записей
- Privacy notice на публичной форме
БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
// 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