javascript

Картинка грузится, а fetch к тому же хосту — нет: как CSP тихо сломала три интеграции

  • среда, 5 августа 2026 г. в 00:00:06
https://habr.com/ru/articles/1066450/

Полгода назад я закрыл замечание аудита «на сайте нет CSP» — прописал
Content-Security-Policy с белым списком хостов, проверил, что ничего
не отвалилось, и забыл. За три дня на прошлой неделе я нашёл три интеграции, которые эта
политика убила молча. Ни одна из них не оставила следа на сервере.

Это заметка про то, почему такие поломки не видно, и про пятистрочный скрипт,
который находит их все сразу.

Симптом

У меня есть страница «МКС онлайн»: живая позиция станции — широта, долгота,
высота, скорость. Позиция считается в браузере: SGP4 по свежим элементам NORAD,
пересчёт раз в секунду.

Пользователь пожаловался, что карта пустая. Я открыл страницу и снял три замера
подряд:

  t+1s :  пикселей 99  |  широта 42,71°                                                                                                                      
  t+4s :  пикселей 99  |  широта 42,71°                                                                                                                      
  t+10s:  пикселей 99  |  широта 42,71°                                                                                                                      

Число не менялось. Страница, которая называет себя «в реальном времени», стояла
намертво — и стояла так у всех, с того самого дня, как я включил CSP.

Причина

В консоли:

  Refused to connect to 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544'                                                                            
  because it violates the Content Security Policy directive: "connect-src ..."                                                                               

connect-src не содержал celestrak.org. Значит fetch за TLE отклонялся,
satrec никогда не создавался, цикл на 1 Гц не запускался ни разу.

Дальше я проверил остальные внешние хосты и нашёл ещё два: ipwho.is
(IP → город, из этого строится «ближайший пролёт рядом с вами») и CDN NOAA,
откуда берётся снимок Земли с погодного спутника.

Почему это не видно

Три причины складываются в идеальную слепую зону.

1. img-src и connect-src — разные директивы, и первая обычно шире.

  img-src 'self' data: blob: https:                                                                                                                          
  connect-src 'self' https://us.i.posthog.com https://api.stripe.com ...                                                                                     

С такой политикой картинка с cdn.star.nesdis.noaa.gov грузится прекрасно.
А fetch к тому же самому хосту — чтобы прочитать заголовок Last-Modified и
показать время съёмки — отклоняется. Изображение на экране есть, подпись под ним
пустая. Выглядит как мелкий баг вёрстки, а не как заблокированный запрос.

2. Нарушение CSP происходит в браузере. На сервере его нет.

Мой хостинг вообще не логирует HTTP‑запросы: из ста строк лога ровно одна
содержала статус. Запрос, который браузер не отправил, не оставит следа нигде,
кроме консоли конкретного пользователя.

3. Мониторинг смотрел не туда.

Мой сторож грепал последние 100 строк лога — это около 56 минут на боевом
сервисе. Ошибка, которая случается раз в сутки, попадает в это окно примерно в
4% случаев. А фронтовые исключения я собирал через $exception — но
заблокированный CSP fetch не бросает необработанное исключение, если код
обёрнут в try/catch. А он был обёрнут: «на случай, если сеть недоступна».

Именно поэтому все три интеграции деградировали ровно так, как я их и написал —
аккуратно. Карта показывала пустое поле вместо позиции. Подпись под снимком
показывала пустоту вместо времени. Геолокация просила ввести город руками.
Ни одного алерта. Код вёл себя правильно; неправильной была моя уверенность,
что запрос вообще уходит.

Что с этим делать

Не «добавить хост» — это лечение симптома. Проверять весь список сразу.

Скрипт вытаскивает все https‑хосты, к которым обращается клиентский код, и
сверяет их с connect-src:

const csp = fs.readFileSync("next.config.ts", "utf8").match(/"connect-src[^"]*"/)[0];                                                                          
const allowed = [...csp.matchAll(/https:\/\/([a-z0-9.*-]+)/g)].map(m => m[1]);                                                                                 
                                                                                                                                                               
const hosts = new Set();                                                                                                                                       
for (const file of walk("components")) {                                                                                                                       
  const src = fs.readFileSync(file, "utf8");                                                                                                                   
  if (!/"use client"/.test(src.slice(0, 200))) continue;   // только клиентский код                                                                            
  for (const m of src.matchAll(/fetch\(\s*[`"']https:\/\/([a-z0-9.-]+)/g)) hosts.add(m[1]);                                                                    
  for (const m of src.matchAll(/URL\s*=\s*[`"']https:\/\/([a-z0-9.-]+)/g)) hosts.add(m[1]);                                                                    
}                                                                                                                                                              
                                                                                                                                                               
for (const h of hosts) {                                                                                                                                       
  const ok = allowed.some(a => a === h || (a.startsWith("*.") && h.endsWith(a.slice(1))));                                                                     
  if (!ok) console.log("БЛОКИРУЕТСЯ:", h);                                                                                                                     
}                                                                                                                                                              
                                                                                                                                                               
       

Тридцать секунд работы — и он нашёл третий хост, про который я ещё не знал.

Две вещи, которые я поменял насовсем:

  • report‑uri / report‑to в CSP. Браузер сам расскажет о нарушениях,
    и это единственный канал, который вообще существует для таких ошибок.

    • Тихий catch вокруг внешнего запроса — теперь повод остановиться и подумать. Он превращает отказ в пустое поле. Иногда это правильно; но тогда нужно, чтобы где‑то это фиксировалось.

    Вывод

    CSP не ломает страницу — она отнимает у неё функции. Разница в том, что упавшую
    страницу видно сразу, а страница без функции выглядит просто скучной. Мою
    «позицию в реальном времени» никто не считал сломанной: она показывала число.
    Просто всегда одно и то же.

    Если у вас есть CSP и есть клиентские fetch — прогоните скрипт. Тридцать
    секунд.