Repository navigation
feat(security): keep secret values out of API key responses - #58
Merged
Merged
Conversation
Что:
- Ответы на запросы по API-ключу проходят общий фильтр в базовой tRPC-прослойке. Поля с секретами (env, build args и secrets, пароли, токены, ключи, webhook URL, заголовки) заменяются плейсхолдером при любой роли владельца ключа, включая owner. В списках вида NAME=value (docker Env) и в картах остаются имена, значения скрываются.
- Из остального текста (логи контейнеров и деплоев, compose-файлы, содержимое mount-файлов, команды, тексты ошибок, чанки подписок) вырезаются значения секретов, которые хранит панель (env сервисов, проектов и окружений, пароли БД и registry), и текст, похожий на учётные данные: NAME=value и key: value с секретным именем, пароли в URL, Bearer/Basic, известные форматы токенов, блоки приватных ключей. Ссылки на переменные (${VAR}, $VAR, ${{project.VAR}}) не трогаются.
- API-ключ больше не может вызвать процедуры, которые отдают секрет или сырые байты файла: *.revealEnvironment, user.revealApiKey, docker.getConfig, docker.readContainerFile, dockerVolume.readVolumeFile, settings.readTraefikEnv. Раньше это зависело от того, задан ли супер-пароль.
- API-ключ не может открыть WebSocket терминала и сырых потоков логов (контейнер, деплой); логи читаются через readLogs с фильтром.
- Запись по API-ключу отклоняется, если во входных данных есть вычищенный текст: иначе плейсхолдер перезаписал бы настоящее значение. Поле-секрет, присланное целиком как плейсхолдер, по-прежнему значит «оставить сохранённое».
- Логи, которые панель отправляет своему AI-провайдеру (ai.analyzeLogs), проходят тот же фильтр.
- Документ OpenAPI отдаётся без фильтра: в нём имена полей, а не значения.
Зачем:
- По API-ключу с панелью работают скрипты и ИИ-агенты (MCP). Владелец хочет поручать им работу с сервисами так, чтобы значения env и другие секреты не попадали в контекст модели ни через поля ответа, ни через логи, файлы и ошибки.
Риски:
- Фильтр по шаблонам намеренно широкий: в логах и compose-файлах может быть скрыто несекретное значение (например "passed: 12" или "author: ..."). Для сессий в браузере ничего не меняется.
- Секрет, которого нет в панели и который записан в тексте без узнаваемого имени или формата (например, значение под нейтральным именем прямо в compose-файле), фильтр не распознает.
- Список известных значений кешируется на 30 секунд и сбрасывается после каждой мутации; значения короче 8 символов, из одних строчных букв или из одних цифр в него не входят, чтобы не портить обычный вывод.
- Клиенты, которые по API-ключу читали файлы контейнеров и томов, docker inspect или сырые WebSocket-логи, получат отказ.
Проверки:
- Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2827 passed, 1 failed (backup-failure-output, таймаут 5 с при параллельном запуске; отдельный запуск файла - 5 passed); `biome check` по изменённым файлам - 0 ошибок, 3 предупреждения useOptionalChain в строках, которые не менялись.
- Ограничения: на живой панели не проверялось; `*.real.test.ts` не запускались (нужны swarm и сеть).
What:
- Responses to API key requests go through one filter in the base tRPC middleware. Secret-bearing fields (env, build args and secrets, passwords, tokens, keys, webhook URLs, headers) are replaced with the placeholder whatever role the key's owner has, owner included. NAME=value lists (docker Env) and maps keep their names and lose their values.
- Every other string (container and deployment logs, compose files, mounted file contents, commands, error messages, subscription chunks) is scrubbed of the secret values the panel stores (service, project and environment env, database and registry passwords) and of text that looks like a credential: NAME=value and key: value with a secret-looking name, URL passwords, Bearer/Basic, well-known token shapes, private key blocks. Variable references (${VAR}, $VAR, ${{project.VAR}}) are left alone.
- An API key can no longer call the procedures that hand back a secret or raw file bytes: *.revealEnvironment, user.revealApiKey, docker.getConfig, docker.readContainerFile, dockerVolume.readVolumeFile, settings.readTraefikEnv. This used to depend on a super password being set.
- An API key cannot open the terminal WebSockets or the raw log streams (container, deployment); logs are read through readLogs, which is scrubbed.
- An API key write is rejected when its input contains scrubbed text, which would otherwise overwrite the real value with the placeholder. A secret field sent back as the bare placeholder still means "keep what is stored".
- Logs the panel sends to its own AI provider (ai.analyzeLogs) go through the same scrubber.
- The OpenAPI document is returned unfiltered: it holds field names, not values.
Why:
- Scripts and AI agents (MCP) talk to the panel with an API key. The owner wants to hand them service work without env values and other secrets reaching the model's context through response fields, logs, files or errors.
Risks:
- The pattern scrubber is broad on purpose: a non-secret value in logs or compose files can be hidden (for example "passed: 12" or "author: ..."). Browser sessions are not affected.
- A secret the panel does not store, written in text without a recognisable name or shape (for example a value under a neutral name inline in a compose file), is not recognised.
- The known value list is cached for 30 seconds and dropped after every mutation; values shorter than 8 characters, all lowercase letters or all digits stay out of it so ordinary output is not mangled.
- Clients that used an API key to read container and volume files, docker inspect or raw WebSocket logs are now refused.
Checks:
- Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2827 passed, 1 failed (backup-failure-output, 5 s timeout under parallel load; the file alone - 5 passed); `biome check` on the changed files - 0 errors, 3 useOptionalChain warnings on lines that were not changed.
- Limitations: not exercised against a live panel; `*.real.test.ts` were not run (they need swarm and network).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or API keys Что: - Широкий фильтр текста больше не принимает за «ключ: значение» то, что значением не является: порт после хоста (`auth-db:5432`), тег образа (`auth-api:1.2.3`, `:latest`), YAML-ключ с вложенным блоком (`auth-router:` и перенос строки), JSON-объект или массив после ключа. - Значение должно стоять на той же строке, что и ключ. - Поля со ссылкой на образ (`image`, `dockerImage`, `Image`) не переписываются, как идентификаторы и ревизии. Зачем: - Агент читает compose-файлы и конфиги Traefik, чтобы их менять. Если имя сервиса содержит слово вроде auth, session или token, прежний фильтр прятал порт, тег или следующую строку, и вернуть такой текст обратно было нельзя. Риски: - Секрет, записанный как `имя: значение`, где значение выглядит как номер порта (до 5 цифр), версия (`1.2.3`) или слово latest/stable/main/master/canary/edge/nightly, этим шаблоном не скрывается. Значения, известные панели, по-прежнему вырезаются отдельно. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run __test__/security/secret-scrub.test.ts __test__/security/api-key-output-guard.test.ts __test__/security/secret-redaction.test.ts` - 129 passed; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на живой панели не проверялось. What: - The broad text scrubber no longer reads things that are not a value as "key: value": a port after a host (`auth-db:5432`), an image tag (`auth-api:1.2.3`, `:latest`), a YAML key with a nested block (`auth-router:` and a line break), a JSON object or array after a key. - The value has to sit on the same line as the key. - Image reference fields (`image`, `dockerImage`, `Image`) are not rewritten, like identifiers and revisions. Why: - An agent reads compose files and Traefik configs in order to change them. When a service name contained a word such as auth, session or token, the previous scrubber hid the port, the tag or the next line, and such text could not be written back. Risks: - A secret written as `name: value` whose value looks like a port number (up to 5 digits), a version (`1.2.3`) or the word latest/stable/main/master/canary/edge/nightly is not hidden by this pattern. Values the panel knows are still removed separately. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run __test__/security/secret-scrub.test.ts __test__/security/api-key-output-guard.test.ts __test__/security/secret-redaction.test.ts` - 129 passed; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a live panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hout seeing their secrets
Что:
- В compose-файле для API-ключа скрывается каждое буквальное значение в блоках environment и build args и каждое встроенное тело secrets/configs, как бы ни называлась переменная. Это правило по месту в файле, а не по догадке об имени. Ссылки на переменные (${VAR}, ${{project.VAR}}), пустые значения и остальная структура файла видны; учитываются якоря и merge-ключи YAML.
- Скрытое значение заменяется меткой: плейсхолдер плюс идентификатор значения (HMAC с серверным секретом, 12 символов). Одинаковые значения дают одинаковую метку, по самой метке значение не восстановить.
- compose.update и mounts.update по API-ключу возвращают значения на место меток из сохранённого текста. Агент читает файл, меняет образ, порты, добавляет и удаляет переменные и сохраняет, не видя значений.
- Сохранение отклоняется, если метку перенесли туда, где значение стало бы читаемым (например, в image или container_name), если метка не выдавалась для этого файла или если в тексте плейсхолдер без метки.
- Файлы монтирования получают такие же метки для найденных в них секретов.
- В список известных значений попадают секреты, записанные прямо в compose-файлы, и варианты каждого известного значения в base64 (три выравнивания) и hex, поэтому они вырезаются из логов и другого текста.
- Поиск известных значений идёт по префиксному дереву: время не растёт с числом секретов.
Зачем:
- Раньше секрет, записанный в compose-файл под нейтральным именем, фильтр не узнавал, а текст с вычищенными местами нельзя было сохранить обратно. Теперь значения в блоках переменных скрыты всегда, а правка файла работает целиком.
Риски:
- Значение вне блоков переменных (метка, аргумент команды) под нейтральным именем и неизвестное панели по-прежнему видно: его место не говорит о том, что это секрет.
- Агент видит в блоках переменных только имена и ссылки, в том числе для несекретных значений вроде порта.
- Compose-файл с ошибкой YAML показывается API-ключу как один плейсхолдер и не редактируется по меткам.
- После восстановления значение записывается в двойных кавычках; для env и build args это равнозначно.
Проверки:
- Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2867 passed, 3 failed по таймауту в backup-failure-output и server-setup при параллельной нагрузке, отдельный запуск этих двух файлов - 18 passed; `biome check` по изменённым файлам - 0 ошибок.
- Ограничения: на живой панели не проверялось.
What:
- In a compose file read with an API key, every literal value in the environment and build args blocks and every inline secrets/configs body is hidden, whatever the variable is called. The rule goes by position in the file, not by a guess about the name. Variable references (${VAR}, ${{project.VAR}}), empty values and the rest of the file stay visible; YAML anchors and merge keys are followed.
- A hidden value becomes a token: the placeholder plus an id of the value (an HMAC keyed with a server secret, 12 characters). Equal values get equal tokens, and the token alone does not give the value away.
- compose.update and mounts.update called with an API key put the values back in place of the tokens from the stored text. An agent reads the file, changes the image or ports, adds and removes variables and saves it without seeing the values.
- The save is rejected when a token was moved to where the value would become readable (for example into image or container_name), when the token was not issued for this file, or when the text holds a placeholder without a token.
- Mounted files get the same tokens for the secrets found in them.
- Secrets written inline in compose files join the known values, together with the base64 (three alignments) and hex forms of every known value, so they are removed from logs and other text.
- Known values are matched through a prefix tree, so the time does not grow with the number of secrets.
Why:
- A secret written into a compose file under a neutral name was not recognised before, and text with scrubbed spots could not be saved back. Now values in the variable blocks are always hidden, and editing the file works end to end.
Risks:
- A value outside the variable blocks (a label, a command argument) under a neutral name and unknown to the panel is still visible: its position does not say it is a secret.
- In the variable blocks an agent sees only names and references, also for non-secret values such as a port.
- A compose file that is not valid YAML is shown to an API key as a single placeholder and cannot be edited through tokens.
- A restored value is written in double quotes; for env and build args that is equivalent.
Checks:
- Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2867 passed, 3 timed out in backup-failure-output and server-setup under parallel load, those two files alone - 18 passed; `biome check` on the changed files - 0 errors.
- Limitations: not exercised against a live panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or running to the end of the line
Что:
- Прогон на локальной панели показал утечку: строка `password = значение` (пробелы вокруг `=`, как в ini и cnf) не распознавалась, а правило для SQL принимало знак `=` за пароль, и само значение оставалось в ответе.
- Широкий фильтр теперь понимает: присваивание с пробелами вокруг `=`; значение до конца строки, если строка целиком является присваиванием или парой `ключ: значение` (значение с пробелами); `'ключ' => 'значение'`; XML-элемент `<Password>значение</Password>`; числовое значение после `ключ: ` (раньше оно пропускалось как номер порта).
- Для содержимого файлов монтирования дополнительно читаются настройки вида `имя значение` (`requirepass ...`). В логах такая форма не применяется: там это обычный текст.
- Правило `PASSWORD значение` больше не принимает `=` и `:` за значение.
- Метка внутри скрытого значения (строка скрыта целиком после того, как в ней был скрыт пароль) восстанавливается до конца.
- Имя переменной внутри ссылки `${VAR:?текст}` не считается ключом.
Зачем:
- Конфигурационные файлы в монтированиях пишут секреты именно так, и агент получал бы их при чтении файла.
Риски:
- Фильтр стал шире: в логах строка, начинающаяся с `session=...` или `auth: ...`, скрывается до конца строки.
- Секрет в файле произвольного формата под нейтральным именем или без имени (например, строка .pgpass) по-прежнему не распознаётся.
Проверки:
- Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run __test__/security` - 332 passed; самая долгая проверка на враждебной строке 600 КБ - 276 мс; `biome check` по изменённым файлам - 0 ошибок.
- Ограничения: полный набор тестов после этого коммита запускается отдельно; на удалённой панели не проверялось.
What:
- A run against a local panel showed a leak: the line `password = value` (spaces around `=`, as in ini and cnf files) was not recognised, and the SQL rule took the `=` sign for the password, so the value itself stayed in the response.
- The broad scrubber now understands: an assignment with spaces around `=`; a value running to the end of the line when the whole line is an assignment or a `key: value` pair (a value with spaces); `'key' => 'value'`; an XML element `<Password>value</Password>`; a numeric value after `key: ` (it used to be let through as a port number).
- Mounted file contents are also read for `name value` settings (`requirepass ...`). Logs are not read that way: there the shape is ordinary prose.
- The `PASSWORD value` rule no longer takes `=` or `:` for the value.
- A token inside a hidden value (a line hidden whole after a password in it was hidden) is resolved all the way down.
- A variable name inside a `${VAR:?text}` reference is not read as a key.
Why:
- Config files in mounts write secrets exactly this way, and an agent would have received them when reading the file.
Risks:
- The scrubber is broader: in logs a line starting with `session=...` or `auth: ...` is hidden to the end of the line.
- A secret in a free-form file under a neutral name or with no name at all (a .pgpass line, for example) is still not recognised.
Checks:
- Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run __test__/security` - 332 passed; the slowest hostile 600 KB line check - 276 ms; `biome check` on the changed files - 0 errors.
- Limitations: the full suite after this commit is run separately; not exercised against a remote panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed files, labels and commands Что: - В содержимом файлов монтирования для API-ключа скрывается значение настройки (`имя = значение`, `имя: значение`), которое выглядит как сгенерированный ключ, как бы настройка ни называлась: от 16 символов из букв, цифр и `_+-`, с буквами и цифрами, и при этом смешанный регистр, либо четыре и более цифр, либо ни одного разделителя. - В compose-файле то же правило применяется к словам в labels, command, entrypoint и healthcheck test: значение метки, позиционный аргумент команды. - Имена настроек и меток остаются видны. Имена вроде `backend-prod-eu-west-01`, адреса, пути, URL, версии и числа этим правилом не затрагиваются. - Скрытые так значения получают метки и возвращаются на место при сохранении, как и остальные. Зачем: - Владелец хочет, чтобы агент читал файлы целиком, но не видел самих значений секретов. Раньше значение под нейтральным именем вне блоков переменных оставалось видимым. Риски: - Правило не действует на логи: там так выглядят идентификаторы запросов. - Секрет из обычных слов без цифр, записанный под нейтральным именем вне блоков переменных, по-прежнему не распознаётся. - Несекретное значение похожего вида (контрольная сумма, UUID) тоже скрывается; метка позволяет сохранить файл без потери. Проверки: - Команды и результаты: в apps/dokploy `bun run typecheck` - без ошибок; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2885 passed, 1 skipped; самая долгая проверка на враждебной строке 600 КБ - 270 мс; `biome check` по изменённым файлам - 0 ошибок. - Ограничения: на удалённой панели не проверялось. What: - In mounted file contents read with an API key, a setting value (`name = value`, `name: value`) that looks like a generated key is hidden whatever the setting is called: 16 or more characters of letters, digits and `_+-`, with both letters and digits, and either mixed case, four or more digits, or no separator at all. - In a compose file the same rule applies to the words of labels, command, entrypoint and healthcheck test: a label value, a positional command argument. - Setting and label names stay visible. Names such as `backend-prod-eu-west-01`, hosts, paths, URLs, versions and numbers are not touched by this rule. - Values hidden this way get tokens and are put back on save, like the others. Why: - The owner wants an agent to read files whole without seeing the secret values themselves. A value under a neutral name outside the variable blocks used to stay visible. Risks: - The rule does not apply to logs: request ids look like that there. - A secret made of ordinary words without digits, written under a neutral name outside the variable blocks, is still not recognised. - A non-secret value of the same look (a checksum, a UUID) is hidden too; its token lets the file be saved without loss. Checks: - Commands and results: in apps/dokploy `bun run typecheck` - no errors; `bun run test -- --run --exclude '**/*.real.test.ts'` - 2885 passed, 1 skipped; the slowest hostile 600 KB line check - 270 ms; `biome check` on the changed files - 0 errors. - Limitations: not exercised against a remote panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Что это
По API-ключу с панелью работают скрипты и ИИ-агенты (MCP). Этот PR делает так, что ответ на запрос по API-ключу не содержит значений секретов — ни в полях, ни в логах, файлах и текстах ошибок. Для сессий в браузере ничего не меняется.
Что меняется
NAME=value(dockerEnv) и в картах имена остаются, значения скрываются.NAME=valueиkey: valueс секретным именем, пароли в URL,Bearer/Basic, известные форматы токенов, блоки приватных ключей.Ссылки на переменные (
${VAR},$VAR,${{project.VAR}}) остаются как есть.*.revealEnvironment,user.revealApiKey,docker.getConfig,docker.readContainerFile,dockerVolume.readVolumeFile,settings.readTraefikEnv. Раньше запрет действовал только при заданном супер-пароле.readLogsс фильтром.ai.analyzeLogs, проходят тот же фильтр.Compose-файлы и файлы монтирования: чтение и правка без раскрытия
environmentи buildargsи каждое встроенное телоsecrets/configs— независимо от имени переменной. Это правило по месту в файле, а не по догадке. Ссылки${VAR}, пустые значения и остальная структура видны.__DOKPLOY_REDACTED_SECRET__XXXXXXXXXXXX__. По метке значение не восстановить, одинаковые значения дают одинаковую метку.compose.updateиmounts.updateпо API-ключу возвращают значения на место меток. Агент читает файл, меняет образ или порты, добавляет и удаляет переменные и сохраняет, не видя значений.image), если метка не от этого файла или если в тексте плейсхолдер без метки.Чего фильтр не гарантирует
passed: 12).Совместимость
Клиенты, которые по API-ключу читали файлы контейнеров и томов,
docker inspectили сырые WebSocket-логи, получат отказ.Проверки
bun run typecheckвapps/dokploy— без ошибок.bun run test -- --run --exclude '**/*.real.test.ts'— 2867 passed, 3 упали по таймауту (backup-failure-output,server-setup) при параллельной нагрузке; отдельный запуск этих двух файлов — 18 passed.biome checkпо изменённым файлам — 0 ошибок.stays fast on a hostile line).image, удаление расписания. Итог: все проверки пройдены, ни один из 16 секретов (включая base64 и hex) не встретился в 55 ответах по API-ключу. Этот прогон нашёл утечкуpassword = значениес пробелами вокруг=, она исправлена в этой же ветке.Связанная работа, отдельными PR: слепое управление env (
env.keys,env.apply) и доработка MCP-обёртки. Этот PR от них не зависит.🤖 Generated with Claude Code