RE:NODE

Безопасность13 мин чтения

Переменные окружения и секреты на сервере приложений

API-ключи, пароли баз данных и токены ботов не место в репозитории. Куда их класть вместо этого, как заменить секрет и как они утекают даже при верной настройке.

Обновлено

0 прочтений

Чаще всего учётные данные утекают не из-за атаки. Причина - репозиторий, который был приватным, когда в него попал ключ, и стал публичным, когда кто-то его форкнул, а к тому времени ключ лежит в истории, а не в файле, и удаление строки не даёт вообще ничего. На втором месте - строка журнала, которая при запуске напечатала весь объект конфигурации. Ни то ни другое не хитрость, и оба случая предотвращаются одной привычкой: значение живёт на сервере, а не в том, что вы отправляете.

За этой привычкой стоит механизм, и у механизма есть острые края. Переменные окружения - не сейф: их читает процесс, всё, что он запускает, и любой, у кого есть доступ к вкладке, где они задаются. В этой статье - что они такое на самом деле, как задавать и читать их без догадок, что именно делать, если секрет уже закоммичен, и пять мест, где они утекают, даже когда всё остальное сделано правильно.

Что такое переменная окружения на самом деле#

Каждый процесс в системе Unix стартует с двумя вещами, которые ему передал запустивший его: аргументами и окружением. Окружение - это простой список пар NAME=value. Ваша программа читает его стандартной функцией, а каждый порождённый ею дочерний процесс наследует копию.

Отсюда четыре свойства, и все четыре важны:

  • Это строки. Нет чисел, булевых значений и null. DEBUG=false - это семисимвольная строка false, которая в большинстве языков истинна, если проверять её напрямую. Разбирайте значение осознанно.
  • Они задаются до старта процесса. Изменение на вкладке Startup ничего не делает, пока вы не перезапустите сервер. Это удивляет тех, кто ждёт перечитывания конфигурации.
  • Они наследуются. Shell-скрипт, запускающий ваше приложение, передаёт их дальше. Так же делает и подпроцесс, который вы запускаете для конвертации картинки. Если какая-то зависимость вызывает внешнюю программу, эта программа видит пароль вашей базы данных.
  • Они не зашифрованы. Они лежат в памяти процесса и, в Linux, в /proc для всего, что работает от того же пользователя. Их ценность в том, что они не попадают в дерево исходников и в журналы, а не в том, что заперты в ящике.

Альтернатива, к которой тянется большинство, - файл конфигурации в репозитории, и он подводит именно так, как описано выше, либо файл конфигурации вне репозитория, что нормально и примерно и есть файл .env. Окружение просто убирает файл из уравнения.

Как задавать: вкладка Startup и файлы .env#

На панельном хостинге переменные сервера находятся на вкладке Startup. Задайте имя и значение, перезапустите, и приложение прочитает его так же, как уже читает. В вашу рабочую копию ничего не записывается, так что следующий Git pull его не затрёт, а следующий push не раскроет.

Другой путь - файл .env, загруженный по SFTP и никогда не коммитящийся. Оба легитимны; разница в том, кто может их увидеть и что происходит при повторном развёртывании.

ГдеКто читаетПереживает Git pullПодходит для
Вкладка StartupЛюбой с этим правом в панелиДаОсновное место для секретов
.env на сервереЛюбой с доступом к файлам или SFTPДа, если в gitignoreДлинные или многострочные значения
.env в репозиторииВсе, навсегдаДаНи для чего

Если вы используете .env, запись в .gitignore появляется первой, до создания самого файла:

.gitignore
.env.env.*!.env.examplenode_modules/

Коммитьте .env.example со всеми ключами и без значений, чтобы следующий человек - включая вас через восемь месяцев - знал, что приложению нужно. Node читает .env нативно начиная с 20.6 командой node --env-file=.env server.js, так что пакет dotenv на современной версии не обязателен; в проектах на Python и Rust обычно используют python-dotenv и dotenvy.

Два правила форматирования, из-за которых бывают настоящие ошибки. Значения с пробелами, # или кавычками нужно заключать в кавычки, а разные парсеры расходятся в экранировании, так что по возможности делайте значения простыми. Многострочные значения - закрытый ключ, JSON сервисного аккаунта - вообще не место в переменной окружения; закодируйте их в base64 в одну строку или загрузите файл и передайте его путь в переменной.

Как читать без догадок#

В каждом языке есть один идиоматический способ доступа, и у каждого своя ловушка с отсутствующими значениями.

Node.js
const url = process.env.DATABASE_URL;          // undefined when unsetconst port = Number(process.env.PORT ?? 3000); // always parse numbersconst debug = process.env.DEBUG === "true";    // never trust the string
Python
import osurl = os.environ["DATABASE_URL"]       # raises KeyError when unset - goodport = int(os.getenv("PORT", "8000"))  # explicit defaultdebug = os.getenv("DEBUG", "").lower() in {"1", "true", "yes"}
Rust
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set");let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8080".into())    .parse().expect("PORT must be a number");

В PHP способ доступа - getenv(), а значения появляются ещё в $_ENV и $_SERVER в зависимости от настройки среды выполнения, что надёжный источник путаницы на тарифах для сайтов: предпочитайте getenv() и проверяйте.

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

javascript
const required = ["DATABASE_URL", "SESSION_SECRET", "DISCORD_TOKEN"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing environment variables: ${missing.join(", ")}`);  process.exit(1);}

Процесс, отказывающийся стартовать, - хороший исход. Процесс, который стартует с неопределённой DATABASE_URL и три дня молча пишет в локальный файл SQLite, - плохой.

Что должно лежать в окружении#

Всё, что даёт доступ к чему-либо, и всё, что различается между вашим ноутбуком и сервером.

  • Учётные данные базы данных. В этой панели слот базы данных генерирует собственные хост, пользователя и пароль, так что задача - скопировать строку подключения в переменную, а не придумывать её. Никогда не используйте её повторно для второго приложения.
  • API-ключи и токены для всего, что вы вызываете: платёжных провайдеров, отправщиков почты, объектного хранилища, API игры.
  • URL вебхуков. URL вебхука Discord или Slack - это секрет: любой, кто им владеет, может писать от вашего имени в этот канал, навсегда. Их вставляют в публичные issue чаще любого другого секрета.
  • Токены ботов. Самый часто утекающий секрет в этой отрасли. Токен Discord - это полный аккаунт; никаких ограничений области у него нет.
  • Секреты подписи и сессий. Ключ, которым ваш фреймворк подписывает cookie или JWT. Если он утёк, кто угодно может выпустить действительную сессию любого пользователя, а его смена разлогинивает всех, то есть даёт как раз тот размен, который стоит иметь в запасе.
  • Пароли RCON и администратора для игровых серверов. Здесь они генерируются на каждый сервер, и к ним стоит относиться так же серьёзно, как к ключу SSH - в статье безопасная работа с RCON объяснено, почему открытый порт RCON хуже, чем кажется.
  • Всё, что вы не напечатали бы на рекламном щите, - это проверка, которая покрывает случаи, пропущенные списком выше.

Чему там не место: большим файлам, всему, что нужно менять на ходу без перезапуска, и публичной конфигурации вроде флага функции, которому спокойно место в репозитории. И не кладите секреты в аргументы командной строки команды запуска: аргументы видны в списках процессов в той степени, в которой окружение видно чуть меньше, и они попадают в журналы.

Если секрет уже закоммичен#

Замените его. Это весь ответ, а всё остальное - хозяйственные хлопоты.

Удалить строку и снова сделать push не помогает. Старый коммит по-прежнему содержит значение и остаётся достижимым по своему хешу, даже после force-push во многих хостинговых схемах. У каждого, кто клонировал репозиторий, полная история лежит на диске. Автоматические сканеры непрерывно следят за публичными репозиториями и новыми коммитами, и реагируют они за секунды, а не часы.

Порядок действий:

  1. Выпустите новый секрет у провайдера. Сделайте это до отзыва старого, если провайдер позволяет существовать обоим, чтобы не было провала.
  2. Поместите новое значение на вкладку Startup и перезапустите приложение.
  3. Отзовите старый секрет. Этот шаг пропускают, потому что приложение снова работает. Старое значение остаётся действительным, пока вы этого не сделаете.
  4. Проверьте, к чему прикасался старый. Панели провайдеров показывают недавнее использование по ключу. Ищите вызовы, которых вы не делали, и вызовы с чужих адресов.
  5. Затем, по желанию, почистите историю с помощью git filter-repo или BFG. Это косметика. Она ничего не «разутекает», ломает каждый существующий клон и не заменяет шаг 3.
Секрет, пробывший в публичном репозитории пять минут, - публичный секрет. Сканеры быстрее вас.

GitHub сканирует публичные репозитории на известные форматы секретов и уведомляет провайдера, поэтому утёкший облачный ключ иногда отключают раньше, чем вы заметите. Не полагайтесь на это: покрываются только узнаваемые шаблоны участвующих провайдеров, а ваш собственный секрет подписи к ним не относится. Если вы нашли свидетельства, что секрет использовали, более широкая процедура описана в статье что делать, если ваш сервер взломали.

Смена секрета без простоя#

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

Два ключа одновременно - большинство провайдеров API, простой случай. Создайте второй ключ, разверните его, убедитесь по панели провайдера, что трафик идёт по новому, удалите первый. Ни простоя, ни согласований.

Только одно значение - пароли баз данных, секреты сессий. Здесь есть момент переключения, так что спланируйте его: смените пароль в базе, обновите переменную, перезапустите. В небольшом приложении это несколько секунд ошибок подключения, лучше всего делать, когда никто не смотрит. Для секрета сессий смиритесь с тем, что все будут разлогинены, и предупредите заранее, если это важно.

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

После любой смены делайте резервную копию базы до и после, если секрет её касался. Как сделать это без окна обслуживания, описано в статье резервные копии баз данных и восстановление.

Как они утекают всё равно#

Вы положили секрет в окружение, а он всё равно оказался в открытом месте. Пять путей в порядке того, как это действительно происходит:

Сборка вписала его в код. Инструменты фронтенда сознательно вшивают часть переменных окружения в бандл JavaScript, который отправляют браузерам. Всё, что называется NEXT_PUBLIC_, VITE_ или REACT_APP_, публично по замыслу, что бы ни содержало. Серверный API-ключ, которому дали такой префикс, чтобы он «заработал в компоненте», - это ключ, напечатанный в браузере каждого посетителя. Прежде чем выкатывать, поищите значение в бандле: если вы находите его поиском браузера, найдёт и любой другой.

Что-то записало конфигурацию в журнал. console.log(process.env) при отладке, обработчик ошибок, сериализующий весь контекст запроса, stack trace на странице ошибки фреймворка по умолчанию в продакшене. Отключите отладочный вывод в продакшене, а если ваша библиотека журналирования умеет скрытие значений, перечислите в ней свои секретные ключи.

Он попал на скриншот или в тикет. Разговоры в поддержке, отчёты об ошибках, стримы. Тикет в панели здесь виден всем сотрудникам, а вложения в нём приватны, и это правильный канал для секрета, которым приходится делиться, - но всё, что побывало в чате, считайте уже замененным.

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

Кто-то с доступом к панели прочитал его. Это не столько утечка, сколько право, которое вы выдали; следующий раздел - о том, как его сузить.

Кто может читать их в панели#

Окружение защищено границей аккаунта, так что работа - в аккаунте.

  • Двухфакторная аутентификация на аккаунте-владельце, с кодами восстановления, хранящимися не там же, где менеджер паролей с самим паролем. Аккаунт, у которого потеряны и то и другое, восстановить нельзя - см. двухфакторная аутентификация в аккаунте панели.
  • Субпользователи с нужными правами и не более. Роли здесь достаточно детальны, чтобы дать консоль без файлов или файлы без биллинга, а доступ можно ограничить по времени и подключать через команду, а не выдавать по одному. Модель описана в статье субпользователи и минимальные привилегии; коротко - модератору, который перезапускает сервер, не нужна вкладка, показывающая ваш ключ Stripe.
  • Журнал активности каждого сервера - это запись того, кто что менял, и первое, что стоит прочитать, если значение не то, которое вы задавали.
  • API-ключи, ограниченные адресом, список сессий, из которых можно выйти, и ограничения частоты на вход. Используйте все три; они бесплатны.
  • Одноразовый вход в слот базы данных. Открытие phpMyAdmin из панели использует токен, истекающий через 60 секунд, так что секрета нет ни в истории браузера, ни в закладке.

Когда кто-то уходит из проекта, порядок такой: удалите субпользователя, смените данные SFTP для каждого сервера, которого он касался, смените всё, что он мог прочитать. Сделать только первое - самая частая полумера. Полный список мер по укреплению аккаунта - на странице безопасности, а вид той же проблемы со стороны развёртывания - в статье развёртывание приложения Node.js из GitHub.

FAQ#

Действительно ли переменные окружения безопасны?

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

Файл .env лучше или хуже панели?

Не лучше и не хуже, а по-другому. .env подходит для длинных или неудобных значений и держит всё в одном месте, которое ваш код уже читает. Вкладка Startup вообще не пускает значение в файловую систему, так что доступ к файлам его не раскрывает, и оно переживает ошибочное rm не в том каталоге. Многие используют панель для секретов, а закоммиченный .env.example для документации, и это хороший вариант по умолчанию.

Я отправил токен в публичный репозиторий и удалил. Всё в порядке?

Нет. Значение осталось в истории коммитов и в каждом уже существующем клоне, а публичные репозитории сканируют непрерывно. Выпустите новый токен, разверните его, отзовите старый, затем проверьте журналы использования у провайдера на вызовы, которых вы не делали. Переписывание истории потом аккуратно, но ничего не меняет в раскрытии.

Почему моя переменная undefined после того, как я её задал?

Переменные окружения читаются при запуске процесса, поэтому значение, заданное после старта, не видно до перезапуска. Другие кандидаты: опечатка в имени, а имена чувствительны к регистру; .env, который загружается после кода, читающего переменную; или переменная времени сборки, которую ищут во время выполнения, - это совсем другой механизм в собираемом коде фронтенда.

Можно ли использовать один пароль базы для двух приложений?

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

Где во всём этом пароли игровых серверов?

На том же месте. Пароли RCON, пароли администратора и API-ключи игрового сервера задаются на вкладке Startup и здесь генерируются на каждый сервер при установке, так что по умолчанию они уникальны. Единственное отличие в том, что некоторые игры записывают пароль в файл конфигурации, который могут читать моды, поэтому на любом сервере, где вы ставите чужой код, считайте секреты известными этому коду - соответствующая привычка описана в статье как держать модифицированный сервер чистым.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000