Чёрный октябрь уязвимостей: что задело мой сервер, как я закрыл дыры и почему ушёл на статику без инлайн-скриптов

У меня круглосуточно работают агенты мониторинга: программы, которые по кругу собирают данные по моим проектам, в том числе бюллетени безопасности, и сверяют их с тем, что стоит на серверах. На очередном круге они подняли октябрьскую сводку: 15 уязвимостей за месяц, все «critical» и «high» — PHP, Nginx, Node.js, Next.js, Java, GitLab. Такие списки обычно пролистывают. Я сел и проверил каждую строку руками: что это на самом деле, задевает ли меня и чем это лечится.
Итог для начала:
две строки сводки меня действительно задели: Node.js был не обновлён четыре дня, а заплатки Nginx ещё не стояли в тот вечер, когда я сел проверять, — хотя я считал, что сервер обновляется сам;
две строки — не то, чем кажутся: «critical» у Nginx — это оценка CVSS, а сам nginx.org ставит «medium»; а одна «уязвимость Next.js» на деле сидит в библиотеке для SVG, которая есть почти на каждом сервере, где обрабатывают картинки, — с Next.js или без;
PHP-уязвимости меня не задели по самой простой причине: PHP на сервере нет.
Последнее — не везение. В июне я писал здесь, как сделал свой сайт со сравнением хостинга easylinklife.com быстрым при полном наборе счётчиков («Lighthouse 100/100…»). С тех пор я снял почти все сторонние скрипты, на 132 страницах не осталось ни одного инлайн-скрипта, а сервер прошёл несколько сплошных осмотров, каждый из которых находил то, чего не видели проверки «по известным местам».
Ниже три части. Первая — разбор «чёрного октября» с командами, чтобы проверить и закрыть то же у себя. Вторая — что я убрал с сайта и почему. Третья — дыры, которые я нашёл у себя за два месяца, с тем, как их найти у вас. Стек: Astro 7 (статическая сборка), Strapi 5 как CMS для сборки, Nginx, Ubuntu 24.04, один VPS.
Часть 1. Чёрный октябрь — строка за строкой
Сводная таблица
Что | Что это на самом деле (первоисточник) | Меня задело? |
|---|---|---|
PHP CVE-2026-109618, 109619 | выполнение кода без входа, CVSS 9.2; PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 от 21.10 | нет — PHP на сервере нет |
Nginx CVE-2026-15176 | переполнение буфера при | нет такой конфигурации; пакет всё равно был старый |
Nginx CVE-2026-93479 | переполнение в вычислении переменных; по nginx.org — ветки 1.30–1.31, Ubuntu закрыла и в 1.24 | был не обновлён |
Nginx CVE-2026-97205 | use-after-free при | нет |
Node CVE-2026-111497 | Brotli в zlib Node, вплоть до выполнения кода | был открыт 4 дня |
та же CVE в системном | ошибка в самой библиотеке Brotli; Ubuntu выпустила заплатку 25.10 | была старая — обновил вместе с Nginx |
Node CVE-2026-105482, 106069 | проверка входа в | закрыто тем же 22.23.4 |
Node CVE-2026-105483 | регрессия нормализации IP в TLS | нет: только ветки 24 и 26 |
«Next.js» CVE-2026-96889 | librsvg (SVG) внутри sharp; Next.js выпустил свой critical GHSA-2464-fhr4-w9xw на оптимизатор картинок | проверил: sharp 0.35.5 с librsvg 2.63.2 — исправлено |
Next.js GHSA-673q-mjm6-r5xr | выполнение кода без входа в функциях | нет Next.js |
Next.js GHSA-p3m5-59mp-jv5r | отказ в обслуживании через Server Actions | нет Next.js |
Java CVE-2026-91403 | JSSE (TLS в Java), CVSS 9.1, без входа по сети | нет Java |
GitLab CVE-2026-110264 | выполнение кода без входа через регулярное выражение, CVSS 10, версии с 7.0 | нет GitLab |
GitLab CVE-2026-97229 | то же через регулярку в CI/CD, CVSS 10, 19.2–19.4 | нет GitLab |
А теперь — почему таблица именно такая и как получить такую же про свой сервер.
Правило, без которого всё остальное бесполезно: номер версии в дистрибутиве врёт
На Ubuntu 24.04 php -v покажет 8.3.6, nginx -v — 1.24.0. По номеру это версии двухлетней давности, и любой сканер «по версиям» покрасит их в красный. Но Ubuntu не обновляет версию — она переносит заплатки в старую. Так, пакет php8.3 версии 8.3.6-0ubuntu0.24.04.13 содержит исправление обеих октябрьских RCE, хотя номер у него 8.3.6.
Поэтому вопрос «закрыта ли CVE» задаётся не номеру версии, а журналу изменений пакета:
# закрыта ли конкретная CVE в установленном или доступном пакете
apt-get changelog php8.3-fpm | grep -m1 -E "CVE-2026-109618|CVE-2026-109619"
apt-get changelog nginx | grep -oE "CVE-2026-[0-9]+" | sort -u
# что установлено и что предлагается
apt-cache policy nginx php8.3-fpm
Если в Candidate версия новее Installed, а в её журнале есть ваша CVE — вы не обновлены. Ровно так я и нашёл у себя Nginx.
PHP2Shell: CVE-2026-109618 и 109619
На php.net 21 октября: «fix two critical vulnerabilities that can allow unauthenticated remote code execution» — выполнение кода без входа. Обе связаны с тем, как PHP восстанавливает состояние после аварийного выхода из обработки запроса (нехватка памяти, longjmp): часть глобального состояния продолжает указывать в память уже завершённого запроса. 26 октября CISA внесла обе в каталог уязвимостей, которые уже эксплуатируются (Known Exploited Vulnerabilities), — это не теоретическая угроза.
Проверить и закрыть:
php -v
dpkg -l | grep -E "^ii php[0-9.]+-(fpm|cli|common)" | awk '{print $2, $3}'
# Ubuntu/Debian, пакеты дистрибутива — ищем CVE в журнале:
apt-get changelog php8.3-fpm | grep -m1 CVE-2026-109618
# обновить и перезапустить обработчик (имя службы — по вашей версии):
sudo apt-get update && sudo apt-get install --only-upgrade 'php8.3*'
sudo systemctl restart php8.3-fpm
# PHP из репозитория ondrej/php или собранный сам — нужна версия не ниже 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
Не забудьте процессы, которые держат старую библиотеку в памяти: после обновления пакета работающий FPM остаётся старым до перезапуска. Проверка — sudo lsof -n | grep -E "php.*(deleted)": пусто — всё перезапущено.
Если сайт был открыт до обновления — искать следы. Выполнение кода без входа на PHP-сайте почти всегда кончается файлом-шеллом. Минимальный осмотр:
# PHP-файлы, изменённые за последние три недели (сравните со своими выкатами)
sudo find /var/www -type f -name "*.php" -newermt "2026-10-07" -printf "%TY-%Tm-%Td %p\n" | sort
# PHP там, где его быть не должно
sudo find /var/www -path "*/uploads/*" -name "*.php*"
# типичные конструкции шеллов (ложные совпадения будут — читать глазами)
sudo grep -rlE --include="*.php" "eval\(\s*(base64_decode|gzinflate|str_rot13)|assert\(\s*\\\$_(POST|GET|REQUEST)|preg_replace\(.*/e" /var/www
Для WordPress есть проверка надёжнее поиска по шаблонам — сверка файлов с эталонными суммами и список администраторов (WP-CLI):
wp core verify-checksums
wp plugin verify-checksums --all
wp user list --role=administrator --fields=ID,user_login,user_registered
Новый администратор, которого вы не создавали, — это не «баг», а взлом: менять все пароли и ключи из wp-config.php, а не только удалять пользователя.
Nginx: «critical» в сводке, «medium» на nginx.org — и всё равно обновиться было надо
Все пять октябрьских уведомлений на nginx.org помечены medium и исправлены в 1.31.7+ и 1.30.6+. В NVD у тех же 15176 и 97205 — CVSS v4 9.2: «critical» в сводке взят оттуда. Расхождение объяснимо: nginx.org оценивает с учётом того, что уязвимость срабатывает только при определённой конфигурации. Задевают они не версию как таковую, а конфигурацию:
CVE-2026-15176 alias вместе с rewrite в одном location
CVE-2026-93479 вычисление переменных (по nginx.org — 1.30.0–1.30.5 и 1.31.2–1.31.6)
CVE-2026-97205 aio
CVE-2026-97629 ssl_session_tickets (подделка TLS-сессии)
CVE-2026-95101 HTTP/3
Посмотреть, есть ли у вас эти директивы:
sudo nginx -T 2>/dev/null | grep -nE "^\s*(aio|alias|rewrite|ssl_session_tickets|http3|listen .*quic)"
У меня — только ssl_session_tickets off;, то есть по конфигурации ни одна меня не задевала. На этом я бы и остановился, если бы не правило из предыдущего раздела. Ubuntu выпустила nginx 1.24.0-2ubuntu7.19 с заплатками от 15176, 93479, 97205 и 97629, а у меня стоял 7.18:
$ apt-cache policy nginx
Installed: 1.24.0-2ubuntu7.18
Candidate: 1.24.0-2ubuntu7.19
Почему не обновилось само, хотя unattended-upgrades включён. Не поломка — расписание. Обновление попало в списки пакетов после того, как утренний запуск автообновления уже отработал и написал «No packages found that can be upgraded unattended». Следующий запуск поставил бы его через сутки. Окно получилось около полутора суток — для уязвимостей, которые уже обсуждают все, это много.

Мой же агент мониторинга это окно видел: в его отчёте стояла строка «ждёт обновления: nginx …7.19». Но строка была обычной, не тревогой: для Nginx агент считал тревогой уязвимость, которая задевает мою конфигурацию, или автообновление, молчащее трое суток, — а здесь не было ни того, ни другого. Вывод простой: обновление безопасности, которое ждёт установки, — уже повод для тревоги, независимо от того, задевает ли уязвимость ваш конфиг. Теперь агент так и делает: обновление из *-security, которое он видит дольше суток, — тревога.
Как проверить, что ваш сервер не в том же окне:
# что из обновлений безопасности ждёт установки прямо сейчас
apt list --upgradable 2>/dev/null | grep -- -security
# включено ли и что делало в последний раз
systemctl is-enabled unattended-upgrades apt-daily-upgrade.timer
sudo tail -20 /var/log/unattended-upgrades/unattended-upgrades.log
systemctl list-timers 'apt-daily*' --no-pager
# что он поставил бы прямо сейчас (ничего не меняет)
sudo unattended-upgrade --dry-run --debug 2>&1 | grep -A5 "pkgs that look like they should be upgraded"
Закрыть руками — с копией конфигурации и проверкой ответа, а не только nginx -t:
sudo tar -czf /root/nginx-before-$(date +%F).tar.gz -C / etc/nginx
sudo apt-get update
sudo apt-get install --only-upgrade -o Dpkg::Options::=--force-confold nginx nginx-common
sudo nginx -t && systemctl is-active nginx
curl -s -o /dev/null -w "%{http_code}\n" --resolve example.com:443:127.0.0.1 https://example.com/
--force-confold оставляет ваши файлы конфигурации, если пакет принесёт свои. --resolve спрашивает свой же Nginx с правильным SNI — так проверка не зависит от DNS и кэшей между вами и сервером.
Node.js: 21 октября, и почему unattended-upgrades его не обновит никогда
21 октября Node выпустил 22.23.4, 24.22.1 и 26.12.1. Для ветки 22 закрыты шесть CVE, самая серьёзная — 111497, ошибка в распаковщике Brotli внутри Node вплоть до выполнения кода. 105483 из сводки ветку 22 не задевает: это регрессия, появившаяся только в 24 и 26.
У меня 22.23.2 простоял четыре дня, пока его не заметил агент другого моего проекта. Причина в том, откуда Node поставлен: из репозитория NodeSource. unattended-upgrades обновляет только источники из своего списка Allowed-Origins, а там Ubuntu и её репозитории безопасности, NodeSource — нет. Проверьте у себя:
node -v
apt-cache policy nodejs | head -3 # откуда пакет
grep -A6 "Allowed-Origins" /etc/apt/apt.conf.d/50unattended-upgrades
Если Node пришёл не из Ubuntu, он обновляется только руками:
sudo apt-get update && sudo apt-get install --only-upgrade nodejs
node -v
# перезапустить всё, что работает на Node: pm2, systemd-службы
sudo pm2 restart <имя> && sudo pm2 reset <имя> # по имени, не «all»
Brotli — не только Node. Ошибка 111497 сидит в самой библиотеке Brotli, а Node просто несёт её копию внутри. Та же библиотека есть в системе (libbrotli1), и свою копию несут другие программы — например, Python-пакет brotli: их проверяют отдельно. Ubuntu выпустила libbrotli1 1.1.0-2ubuntu0.1 с этой заплаткой 25 октября; у меня системную библиотеку держали в памяти Nginx и служба обновления прошивок. Обновить пакет мало — проверьте, что старую копию никто не держит:
apt-get changelog libbrotli1 | grep -m1 CVE-2026-111497 # заплатка в пакете
apt list --upgradable 2>/dev/null | grep -i brotli # ждёт ли установки
sudo grep -l 'libbrotli.*(deleted)' /proc/[0-9]*/maps 2>/dev/null # кто держит старую — пусто
pip list 2>/dev/null | grep -i brotli # Python-пакеты со своей копией
Новый пакет — не новый процесс. На втором моём сайте apt поставил Node 22.23.4 25 октября днём, а приложение ещё работало на 22.23.2: процесс читает программу при запуске и сам не перезапускается. Перешло на новую только при выкате. То же с FPM у PHP и с любой библиотекой. Процесс, чей исполняемый файл заменён на диске, видно сразу:
sudo ls -l /proc/[0-9]*/exe 2>/dev/null | grep '(deleted)' # должно быть пусто
sudo needrestart -b # то же и для библиотек, если пакет стоит
Проверил эту команду от обратного: запустил копию sleep и удалил её файл — строка нашлась ровно одна.
«Next.js» CVE-2026-96889, которая на самом деле про sharp
В сводке эта строка стоит под Next.js. В базе NVD CVE-2026-96889 — это librsvg, библиотека отрисовки SVG: use-after-free при разборе SVG с вложенными XInclude и повторяющимися сущностями, вплоть до выполнения кода. Next.js выпустил на неё свой бюллетень GHSA-2464-fhr4-w9xw уровня critical: выполнение кода без входа через оптимизатор картинок, если ему подсунуть SVG. Обходной путь в бюллетене — sharp не ниже 0.35.5.
Казалось бы, раз Next.js у меня нет — пропускаем. Но librsvg — внутри libvips, а libvips — внутри sharp, через который проходит почти любая обработка картинок в Node: Strapi, Next.js, Payload, самописные сервисы. Если ваш сервис принимает SVG от пользователей и пропускает их через sharp — это ваша строка таблицы, с Next.js или без. И обновить сам Next.js мало: старый sharp может остаться в lock-файле, проверять надо его.
# версия librsvg внутри sharp (из каталога проекта)
node -e "console.log(require('sharp').versions.rsvg)"
# системная библиотека
dpkg -l | grep librsvg2-2
apt-get changelog librsvg2-2 | grep -m1 CVE-2026-96889
Исправлено в librsvg 2.63.2. У меня sharp 0.35.5 несёт ровно 2.63.2, системный пакет тоже с заплаткой. Плюс мой обработчик картинок SVG не трогает вовсе, а Nginx отдаёт загрузки отдельной политикой (о ней ниже). Строка закрыта трижды, но узнал я это, только когда проверил.
Две другие уязвимости Next.js из сводки — его собственные: GHSA-673q-mjm6-r5xr (выполнение кода в функциях use cache, 16.0–16.4.0, обходного пути нет) и GHSA-p3m5-59mp-jv5r (отказ в обслуживании через Server Actions). Проверка и лечение:
npm ls next sharp
npm install next@16.4.1 # для ветки 15 — не ниже 15.5.28
npm ls sharp # не ниже 0.35.5
Java CVE-2026-91403 и GitLab CVE-2026-110264, 97229
Ни Java, ни GitLab на моём сервере нет, но строки серьёзные, поэтому команды:
java -version 2>&1 | head -1
# уязвимы Oracle Java SE 8u511, 11.0.33, 17.0.21, 21.0.13, 25.0.5, 27.0.1 и GraalVM тех же линий:
# ставить октябрьское обновление (Oracle CPU 21.10) или сборку OpenJDK вашего дистрибутива после 21.10
apt-get changelog openjdk-21-jre-headless | grep -m1 CVE-2026-91403
# GitLab: версия
sudo gitlab-rake gitlab:env:info | grep "GitLab information" -A2
# 110264 — CVSS 10, все версии с 7.0: исправлено в 18.11.13, 19.0.10, 19.1.9, 19.2.9, 19.3.6, 19.4.3, 19.5.1
# 97229 — CVSS 10, 19.2–19.4: исправлено в 19.2.8, 19.3.5, 19.4.2
У обеих GitLab-уязвимостей одинаковая природа: специально составленное регулярное выражение, выполнение кода без входа. Если GitLab смотрит в интернет — обновление сегодня, а не в окно обслуживания.
Что ещё нашлось в тот же вечер: npm audit
Сводка про платформы и системные пакеты, но тот же вопрос надо задать зависимостям. Мой гейт выката останавливает сборку, если у сайта есть критичная уязвимость с исправлением. Сторож на сервере раз в сутки гоняет npm audit и для CMS. 22 октября он прислал «6 новых (critical 0)» — ничего срочного. 23-го аудит уже видел critical 1, но тревога не ушла: сторож пишет при смене состояния, а состояние было то же — «беда, уже сообщено». Критичная DOMPurify пять дней лежала в журнале молча. Теперь рост числа критичных во время идущей беды — тревога заново, с первого раза. В тот вечер:
сайт (Astro): high 3 — undici, vite, zod → npm audit fix: рабочие зависимости — 0
Strapi: critical 1 — DOMPurify ≤ 3.4.16 → исправлено в 3.4.17 от 22.10
Strapi закрепил DOMPurify ровно на 3.4.16, поэтому npm audit fix его не поднимет: он предлагает обновить весь Strapi, а это уже мажорный переход с риском. Точечное лечение — overrides:
{
"overrides": {
"dompurify": "^3.4.17"
}
}
npm install
npm ls dompurify # должна остаться одна версия
npm audit --omit=dev | tail -3 # critical — 0
npm run build # админка собирается
Именно ^3.4.17, а не точная версия. Точные версии в overrides у меня уже однажды стали потолком: следующее исправление не приехало, потому что override держал старое.
Такие пробы я делаю на копии каталога без .env и загрузок: собрал, проверил — потом то же в рабочем дереве. Strapi целиком (5.56 → 5.59) я пока не поднял: на копии сборка падает из-за моего же старого override для undici. Это отдельная задача, и админка до её решения наружу не смотрит (часть 3).
Часть 2. Почему я ушёл со скриптов на чистую статику
PHP-дыры и атака через CDN — два довода из одного месяца
Первый довод — сам PHP. Выполнение кода без входа означает: каждый сайт, где на запрос посетителя отвечает PHP, был уязвим до обновления. У статического сайта на запрос посетителя не выполняется ничего — Nginx отдаёт файл. Этот класс атак закрыт устройством, а не скоростью обновления.
Второй довод — чужой JavaScript. В июне атаковали три маркетинговых плагина WordPress — OptinMonster, TrustPulse и PushEngage. Первой атаку описала Sansec (13 июня), через два дня — Patchstack: «served tampered JavaScript from their vendors’ CDNs to live websites». Подменённый скрипт с CDN поставщика в браузере вошедшего администратора создавал скрытого администратора. Ни одна строка PHP на самих сайтах не менялась — хватило того, что сайт исполнял чужой код. А 18–19 октября украденные ключи UpdraftCentral 18 часов подряд ставили бэкдоры на подключённые сайты.
Статика первую дыру закрывает, вторую — нет. Если страница грузит чужой скрипт, он делает в браузере всё, что ему разрешено. Это закрывают отказ от лишних скриптов и строгая политика безопасности браузера (CSP).
Чего статика не закрывает. Сервер остаётся сервером: Nginx, Node для сборки и CMS, npm-зависимости. Ровно поэтому две строки «чёрного октября» меня задели. Статика сужает поверхность атаки, но обновлять сервер всё равно надо.
Почему снят Partytown — и с ним GTM, GA4 и Clarity
Partytown. В середине августа я проверил живой сайт браузером — не Lighthouse, а то, уходят ли запросы счётчиков. Воркер Partytown падал с той самой ошибкой, которую в июньской статье я «вылечил» для одного Clarity: Cannot read properties of undefined (reading 'apply'). Счётчики не отправляли ни одного запроса. Баннер согласия работал, Lighthouse показывал 99–100 — а аналитики не было. 14 августа Partytown снят. Попутная выгода: он собирал код воркера через new Function, и ради него в CSP стоял 'unsafe-eval'. Теперь его нет.
GTM и GA4. GTM у меня был нужен ровно для одного — загрузить GA4. Рекламных сетей на сайте нет, Consent Mode нужен был только им. А GTM — это контейнер, в который можно добавить любой сторонний скрипт без выката сайта: тот самый канал «чужой JavaScript на моей странице», через который пришла июньская атака. Сняты вместе с Partytown.
Clarity продержалась до сентября. Записи сессий никто не смотрел, а данные посетителей уходили в Microsoft. Снята.
Осталась Метрика — единственный счётчик, по которому я реально смотрю, откуда приходят люди. Она грузится только после согласия на cookie и в простое главного потока.
Скорость не пострадала: 24 октября Lighthouse на мобильном профиле дал странице каталога 100, TBT 0 мс. Не потому, что скрипты спрятаны в воркер, а потому, что их стало меньше.
Урок, который пригодится дальше: метрика показывает не то, ради чего на неё смотришь. Lighthouse не знает, уходит ли запрос счётчика. Проверять надо само действие.
Ни одного инлайн-скрипта: зачем и как
Самая частая атака на сайт с формами и пользовательским текстом — XSS: в страницу попадает <script> или onerror= из данных. Если CSP не разрешает инлайн-код ('unsafe-inline' нет), такой скрипт браузер не выполнит. Но тогда не выполнятся и ваши собственные инлайн-скрипты.
Сначала я пошёл путём хэшей: каждый инлайн-скрипт — с sha256-… в политике. Это работает, но держится на ручном труде: любая правка скрипта меняет хэш, и до правки политики скрипт молча не выполняется. Встроенная CSP Astro не помогла: она хэширует только собранные Astro скрипты, а is:inline пропускает как есть — так сказано в документации, и я проверил это на сборке: из семи инлайнов её хэши не покрыли ни одного.
Поэтому я убрал инлайны совсем. Сейчас в 132 страницах сборки:
исполняемых инлайн-<script> 0
атрибутов style= 0
тегов <style> 0
обработчиков on*= 0
<script type="application/json"> 396 — это данные, браузер их не исполняет
Проверить свою сборку так же:
cd dist
grep -rhoE "<script(\s[^>]*)?>" --include="*.html" . | grep -vE 'src=|type="application/(ld\+)?json"' | sort | uniq -c
grep -rlE '\sstyle="' --include="*.html" . | wc -l
grep -rhoE '\son[a-z]+="' --include="*.html" . | sort | uniq -c
Четыре приёма, которыми это сделано.
Приём 1. Скрипт, который должен успеть до первой отрисовки, — отдельным файлом. Тема (светлая/тёмная) обязана выставиться до отрисовки, иначе страница мигнёт. Обычный <script> Astro уносит в отложенный модуль — поздно. Решение — импорт Vite с ?url и обычный блокирующий тег в <head>:
---
import temaUrl from "../scripts/tema.js?url";
---
<head>
<script is:inline src={temaUrl}></script>
</head>
is:inline здесь значит «не обрабатывай тег», но код лежит в отдельном файле /_astro/tema.*.js — его покрывает script-src 'self'.
Приём 2. Остальные скрипты — обычным <script>. Astro соберёт их в /_astro/*.js. Не используйте define:vars — по документации он подразумевает is:inline, то есть снова инлайн.
Приём 3. Данные с сервера — блоком JSON, а не кодом.
<script type="application/json" id="quiz-providers" set:html={providerDataJSON} />
const providers = JSON.parse(document.getElementById("quiz-providers").textContent);
Браузер такой блок не исполняет, хэш ему не нужен. Одно условие: экранируйте < в значении (JSON.stringify(x).replace(/</g, "\\u003c")), иначе строка </script> в данных закроет тег.
Приём 4. Стили — только файлами. В astro.config.mjs:
export default defineConfig({
build: { inlineStylesheets: "never" },
vite: { build: { assetsInlineLimit: 0 } },
});
Атрибуты style= заменены классами, обработчики onclick= — addEventListener в собранных скриптах.
Строгая CSP: заголовок целиком

Политика задаётся заголовком Nginx, а не <meta>: заголовок применяется к ответу целиком и его нельзя подменить из разметки.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://mc.yandex.ru https://mc.yandex.com https://cdn.jsdelivr.net/gh/yandex/; style-src 'self'; font-src 'self' data:; img-src 'self' data: https: blob:; connect-src 'self' https://mc.yandex.ru https://mc.yandex.com wss://mc.yandex.ru https://yandex.ru https://mc.yandex.az https://mc.yandex.by https://mc.yandex.co.il https://mc.yandex.com.am https://mc.yandex.com.ge https://mc.yandex.com.tr https://mc.yandex.ee https://mc.yandex.fr https://mc.yandex.kg https://mc.yandex.kz https://mc.yandex.lt https://mc.yandex.lv https://mc.yandex.md https://mc.yandex.tj https://mc.yandex.tm https://mc.yandex.uz wss://mc.yandex.az wss://mc.yandex.by wss://mc.yandex.co.il wss://mc.yandex.com.am wss://mc.yandex.com.ge wss://mc.yandex.com.tr wss://mc.yandex.ee wss://mc.yandex.fr wss://mc.yandex.kg wss://mc.yandex.kz wss://mc.yandex.lt wss://mc.yandex.lv wss://mc.yandex.md wss://mc.yandex.tj wss://mc.yandex.tm wss://mc.yandex.uz; frame-src 'self' https://mc.yandex.ru https://mc.yandex.com https://mc.yandex.az https://mc.yandex.by https://mc.yandex.co.il https://mc.yandex.com.am https://mc.yandex.com.ge https://mc.yandex.com.tr https://mc.yandex.ee https://mc.yandex.fr https://mc.yandex.kg https://mc.yandex.kz https://mc.yandex.lt https://mc.yandex.lv https://mc.yandex.md https://mc.yandex.tj https://mc.yandex.tm https://mc.yandex.uz; frame-ancestors 'none'; worker-src 'self' blob:; child-src 'self' blob:; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always;
default-src 'self'— всё, что не названо, только со своего домена.script-src— свои файлы и Метрика. Ни'unsafe-inline', ни'unsafe-eval', ни хэшей.style-src 'self'— без'unsafe-inline': этого и добивались приёмом 4.frame-ancestors 'none'— страницу нельзя встроить в чужой фрейм (кликджекинг).connect-srcиframe-src— длинные из-за Метрики: в них все страновые доменыmc.yandex.*по официальному списку Яндекса (почему — ниже, в абзаце про журнал отчётов).img-srcоткрыт для любогоhttps:— счётчик шлёт данные и картинкой, а картинки не исполняются.worker-srcиchild-src—'self' blob::blob:Яндекс требует для карты кликов и Вебвизора; чужих адресов здесь нет.base-uri 'self'— внедрённый<base href>не уведёт относительные ссылки и скрипты на чужой домен.form-action 'self'— внедрённая форма не отправит данные наружу.object-src 'none'— никаких<object>и<embed>: старый, но живой путь исполнения.upgrade-insecure-requests— случайнаяhttp://-ссылка на ресурс уходит по HTTPS.Чужой домен в политике — только тот, без которого браузер с согласием на cookie реально ломает счётчик. Каждый я добавлял по замеру: браузер, согласие, список заблокированных запросов. Лишние (шрифты Google, старый виджет репутации) нашлись и сняты при аудите.
Ловушка 'self': он доверяет всему на вашем домене — и загрузкам пользователей тоже. У меня картинки CMS отдаются с того же домена из /uploads/. Значит, любой файл .js, оказавшийся в загрузках, браузер исполнил бы как «свой». Сейчас этот location отдаёт только картинки и со своей, отдельной политикой:
location ^~ /uploads/ {
root /var/www/site/backend/public;
include snippets/security-headers.conf;
if ($uri !~* "\.(?:jpe?g|png|webp|gif|avif|ico|svg)$") { return 403; }
add_header Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox" always;
try_files $uri =404;
}
sandbox превращает даже SVG со скриптом, открытый напрямую, в безвредную картинку. style-src 'unsafe-inline' здесь допустим: внутри SVG стили встроены, а скрипты запрещены default-src 'none'.
Как включать, не сломав сайт. Рядом стоит второй заголовок — Content-Security-Policy-Report-Only с тем, что я хочу включить следующим, и report-uri /csp-otchet. Браузер ничего не блокирует, но присылает отчёт о каждом нарушении. Неделю собираю отчёты, отделяю свои нарушения от расширений браузера (moz-extension:, chrome-extension:), свои чиню и только потом переношу правило в основную политику.
Приёмник отчётов принимает тело запроса из интернета, поэтому работает под своим пользователем без оболочки, а Nginx ограничивает частоту (30 запросов в минуту с адреса). Сначала я поднял его под root — это нашёл сплошной осмотр прав, о нём в части 3.
Журнал отчётов надо читать — иначе он бесполезен. На втором моём сайте отчёты копились, но до 20 октября никто их не разбирал. Разбор показал: CSP резала Метрику у посетителей, которым она отвечает со страновых доменов — mc.yandex.kz, mc.yandex.md, mc.yandex.com. Посетители из Казахстана и Молдовы просто не считались. Я проверил свою сводку: тот же случай 20 октября, connect-src и frame-src отказали mc.yandex.md. У Яндекса есть официальный список доменов Метрики для CSP — 18 доменов mc.yandex.*. В политике у меня было два. Теперь все стоят в connect-src и frame-src. Первой правкой я, кстати, пропустил mc.yandex.com в frame-src — нашёл, только сверив политику с живым заголовком целиком, а не по кусочку. mc.webvisor.* из того же списка мне не нужны: Вебвизор у меня выключен. Вывод: сводка отчётов «директива × заблокированный адрес» — раз в неделю, а не «когда сломается».
Если сайт не статический. Второй сайт — витрина на Astro в режиме SSR: страницу собирает Node на каждый запрос. CSP там устроена так же — script-src 'self' и домены Метрики, без 'unsafe-inline' и без nonce: Astro выносит скрипты файлами и в SSR. А в style-src 'unsafe-inline' пока остался — его требуют стили компонентов. То есть отказ от инлайн-скриптов SSR не мешает, со стилями — работы больше.
Чего ещё нет. В теневой политике стоит require-trusted-types-for 'script'. В основную её перенести пока нельзя: загрузчик Метрики сам присваивает script.src строкой, и с включённым Trusted Types её запросы блокируются. Нужна собственная политика trustedTypes.createPolicy('default', …), пропускающая только mc.yandex.ru. Это следующий шаг.
Остальные заголовки и две ловушки Nginx
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-site" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always;
server_tokens off;
add_header в location отменяет все add_header уровнем выше. Стоит добавить в location один свой заголовок (как Cache-Control у загрузок) — и заголовки безопасности из server там пропадают. Я выношу их в сниппет и подключаю include в каждом location, где есть свой add_header.
Правьте тот файл, который Nginx читает. У меня sites-enabled/ однажды оказался копией, а не ссылкой на sites-available/: nginx -t говорил «successful», а правка не действовала.
ls -la /etc/nginx/sites-enabled/ # ждём «-> ../sites-available/…»
curl -sI --resolve example.com:443:127.0.0.1 https://example.com/ | grep -iE "content-security|strict-transport"
Проверять не конфиг, а ответ сервера.
Часть 3. Дыры, которые я нашёл у себя за два месяца
Почти ни одну из них не нашла проверка «по известным местам». Находил сплошной осмотр класса: не «правильные ли права у трёх .env», а «что на этом сервере прочтёт любой пользователь». Для каждой — команда, чтобы проверить у себя.
Сентябрь: все секреты CMS лежали в git с мая — и были живы
Это моя собственная ошибка, и рассказываю я её именно затем, чтобы вы проверили у себя: она из самых частых.
В закрытом репозитории была папка backups/: дампы базы и архив каталога CMS вместе с .env — «на всякий случай», ещё с весны. Сверка (по отпечаткам, без вывода значений) показала: каждый ключ в архиве совпадал с рабочим. Об одной утечке мне сообщили раньше, чем я её нашёл: почтовая служба сама отозвала свои ключи и прислала письмо. Внутренние ключи Strapi (APP_KEYS, JWT_SECRET, ADMIN_JWT_SECRET, TRANSFER_TOKEN_SALT) и пароль пользователя базы я сменил, папку убрал.
Потом — отдельный обход всех объектов истории git (git cat-file --batch-all-objects, с распаковкой архивов внутри). Он нашёл токены ещё нескольких сервисов. Каждый я проверял на живость запросом к API и печатал только отпечаток, не значение. Дальше правило одно: живой — перевыпустить, старый — отозвать в кабинете сервиса. Часть этого делается только руками в кабинете каждого сервиса, поэтому такие пункты я веду отдельным списком, пока каждый не закрыт. Выкат, который забирал код с GitHub личным токеном, после отзыва токена встал — и перешёл на ключ развёртывания только для чтения: личному токену на сервере делать нечего.
Главное, что я понял: удалить файл из репозитория мало — он остаётся в истории. Секрет, побывавший в git, перевыпускают.
# что из истории похоже на секреты (имена файлов)
git log --all --name-only --format="" | sort -u | grep -iE "\.env|backup|dump|\.sql|secret|token|credential"
# готовые сканеры истории
gitleaks detect --source . --log-opts="--all"
trufflehog git file://. --only-verified
Сентябрь: root исполнял файлы, которые мог переписать веб-сервер
Всё дерево сайта принадлежало www-data, а выкат от root делал в нём git pull (с хуками из .git/hooks) и запускал скрипты сборки. Любой npm-пакет, исполняемый при сборке под www-data, мог подложить root свой код — а www-data это ещё и рабочие процессы Nginx.
Сейчас дерево принадлежит root, сборка идёт под отдельным пользователем, который пишет только в node_modules и временную папку сборки, .env — root:<сборщик> 640. Выкат сам проверяет владельца и останавливается, если дерево снова не root.
# файлы, которые исполняет root, но может переписать кто-то другой
sudo find /var/www/site -maxdepth 3 \( -name "*.sh" -o -name "*.mjs" -o -path "*/.git/hooks/*" \) \
! -user root -printf "%u %p\n"
sudo find /var/www/site -maxdepth 3 -perm -o+w -printf "%M %p\n"
И не добавляйте safe.directory в git «чтобы не ругался»: он выключает ровно ту защиту, которая предупреждает, что root работает в чужом репозитории.
Сентябрь: CMS работала от root
Strapi слушал только 127.0.0.1, но процесс работал под root. Любая уязвимость в Strapi сразу давала бы root. Теперь у него свой пользователь, писать он может только в папку загрузок, .env — root:<группа CMS> 640. Для PM2 это uid/gid в ecosystem.config.js, и применяется он не restart, а pm2 delete <имя> + pm2 start … --only <имя>.
ps -eo user,comm,args | grep -E "node|php-fpm|python" | grep -v grep
Всё, что смотрит в сеть и работает под root, — кандидат на свою учётную запись.
Следующая ступень — песочница systemd. На втором сайте она стоит: код принадлежит root, процесс приложения пишет только в четыре папки данных, а юнит запрещает остальное:
[Service]
ProtectSystem=strict
ReadWritePaths=/var/www/shop/data /var/log/shop
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
RestrictSUIDSGID=yes
CapabilityBoundingSet=
Взломанный процесс не перепишет свой же код: файловая система для него — только чтение, кроме названных папок. Новый «писатель» в коде сразу видно — он получает EROFS, пока его папку не впишут. У CMS первого сайта такой песочницы пока нет — это в списке.
systemctl show <юнит> -p ProtectSystem -p ReadWritePaths -p NoNewPrivileges
systemd-analyze security <юнит> # оценка и что ещё закрыть
И мелочь, которую легко пропустить: pm2 save пишет окружение процессов — с ключами API — в ~/.pm2/dump.pm2. Проверьте права. Если PM2 работает от root, как у меня, — 600 и каталог, закрытый для остальных. Если PM2 работает от отдельного пользователя, а код принадлежит root, как на втором сайте, — 640 root:<пользователь pm2>: процесс читает снимок, чтобы подняться после перезагрузки, но переписать его не может. Иначе взломанный процесс сделал бы pm2 save со своей командой и пережил бы перезагрузку.
Октябрь: .git выката читали все пользователи сервера
Каталог .git на сервере имел права 775. Его читали все — пользователь веб-сервера, CMS, другие службы на сервере. А в истории git — те самые дампы и архив с .env из сентябрьского случая (секреты из него к тому времени уже были сменены): файлы из рабочего дерева я убрал, а пакет git на 340 МБ остался читаемым. chmod 750 .git, проверено от имени всех учёток сервера.
Там же нашлись 54 дампа полной базы с правами 644 — их мог прочесть любой пользователь сервера.
# что прочтёт любой пользователь: дампы, ключи, .env, .git
sudo find /var/www /home /root /opt /var/backups -type f -perm -o+r 2>/dev/null \
| grep -iE "\.sql|\.dump|\.gz$|\.env|id_rsa|id_ed25519|token|\.pem$" | head -50
sudo find / -type d -name .git -perm -o+rx 2>/dev/null | grep -v "^/usr/"
# проверка от имени непривилегированного пользователя
sudo runuser -u nobody -- test -r /path/to/file && echo "читается всеми"
Октябрь: символические ссылки, мёртвые прокси и загрузки под 'self'
Один сплошной осмотр Nginx дал три находки.
Nginx ходил по символическим ссылкам. Ссылка на /etc/hostname, положенная в папку загрузок, отдавалась кодом 200. Любой, кто может положить ссылку в отдаваемый каталог (загрузка архива, плагин, соседний процесс), читал бы через сайт файлы сервера. Одна строка:
disable_symlinks on from=$document_root;
# проверка: ссылка в отдаваемом каталоге должна дать 403/404, а не 200
sudo ln -s /etc/hostname /var/www/site/public/proba-link
curl -s -o /dev/null -w "%{http_code}\n" --resolve example.com:443:127.0.0.1 https://example.com/proba-link
sudo rm /var/www/site/public/proba-link
Шесть proxy_pass вели на порты, где уже никто не слушал — остатки старых служб. Кто первым поднимет процесс на таком порту — хоть непривилегированный пользователь на порту выше 1024 — тот и отвечает посетителям под вашим доменом. Мёртвые location удалены.
for p in $(sudo nginx -T 2>/dev/null | grep -oE "proxy_pass https?://(127\.0\.0\.1|localhost):[0-9]+" | grep -oE "[0-9]+$" | sort -u); do
ss -ltn "sport = :$p" | grep -q LISTEN && echo "порт $p: слушают" || echo "порт $p: НИКТО НЕ СЛУШАЕТ"
done
Загрузки CMS попадали под script-src 'self' — об этом выше, в части 2: теперь только картинки и своя политика с sandbox.
CMS не торчит наружу
Strapi нужен только во время сборки. Он слушает 127.0.0.1:1337, наружу открыты только 80, 443 и SSH на нестандартном порту, вход только по ключу. Прокси /api/cms/, через который когда-то ходила сборка, закрыт. Админку я открываю через SSH-туннель:
ssh -L 1337:127.0.0.1:1337 user@server
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/admin/ # снаружи — 404
sudo ss -ltnp | grep -vE "127\.0\.0\.1|\[::1\]" # что слушает не только localhost
sudo sshd -T | grep -E "^(passwordauthentication|permitrootlogin|pubkeyauthentication)"
Для PHP-сайта это было бы самое ценное в статье: если панель управления не отвечает из интернета, уязвимость в ней не эксплуатируется из интернета.
Домен: то, что не на сервере
Сервер можно закрыть идеально, а сайт уведут на уровне домена: кто управляет DNS, тот направит адрес и почту куда захочет. Здесь у меня закрыто не всё, и это честный список:
вход в DNS-панель (Cloudflare) — с двухфакторной аутентификацией; ключи API от неё, найденные в истории git, удалены;
DNSSEC: зона подписана у Cloudflare (записи
DNSKEYесть), но записьDSу регистратора не поставлена — посредник, через которого куплен домен, её не принимает. Цепочка доверия не замкнута, и резолверы подпись не проверяют;CAA заведена (
0 issue "letsencrypt.org"), но Cloudflare, пока включён его бесплатный сертификат, сам добавляет к ней свои удостоверяющие центры — снаружи их видно пять. Проверяйте не панель, а ответ DNS.
И урок, который стоил мне вечера: прежде чем убирать «лишнюю» запись почты (SPF, DKIM), спросите, кто через неё отправляет. Сервер может ею не пользоваться, а почтовый ящик — пользоваться: у меня письма от имени домена уходят из Gmail через внешний SMTP, и «уборка» SPF оставила меня без отправки, пока я не вернул записи.
Проверить у себя — без доступа к панелям:
dig +short CAA example.com # кому разрешено выпускать сертификаты
dig +short DS example.com # есть ли DS у регистратора
dig +dnssec example.com | grep -E "flags:.* ad" # флаг ad — резолвер проверил подпись
CAA — 0 issue "letsencrypt.org" в DNS-панели; если DNS у Cloudflare, а сайт идёт мимо него (серое облако), бесплатный сертификат Cloudflare можно выключить — тогда в CAA останется только ваш УЦ. DS Cloudflare показывает в настройках DNSSEC, её вставляют в панели регистратора — если регистратор или посредник это поддерживает; уточните до покупки домена. Плюс блокировка переноса домена у регистратора, автопродление и двухфакторная — там же.
fail2ban и обратная его сторона
Шесть клеток: sshd, nginx-404, nginx-badbots, nginx-http-auth, nginx-limit-req, nginx-scraping. Самая полезная для статики — nginx-404: 10 промахов за 10 минут — бан на 6 часов. Сканеры перебирают /wp-login.php, /.env, //wp-content/plugins/…, у статического сайта таких адресов нет, и каждый перебор — промах.
Ряженых роботов не банить, а отдавать им кэш. На втором сайте за неделю пришло 11 952 запроса с User-Agent «Googlebot/2.1», и только 363 — с адресов Google; остальные 97 % — чужие сборщики, которые представляются Гуглом, чтобы их реже банили. Банить по User-Agent нельзя: настоящий и ряженый выглядят одинаково, а ошибка стоит места в индексе. Там сделано иначе: всем, кто по User-Agent похож на робота, Nginx отдаёт страницу из своего кэша — собранную не больше минуты назад; люди получают свежую (map $http_user_agent … → proxy_cache_bypass для людей). Повод — скорость: приложение там — один процесс Node, и под наплывом роботов страница собиралась в среднем 5,2 с против 0,002 с у отдачи из кэша Nginx. Считать роботов — только по опубликованным сетям вендоров (googlebot.json у Google, searchbot.json у OpenAI), не по User-Agent. Грубая прикидка по журналу:
# «Googlebot» по адресам: 66.249.* — грубо; точно — сверять с googlebot.json
zcat -f /var/log/nginx/access.log* | awk -F'"' '$6 ~ /Googlebot\/2.1/ { split($1,a," ");
print (a[1] ~ /^66\.249\./) ? "настоящий" : "чужой" }' | sort | uniq -c
Обратная сторона: она банит и вас, если проверять мёртвые адреса снаружи. Один раз я так забанил себя, проверяя сайт, и решил, что сервер лежит. Мёртвые адреса проверяйте с самого сервера через --resolve. А если бан всё же прилетел, а SSH жив, снять его можно самому:
sudo fail2ban-client status nginx-404
sudo fail2ban-client set nginx-404 unbanip <ваш IP>
Сторожа: защита, которую никто не проверяет, ломается молча
Гейты выката — новая сборка не подменяет живой сайт, пока не пройдёт:
в сборке нет значений из
.envи ключей узнаваемого формата;нет инлайн-кода, не покрытого политикой, — проверка читает живой заголовок CSP;
npm audit: критичная уязвимость с доступным исправлением — стоп.
Сторож на сервере раз в сутки сверяет сервер с записанной нормой: исполняемые файлы в веб-корнях и папке загрузок, SVG со скриптом в загрузках, изменённые файлы системных пакетов (dpkg -V), /etc/ld.so.preload, учётные записи и ключи SSH, автозапуск, чужие процессы и соединения, новые администраторы и токены CMS, дерево кода против коммита, новые находки npm audit. Новое — не обязательно взлом, но человек обязан посмотреть. Тревога — в Telegram.
Сторожа смотрят и друг за другом. Резервная копия сервера снимается на мой домашний компьютер, и сторож на сервере следит за её свежестью. 26 октября он прислал «копия с ПК — беда, последняя 31 ч назад»: программа копирования на компьютере остановилась без единой записи в журнале. Сторож сработал как надо — а я прочитал тревогу только через полтора дня, когда сел писать эту статью. Самое слабое звено в цепочке тревог — тот, кто их читает.
Однажды он поднял тревогу «следы взлома», и она была ложной: шаблон узнал «секрет» в файле самой проверки. Я разобрал её и поправил сторожа, но выключать не стал: молчащий сторож хуже шумного.
И честно о слабом месте, найденном за этой статьёй: сторож, который сообщает только о смене состояния, молчит, когда беда становится хуже. Так прошла мимо критичная DOMPurify (часть 1). Правило «ухудшение — тревога заново» добавлено, и проверено от обратного: со сломанным правилом проба краснеет.
Чек-лист: всё из статьи одним списком
ОБНОВЛЕНИЯ
[ ] apt-get changelog <пакет> | grep CVE — проверять CVE, а не номер версии
[ ] apt-cache policy nginx php*-fpm openssl — Installed = Candidate
[ ] unattended-upgrades: журнал, таймер, --dry-run; помнить про окно до суток
[ ] Node из NodeSource и подобного — обновлять руками, перезапускать процессы
[ ] apt list --upgradable | grep -- -security — ждёт дольше суток = тревога
[ ] после обновления — /proc/*/exe «(deleted)» пусто: PHP-FPM, Node перезапущены
[ ] npm audit --omit=dev; закреплённое зависимостью —
overrides с ^, проба на копии
[ ] sharp: require('sharp').versions.rsvg ≥ 2.63.2, если принимаете SVG
[ ] Brotli: libbrotli1 и Python-пакеты с заплаткой 111497;
/proc/*/maps без «libbrotli (deleted)»
ЕСЛИ PHP
[ ] find изменённых .php и .php в uploads;
wp core/plugin verify-checksums; список администраторов
САЙТ
[ ] счётчики: браузером с согласием — уходят ли запросы? Не нужен — снять
[ ] CSP без 'unsafe-inline' и 'unsafe-eval'; рядом Report-Only с report-uri
[ ] отчёты CSP — сводкой раз в неделю;
Метрика — все страновые mc.yandex.* по списку Яндекса
[ ] сборка: 0 исполняемых инлайн-<script>, 0 style=, 0 on*=
[ ] загрузки: только картинки и своя CSP с sandbox
[ ] add_header в location — с include сниппета безопасности; sites-enabled — ссылка
СЕРВЕР
[ ] панель CMS снаружи — 404; CMS на 127.0.0.1 и не от root
[ ] песочница systemd; dump.pm2 — 600 (или 640 root:<pm2>)
[ ] дерево сайта не принадлежит веб-серверу; .env — 640; без safe.directory
[ ] find -perm -o+r: нет дампов, ключей и .git, читаемых всеми
[ ] секреты из истории git — перевыпущены (gitleaks / trufflehog)
[ ] disable_symlinks on; server_tokens off; мёртвых proxy_pass нет
[ ] fail2ban nginx-404; мёртвые адреса — проверять с сервера
ДОМЕН
[ ] DNS-панель и регистратор — двухфакторная; блокировка переноса; автопродление
[ ] dig CAA / DS: CAA только на ваш УЦ; DNSSEC с DS у регистратора (флаг ad)
[ ] почта: прежде чем убирать SPF/DKIM — выяснить, кто через них отправляет
СТОРОЖА
[ ] сторож пишет вам сам — и вы проверяли, что он ловит, подложив нарушение
[ ] беда стала хуже — тревога заново; тревоги кто-то читает каждый день
Ограничения
Слов «сайт полностью защищён» я писать не буду — две строки «чёрного октября» это наглядно показали. Закрыты целые классы: выполнение кода на запрос посетителя, инлайн-XSS, чужие скрипты без спроса, кликджекинг, чтение секретов соседними пользователями, исполнение загрузок. Остаются Strapi на Node.js (с отложенным мажорным обновлением и без песочницы systemd), сама Метрика как сторонний скрипт, Trusted Types, ещё не включённый в основную политику, и домен: CAA пока разрешает не одного Let’s Encrypt, а DNSSEC не замкнут. Всё выше — про один небольшой сайт. Ваши формы и интеграции потребуют своих исключений, и каждое лучше добавлять по замеру.
Что дальше
Журналы Nginx, которые я читал ради fail2ban, показали ещё одно: какие страницы перечитывает робот поиска ChatGPT, и что на моём втором сайте — небольшой витрине цифровых товаров skoropay.shop — именно он приносит продажи. Об этом — следующая статья.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.