8.7 KiB
Код-ревью server.js — auth, CSRF, XSS, заголовки
Дата: 2026-05-30
🔴 Auth bypass 1 — отсутствие проверки подписи JWT (критично)
const payload = JSON.parse(Buffer.from(auth.replace('Bearer ', '').split('.')[1], 'base64').toString());
Это не аутентификация — это просто декодирование base64 payload без проверки подписи. Любой может прислать самодельный токен Bearer xxx.<base64 любого JSON>.yyy и стать любой компанией. ClientID, company_id полностью подконтрольны атакующему → полный обход изоляции компаний, доступ к чужим whitelist, экспорт. Это та самая дыра, ради которой нужен JWKS/проверка подписи (см. отдельный prompt-opus-auth). До внедрения проверки подписи продакшен поднимать нельзя.
Минимум: jose.jwtVerify(token, JWKS, { issuer: 'auth-api' }) с кэшированием ключей, и брать payload только из верифицированного результата. Также split('.')[1] упадёт на токене без точек → попадёт в catch → req.user = {}.
🔴 Auth bypass 2 — пустой req.user не блокирует доступ
} catch { req.user = {}; }
next();
При невалидном токене req.user = {} и запрос идёт дальше. В / тогда clientId = undefined → getOrCreateCompany(undefined, undefined) создаст/найдёт компанию с client_id = NULL. Все анонимы делят одну «нулевую» компанию, видят и редактируют её whitelist. Нет ни одного return res.status(401). Фикс — после catch и для не-DEV пути: если нет req.user.clientId → return res.status(401).send('Unauthorized').
🔴 DEV_MODE — риск включения в проде
const DEV = process.env.DEV_MODE === 'true';
...
if (DEV) { req.user = { ... clientId: 'WZ01325' ... }; return next(); }
Если DEV_MODE=true случайно попадёт в прод-конфиг — полный обход аутентификации, все становятся WZ01325. Нет защиты «DEV только не в production». Добавить страховку: const DEV = process.env.DEV_MODE === 'true' && process.env.NODE_ENV !== 'production'; и логировать предупреждение при старте, если DEV активен.
🔴 CSRF — все POST-формы без токенов (критично)
/add, /delete/:id — обычные form-POST, меняют состояние, без CSRF-токена. В проде аутентификация по cookie/сессии (а не по заголовку Authorization вручную) ⇒ сторонний сайт может отправить <form action="https://white.../delete/123" method=POST> и удалить чужие записи. Если же токен реально приходит только в заголовке Authorization (не в cookie), CSRF слабее — но форма в браузере не может сама проставить Authorization, значит модель аутентификации в браузере вообще не работает с текущим кодом. Это надо прояснить (см. questions.md). В любом случае при cookie-сессии нужен CSRF-токен (csurf или double-submit) на все POST.
🟡 XSS через ?error= в редиректе
res.redirect('/?error=' + encodeURIComponent(e.message));
Сам редирект экранирует. Уязвимость — в шаблоне: если index.ejs выводит error через <%- %> (не экранируя) — рефлексивный XSS. По ревью ejs вывод идёт через <%= %>, так что сейчас безопасно. Но e.message может содержать сырой текст ошибки БД — нежелательно показывать пользователю (info leak). Маппить на дружелюбные сообщения, не отдавать e.message БД наружу.
Кроме того /?error= читается из query, но в роуте / параметр req.query.error вообще не прокидывается в render (error: null) — то есть редирект с ?error= ничего не покажет. Несоответствие: либо читать req.query.error, либо убрать. Если читать — обязательно только экранированный вывод.
🟡 Обработка ошибок — каскад в /add
} catch (e) {
const company = await q.getOrCreateCompany(clientId, companyName).catch(() => null);
const entries = company ? await q.listEntries(company.id).catch(() => []) : [];
...
В catch-ветке снова дёргается БД (3 запроса). Если БД легла — все упадут в .catch(() => ...) и пользователь увидит исходную ошибку с пустым списком — приемлемо, но шумно. Главное: нет глобального error-handler middleware (app.use((err, req, res, next) => ...)) — необработанный промис в любом роуте уронит ответ висящим. Добавить финальный error middleware + process.on('unhandledRejection').
🟡 Нет helmet / security-заголовков
Отсутствуют X-Frame-Options/CSP (clickjacking), X-Content-Type-Options: nosniff, Referrer-Policy, HSTS. Добавить helmet() сразу после создания app. Особенно frame-ancestors/X-Frame-Options: DENY — UI с формами удаления уязвим к clickjacking.
🟡 Нет rate-limit
/add, /delete, /export без ограничения частоты. Можно засыпать INSERT-ами/экспортом. Добавить express-rate-limit на мутирующие роуты и на /export.
🟡 /export — нет авторизации и изоляции (важно по ТЗ)
app.get('/export', async (req, res) => {
const cidrs = await q.getExportCIDRs();
getExportCIDRs() без аргументов — отдаёт CIDR ВСЕХ компаний всем подряд, без проверки req.user, без фильтра по компании. Это утечка whitelist всех клиентов. По ТЗ экспорт должен быть либо служебным (ограничен по IP/токену), либо в рамках компании. Сейчас — публичный дамп всех адресов. Требует решения из questions.md (IP-ограничение/служебный токен), но в текущем виде — критичная утечка.
🟡 /healthz выше auth — ок, но раскрывает «OK» только
/healthz объявлен после middleware (значит проходит через auth). При DEV ок; в проде healthz будет требовать токен → проба готовности k8s упадёт. Вынести /healthz ВЫШЕ auth middleware.
🟡 Тело запроса без лимита размера
express.urlencoded({ extended: true }) без limit. DoS большими телами. Поставить limit: '32kb'.
🟢 SSRF/path traversal
express.static на фиксированную папку, шаблоны фиксированы — обхода нет.
Итог (по приоритету):
- 🔴 JWT без проверки подписи → полный обход auth и изоляции — блокер прода.
- 🔴 Пустой
req.user = {}не возвращает 401 → анонимы делят NULL-компанию. - 🔴
/exportбез авторизации отдаёт CIDR всех компаний — утечка. - 🔴 DEV_MODE без привязки к NODE_ENV — риск обхода auth в проде.
- 🔴 Нет CSRF на POST-формах.
- 🟡 Нет helmet, rate-limit, лимита тела, глобального error-handler.
- 🟡
/healthzпод auth (сломает k8s-пробу),?error=не читается в/, сырые ошибки БД наружу.