Публикуем секреты в Production без регистрации и SMS
- пятница, 11 сентября 2026 г. в 00:00:17

Тесты зелёные, линтер молчит, 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_, к которым обращается приложение, становятся частью клиентского кода. Поэтому хранить там секрет нельзя.
Пропустим этап написания остального кода и предположим, что проект готов к развёртыванию. Собираем проект с помощью Vite 8.2.2 и получаем следующий результат:
Файл | Размер в этом запуске |
|---|---|
| 438 байта |
| 1059 байтов |
| 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, это ещё не готовый отчёт и не список уязвимостей. Это лишь материал для следующего этапа: нужно открыть каждую находку, понять её назначение и решить, должна ли она присутствовать в публичной сборке.
Две Debug-строки я добавил не случайно. Они служат контрольными маркерами: одна нужна приложению, другая находится в ветке только для разработки. По результатам сборки можно проверить, как Vite поступил с каждым случаем.
Чтобы не смешивать JavaScript с картой исходников, ограничим поиск файлами .js:
Get-ChildItem -Path dist -File -Force -Recurse -Filter *.js | Select-String -Pattern 'secret|token|api|staging|debug|sourceMappingURL'

Находка | Есть в .js | Что означает находка |
|---|---|---|
Production-адрес API | да | Публичная конфигурация, необходимая клиенту для запросов |
| да | Публичный тестовый ключ |
| да | Секретный ключ, которому не место в клиентском коде |
Staging API URL | да | В Production-сборку попал адрес другого окружения |
Feature Flag | да | Логика фичи полностью открыта клиенту |
Production Debug-строка | да | Служебное сообщение |
Dev-Only Debug-строка | нет | — |
Вот тут и начинается самое интересное. Одинаковая галочка «Да» в таблице может означать совершенно разные вещи — от «так и задумано» до «срочно отменяем развёртывание, пока нас не взломали».
Разберём находки по порядку, начиная с адреса 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_, ему в клиентской сборке делать нечего.
Далее — ключ https://staging-api.example.test/v1. Сам по себе адрес тестового сервера не является секретом (хотя лишний раз светить его наружу не стоит). Проблема в том, что мы проверяем Production-артефакт, а внутри лежит конфигурация другого окружения!
На нашем стенде это просто маркер, но в реальном проекте такая ситуация вызывает вопросы:
Почему прод-клиент вообще знает этот адрес?
Забыли вычистить после отладки?
Конвейер подтянул не тот .env?
Или URL действительно нужен приложению?
Сборщик сохранил переменную VITE_EXPERIMENTAL_CHECKOUT=true. Приложение запрашивает её, и Vite подставляет в бандл. Для интерфейсных фича-флагов это обычная практика. Но важно помнить: ничего из того, что лежит в .env.production, нельзя считать скрытым.
Пользователь легко может открыть DevTools, изменить значение флага или вызвать закрытый метод API напрямую.
Фронтендовый фича-флаг подходит только для управления интерфейсом. Если эксперимент даёт доступ к платным или приватным функциям, проверка должна дублироваться на бэкенде.
В рабочем JS остался служебный маркер: DEBUG_RETAINED_FALLBACK_TO_V1. Он не раскрывает доступов, но наглядно показывает: служебный текст легко попадает в прод, если используется в исполняемом коде.
В маленьком тесте это мелочь, но в крупном проекте такие строки могут раскрывать внутренние режимы, старые фоллбеки, временные обходные пути или детали незавершённых экспериментов.
Если строка нужна для мониторинга или диагностики — оставляем её осознанно. Если это забытый след отладки — вычищаем.
Казалось бы, с 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 как к «чёрному ящику». Заглядывайте туда чаще — там можно найти много интересного ещё до того, как релиз станет публичным.