Тесты зелёные, линтер молчит, pull request одобрен. Можно выкатывать — главное, чтобы ничего не упало, но мы ведь 100 раз протестировали, изучили pull request, запустили линтер и уверены в качестве кода.

Но опасность поджидала там, где её совсем не ждали.

Недавно мне попалась статья, в которой автор разбирал JavaScript на чужом сайте — просто просматривал Source‑файлы, которые сайт отдаёт браузеру. Он обнаружил там API-ключи, хранящиеся прямо в JS. Меня поразило, насколько легко их можно найти. Если любой посетитель может открыть вкладку Source и изучить содержимое файлов, то что увидит он в файлах моего сайта?

О том, что я нашёл у себя, рассказывать не буду. Сегодня мы поговорим о том, что мало кто из разработчиков проверяет готовые чанки и исходники. А ведь именно там может скрываться много неожиданного. Вы уверены, что точно знаете, что содержится в вашем Production-артефакте? Не в .env.production и не в конфигах Vite, а в реальных строках и файлах, которые прошли минификацию.

Чтобы вместе проверить это, я подготовил тестовый релиз, в который специально включил:

  • Публичную конфигурацию и адрес Dev-сервера

  • Клиентский Feature Flag

  • Две Debug-строки

  • Секретный ключ

Давайте посмотрим, что из этого утекло в итоговую сборку.

Добавляем контрольные маркеры

Для эксперимента создадим намеренно неидеальный релиз. В нём будут присутствовать как стандартные клиентские значения, так и следы тестового окружения, а также фиктивный секрет. Это позволит проверить не только поиск строк, но и способность отличать настоящие проблемы от ожидаемых частей приложения.

Запишем эти значения в .env.production:

VITE_PUBLIC_API_URL=https://api.production.example.test/v1
VITE_PUBLISHABLE_KEY=pk_test_FAKE_ARTICLE_ONLY
VITE_SECRET_KEY=sk_test_FAKE_ARTICLE_ONLY
VITE_STAGING_API_URL=https://staging-api.example.test/v1
VITE_EXPERIMENTAL_CHECKOUT=true

Чтобы переменные действительно участвовали в сборке, используем их в src/main.js:

const clientConfig = { 
  apiUrl: import.meta.env.VITE_PUBLIC_API_URL, 
  publishableKey: import.meta.env.VITE_PUBLISHABLE_KEY, 
  secretKeyMistake: import.meta.env.VITE_SECRET_KEY, 
  stagingApiUrl: import.meta.env.VITE_STAGING_API_URL, 
  experimentalCheckout: import.meta.env.VITE_EXPERIMENTAL_CHECKOUT, 
  diagnosticLabel: "DEBUG_RETAINED_FALLBACK_TO_V1"
};

if (import.meta.env.DEV) { 
  console.debug(“DEBUG_DEV_ONLY_REMOVED_FROM_JS”); 
}

document.querySelector(“#inventory”).textContent = JSON.stringify(clientConfig, null, 2);

Объект clientConfig выводится на страницу, значит, его значения нужны приложению и должны сохраниться в JavaScript.

Рядом есть console.debug(“DEBUG_DEV_ONLY_REMOVED_FROM_JS”), но консоль вызывается только при import.meta.env.DEV. В Production эта ветка не нужна, и сборщик должен удалить её из исполняемого файла.

Vite подставляет значения import.meta.env во время сборки. Переменные с префиксом VITE_, к которым обращается приложение, становятся частью клиентского кода. Поэтому хранить там секрет нельзя.

Собираем чистый Dist

Пропустим этап написания остального кода и предположим, что проект готов к развёртыванию. Собираем проект с помощью Vite 8.2.2 и получаем следующий результат:

Файл

Размер в этом запуске

index.html

438 байта

assets/index-BkzHq7ag.js

1059 байтов

assets/index-BkzHq7ag.js.map

814 байтов

Начинаем с инвентаризации файлов, запустив команду:

Get-ChildItem -Path dist -File -Recurse | Select-Object -ExpandProperty FullName
Выводим список всех файлов бандла
Выводим список всех файлов бандла

Многие пропускают этот шаг и сразу начинают искать секреты. Однако сначала важно понять, из чего состоит релиз. Помимо JS, там могут оказаться забытые JSON-конфиги, случайно попавшие Source Maps и другие «сюрпризы», которые не видны при просмотре исходников.

Далее ищем значения, которые могут рассказать чуть больше о сборке:

Get-ChildItem -Path dist -Recurse -Filter *.js | Select-String -Pattern 'secret|token|api|staging|debug|sourceMappingURL'

Получаем такой вывод. Я выделил место, где видны значения наших файлов. Ответ утилиты — это не готовый отчёт об инциденте, а лишь сырой инвентарь, который нам предстоит разобрать.

Только без паники! Если в терминале вы увидели слово api, это ещё не готовый отчёт и не список уязвимостей. Это лишь материал для следующего этапа: нужно открыть каждую находку, понять её назначение и решить, должна ли она присутствовать в публичной сборке.

Что осталось в JavaScript

Две Debug-строки я добавил не случайно. Они служат контрольными маркерами: одна нужна приложению, другая находится в ветке только для разработки. По результатам сборки можно проверить, как Vite поступил с каждым случаем.

Чтобы не смешивать JavaScript с картой исходников, ограничим поиск файлами .js:

Get-ChildItem -Path dist -File -Force -Recurse -Filter *.js | Select-String -Pattern 'secret|token|api|staging|debug|sourceMappingURL'

Результаты анализа JS-бандла

Находка

Есть в .js

Что означает находка

Production-адрес API

да

Публичная конфигурация, необходимая клиенту для запросов

pk_test_…

да

Публичный тестовый ключ

sk_test_…

да

Секретный ключ, которому не место в клиентском коде

Staging API URL

да

В Production-сборку попал адрес другого окружения

Feature Flag

да

Логика фичи полностью открыта клиенту

Production Debug-строка

да

Служебное сообщение

Dev-Only Debug-строка

нет

Вот тут и начинается самое интересное. Одинаковая галочка «Да» в таблице может означать совершенно разные вещи — от «так и задумано» до «срочно отменяем развёртывание, пока нас не взломали».

Разберём находки по порядку, начиная с адреса API и ключей.

Адрес API в сборке — ожидаемая находка

Начнём с Production URL:

https://api.production.example.test/v1

Браузеру нужно знать, куда отправлять запросы. Если URL зашит в клиентский конфиг, вполне логично, что он появится в итоговом JS.

Главный вывод: мы ищем не отсутствие адресов, а соответствие реального содержимого нашим ожиданиям.

Два похожих ключа — разные полномочия

В JavaScript обнаружены два ключа:pk_test_FAKE_ARTICLE_ONLY и sk_test_FAKE_ARTICLE_ONLY. На первый взгляд они отличаются только префиксом, но для приложения имеют совершенно разное значение.

  • pk_— публичный ключ. Он предназначен для работы в браузере и не даёт админских прав. Его присутствие в JS — обязательное условие корректной работы.

  • sk_— приватный ключ сервера. Он подтверждает серверные запросы к Stripe и предоставляет доступ к операциям аккаунта. Даже если это тестовый ключ с префиксом sk_test_, ему в клиентской сборке делать нечего.

Staging URL: не секрет, но неприятно

Далее — ключ https://staging-api.example.test/v1. Сам по себе адрес тестового сервера не является секретом (хотя лишний раз светить его наружу не стоит). Проблема в том, что мы проверяем Production-артефакт, а внутри лежит конфигурация другого окружения!

На нашем стенде это просто маркер, но в реальном проекте такая ситуация вызывает вопросы:

  • Почему прод-клиент вообще знает этот адрес?

  • Забыли вычистить после отладки?

  • Конвейер подтянул не тот .env?

  • Или URL действительно нужен приложению?

Фича-флаг виден всем

Сборщик сохранил переменную VITE_EXPERIMENTAL_CHECKOUT=true. Приложение запрашивает её, и Vite подставляет в бандл. Для интерфейсных фича-флагов это обычная практика. Но важно помнить: ничего из того, что лежит в .env.production, нельзя считать скрытым.

Пользователь легко может открыть DevTools, изменить значение флага или вызвать закрытый метод API напрямую.

Фронтендовый фича-флаг подходит только для управления интерфейсом. Если эксперимент даёт доступ к платным или приватным функциям, проверка должна дублироваться на бэкенде.

Debug-строка, пережившая минификацию

В рабочем JS остался служебный маркер: DEBUG_RETAINED_FALLBACK_TO_V1. Он не раскрывает доступов, но наглядно показывает: служебный текст легко попадает в прод, если используется в исполняемом коде.

В маленьком тесте это мелочь, но в крупном проекте такие строки могут раскрывать внутренние режимы, старые фоллбеки, временные обходные пути или детали незавершённых экспериментов.

Если строка нужна для мониторинга или диагностики — оставляем её осознанно. Если это забытый след отладки — вычищаем.

Внезапный поворот: строка исчезла из JS, но осталась в релизе

Казалось бы, с JavaScript всё ясно. Вторая Debug-строка (DEBUG_DEV_ONLY_REMOVED_FROM_JS) успешно исчезла из чанка благодаря оптимизатору. Но мы проверяли только .js, а рядом лежит .map...

Запускаем отдельный поиск по картам Source Map:

Get-ChildItem -Path dist -Recurse -Filter *.map | Select-String -Pattern ‘DEBUG_DEV_ONLY_REMOVED_FROM_JS|sourcesContent’

И строка оказывается на месте:

if (import.meta.env.DEV) {
 console.debug("DEBUG_DEV_ONLY_REMOVED_FROM_JS");
}

В исполняемом JS её действительно нет, но из релизного артефакта она никуда не делась. Причина проста: задача сборщика — удалить мёртвый код из исполняемого файла, а задача Source Map — сохранить исходный код в первозданном виде (в секции sourcesContent), чтобы вы могли удобно отлаживать ошибки.

Итоги эксперимента

Мы начали с простого поиска строк в сборке, а в итоге получили чёткое разграничение уровней ответственности:

  • Исходный код показывает, что мы планировали отправить пользователю

  • Production-артефакт демонстрирует, что CI действительно сгенерировал

  • Процесс развёртывания определяет, что из этого реально попадёт к пользователю

Не относитесь к папке dist как к «чёрному ящику». Заглядывайте туда чаще — там можно найти много интересного ещё до того, как релиз станет публичным.

Комментарии (0)