InquirerDOT eyes new diving tourism spot in Misamis OrientalCNN TürkHava Durumu (29-10-2026)PunchAkpabio alleges payment for social media attacks on TinubuESPN DeportesAmérica extiende racha sin ganar en el Banorte한겨레“아빠, 보고싶어”…무면허 지게차 사고 유족, 진상 규명 요구ZDF heuteAktuelle Pressemitteilungen des ZDFThe South AfricanOrlando Pirates expected starting XI against Kaizer ChiefsGolem.deApple: Mac Studio mit 512 GB RAM kostet mehr als 25.000 Euro3DNews«Да, мы вайбкодим»: Epic Games подтвердила повсеместное использование ИИ в разработке Fortnite, Unreal Engine 6 и не толькоSportstarLockie Ferguson ruled out of New Zealand vs India T20I series; Replacement namedMyJoyOnlineThe Inconvenient Truth: One Africa in our speeches, 54 agendas in our decisionsHet Laatste NieuwsEerste dag van nationale rouw gestart, geluidsopnames onthullen dat verkeersleider in de fout gegaan is
The Daily Newsstand · Free, Always
Thursday, October 29, 2026

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

Translate

У меня круглосуточно работают агенты мониторинга: программы, которые по кругу собирают данные по моим проектам, в том числе бюллетени безопасности, и сверяют их с тем, что стоит на серверах. На очередном круге они подняли октябрьскую сводку: 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

переполнение буфера при alias + rewrite в одном location

нет такой конфигурации; пакет всё равно был старый

Nginx CVE-2026-93479

переполнение в вычислении переменных; по nginx.org — ветки 1.30–1.31, Ubuntu закрыла и в 1.24

был не обновлён

Nginx CVE-2026-97205

use-after-free при aio

нет aio; пакет был старый

Node CVE-2026-111497

Brotli в zlib Node, вплоть до выполнения кода

был открыт 4 дня

та же CVE в системном libbrotli1

ошибка в самой библиотеке Brotli; Ubuntu выпустила заплатку 25.10

была старая — обновил вместе с Nginx

Node CVE-2026-105482, 106069

проверка входа в dns.resolve, неканонические IP в TLS

закрыто тем же 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

выполнение кода без входа в функциях use cache (16.0–16.4.0)

нет 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 — именно он приносит продажи. Об этом — следующая статья.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.