Files
portal/docs/observer/notes/2026-07-18-finder-csp-inline-script-fix.md
T
Дмитрий 1514991ceb @
style(sales): Pint — порядок импортов в SalesClientsController

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 14:40:02 +03:00

5.7 KiB
Raw Blame History

Поиск клиентов был мёртв с 15.07 — CSP резал встроенный скрипт

Дата: 18.07.2026 · Где: моя/sales-finder (служба /opt/sales-finder, lk.liderra.ru/finder/)

Симптом (со слов владельца)

«Вкладки по отраслям пропали», «выбираю — не работает». На скриншоте: выбран раздел («Автотовары», потом «Медицина»), второй список не появляется, кнопка «Собрать список» серая.

Корень

Вся логика страницы жила во встроенном <script> внутри HTML (плюс атрибут onsubmit="return confirm(...)" у крестика удаления). 15.07.2026 в 10:46 UTC службу завели за nginx lk.liderra.ru (location ^~ /finder/, SF_BASE_PATH=/finder). Там на уровне server висит

script-src 'self' https://smartcaptcha.cloud.yandex.ru https://yastatic.net https://*.yandex.ru https://*.yandex.net

— без 'unsafe-inline', без nonce/hash. Внутри location своих add_header нет, значит CSP наследуется целиком. Браузер молча не исполняет ни встроенный скрипт, ни on*=-атрибуты. style-src при этом содержит 'unsafe-inline' — поэтому страница выглядела целой, но была мёртвой.

Подтверждение датой: из 87 прогонов все done, последний создан 14.07.2026 10:24 UTC — за день до правки nginx. После неё — ни одного нового сбора.

Второй, тихий дефект того же корня: подтверждение удаления не срабатывало → крестик ✕ удалял список молча с первого клика.

Про «вкладки по отраслям»: их не было никогда. До 14.07 разделы 2ГИС показывались группами <optgroup> в одном списке (коммит 87db3bc5), 14.07 коммит c864fbd0 заменил их каскадом «раздел → ниша → глубже». Это отдельный вопрос UX, к поломке отношения не имеет.

Что сделано

  • JS вынесен в web/static/app.js, отдаётся маршрутом GET /app.js (application/javascript, Cache-Control: no-cache). Файл со своего домена CSP пропускает по 'self' — защиту портала не ослабляли.
  • Данные (дерево рубрик, карта «уже собрано», адрес просмотра) едут в data-атрибутах #cascade (data-catalog / data-done / data-viewurl) — атрибуты CSP не касается. Выбран этот способ, а не <script type="application/json">, чтобы не зависеть от трактовки «неисполняемых» script-блоков браузерами.
  • onsubmit-подтверждение заменено делегированным обработчиком в app.js.

Проверка

  • Тесты: 252 зелёных (pytest), из них 13 новых/переписанных — сначала RED, потом GREEN. Ключевые: нет <script> и on*= в HTML, данные читаются из атрибутов, /app.js отдаётся как JS с no-cache.
  • Живой браузер под боевым CSP: локальный двойник с точь-в-точь прод-заголовком (Playwright): выбор «Медицина» → появился второй список, «Частные стоматологии» → третий, rubric=chastnye-stomatologii, кнопка disabled=false, 85 зелёных городов, встроенных скриптов 0, в консоли только 404 favicon.
  • На бою после выката: страница 200, встроенных <script> 0, on*= 0, <script src="/finder/app.js">, /finder/app.js → 200 application/javascript no-cache.

Выкат

Прод не под git → перед копированием сверил sha256: боевые ui.py/app.py были байт-в-байт равны HEAD (чужого на проде не было). Бэкап /tmp/sf-backup-csp-20260718-113602.tgz + копии /tmp/ui.py, /tmp/app.py. Скопированы 3 файла, chown www-data, systemctl restart sales-finder (11:37:08 UTC, стартовала чисто).

Откат: распаковать бэкап в /opt/sales-finder/web и перезапустить службу.

Грабли на будущее

  • 🪤 Любая страница за lk.liderra.ru не имеет права на встроенный JS и on*=-атрибуты. Отдельная служба за прокси наследует CSP портала целиком.
  • 🪤 Проверка «служба жива + логов ошибок нет» ничего не доказывает для фронта: поломка случается в браузере, до сервера запрос не доходит. Смотреть глазами/браузером.
  • 🔑 Дата последнего успешного действия в БД — хороший датчик: разрыв совпал с правкой nginx с точностью до дня.