fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless для значений, которые systemd принимает, но не спросил, какие значения он принимает вообще. 1. Домен значений файла окружения Перед тем как принять пару, systemd прогоняет ключ и значение через utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам utf8_is_valid — встроенный NUL и невалидный UTF-8. Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и после этого админка не поднималась. Тот же класс дефекта, ради уничтожения которого контракт и существует, только слоем ниже. Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ секрет, а не отказ. Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри двойных кавычек перевод строки накапливается как обычный байт и переживает round-trip. Именно эта подмена и позволила проверке не знать про noncharacters. Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом: 0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает. 2. Рецепт восстановления выполнял env-файл как код В docs/operations/12, раздел «Забыт пароль администратора», стояло `set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда, когда файл научился нести произвольные значения. Для systemd HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку команд — от root, прямо в рецепте восстановления доступа. Соседний раздел той же страницы при этом уже правильно запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и тут же его предлагал. Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий возврат source/./eval над этими файлами в командах документации и в скриптах; гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную конструкцию по имени, его не роняло. 3. Отказ приходил после мутаций хоста Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и раскладки файловой системы, — а read-only preflight-install говорил PASS: он зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка конфигурации роняла операцию, оставив за собой изменённый хост, что прямо противоречит контракту PHASE 0. validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же. 4. Точность порта автомата и его описания - в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой перевод строки (в VALUE_ESCAPE — наоборот, strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>; - комментарий обещал одно намеренное расхождение с systemd, а их два: кроме строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла. Оба fail-closed и теперь названы оба. Тесты: граничная таблица во всех слоях дополнена значениями вне домена (U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8), соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает непригодную конфигурацию, проверяются все значения файла, запись и проверка ходят по одному списку пар. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,17 @@ code_without_comments() {
|
||||
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
|
||||
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
|
||||
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
|
||||
# fenced_code_of отдаёт КОМАНДЫ документа — строки внутри ```-блоков.
|
||||
#
|
||||
# Нужен по той же причине, что и code_without_comments: комментарий или абзац,
|
||||
# объясняющий, почему конструкции здесь больше нет, обязан называть её по имени,
|
||||
# иначе он бесполезен. Гейт «такой команды в документации нет» без этого падал бы
|
||||
# ровно на том объяснении, которое описывает выполненную им же работу.
|
||||
fenced_code_of() {
|
||||
[ -f "$1" ] || fail "fenced_code_of: файл не найден: $1"
|
||||
awk '/^```/ { inside = !inside; next } inside { print }' "$1"
|
||||
}
|
||||
|
||||
code_has() {
|
||||
local file="$1"
|
||||
shift
|
||||
@@ -872,6 +883,91 @@ run_admin_login_acceptance() {
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'SHELL_NEED_ESCAPE' \
|
||||
|| fail "acceptance: кодировщик env потерял ссылку на правила systemd"
|
||||
|
||||
log_step "Acceptance: the transport domain is systemd's, not ours"
|
||||
# systemd прогоняет ключ и значение через utf8_is_valid перед тем, как принять
|
||||
# пару (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там - это
|
||||
# -EINVAL, то есть НЕзагруженный EnvironmentFile и юнит, который не стартует.
|
||||
# Пока контракт знал только про управляющие символы, пароль `abcde` плюс
|
||||
# U+FDD0 - шесть символов, восемь байт - проходил все двери продукта и
|
||||
# оставлял сервер без работающей панели.
|
||||
code_has apps/credential/admin.go -F -- 'func IsEnvTransportableText' \
|
||||
|| fail "acceptance: контракт снова не знает домена значений systemd"
|
||||
code_has apps/credential/admin.go -F -- 'IsEnvTransportableText(password)' \
|
||||
|| fail "acceptance: правило пароля перестало проверять домен транспорта"
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'export function isEnvTransportable' \
|
||||
|| fail "acceptance: оркестратор снова не знает домена значений systemd"
|
||||
code_has orchestrator/src/config/profile.ts -F -- 'isEnvTransportable(value)' \
|
||||
|| fail "acceptance: правило пароля оркестратора перестало проверять домен транспорта"
|
||||
|
||||
# Границы взяты из unichar_is_valid: U+FDD0..U+FDEF и (cp & 0xFFFE) == 0xFFFE.
|
||||
local unicode_domain_owner
|
||||
for unicode_domain_owner in apps/credential/admin.go \
|
||||
orchestrator/src/lib/envFile.ts \
|
||||
apps/frontend/src/constants/credentials.ts; do
|
||||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][dD][dD]0' \
|
||||
|| fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF"
|
||||
code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \
|
||||
|| fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF"
|
||||
done
|
||||
# Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без
|
||||
# проверки это была бы не ошибка, а тихая подмена секрета.
|
||||
local surrogate_owner
|
||||
for surrogate_owner in orchestrator/src/lib/envFile.ts \
|
||||
apps/frontend/src/constants/credentials.ts; do
|
||||
code_has "$surrogate_owner" -E -- '0[xX][dD]800' \
|
||||
|| fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты"
|
||||
done
|
||||
|
||||
# Порт автомата обязан повторять upstream буквально: в состоянии
|
||||
# DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой
|
||||
# перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd.
|
||||
code_has orchestrator/src/lib/envFile.ts -F -- 'c !== "\n"' \
|
||||
|| fail "acceptance: escape в двойных кавычках разошёлся с автоматом systemd"
|
||||
|
||||
log_step "Acceptance: unusable configuration is rejected before the first mutation"
|
||||
# Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала
|
||||
# на шаге записи файла - уже после bootstrap оркестратора, apt и раскладки
|
||||
# файловой системы, а read-only preflight-install про неё говорил PASS.
|
||||
code_has orchestrator/src/config/env.ts -F -- 'validateRuntimeEnvTransport(config);' \
|
||||
|| fail "acceptance: конфигурация снова проверяется на транспорт только при записи"
|
||||
"$BUN_BIN" -e '
|
||||
const source = require("node:fs").readFileSync("orchestrator/src/config/env.ts", "utf8");
|
||||
const start = source.indexOf("export function parseRuntimeEnv");
|
||||
if (start < 0) throw new Error("parseRuntimeEnv отсутствует");
|
||||
const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig"));
|
||||
if (!body.includes("validateRuntimeEnvTransport(config)")) {
|
||||
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
|
||||
}
|
||||
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации"
|
||||
|
||||
log_step "Acceptance: env files are read as data, never executed"
|
||||
# Файл научился нести произвольные значения, и это сделало `source` опасным:
|
||||
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
|
||||
# (подстановок в EnvironmentFile нет), а bash внутри двойных кавычек выполнил
|
||||
# бы подстановку команд - от root, прямо в рецепте восстановления доступа.
|
||||
#
|
||||
# Проверяются КОМАНДЫ: объяснение, почему такой строки здесь больше нет,
|
||||
# обязано называть её по имени, поэтому текст вне ```-блоков не считается.
|
||||
local env_doc
|
||||
while IFS= read -r env_doc; do
|
||||
[ -n "$env_doc" ] || continue
|
||||
if fenced_code_of "$env_doc" \
|
||||
| grep -qE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|hy2xs\.env|bootstrap-admin\.secret)'; then
|
||||
fail "acceptance: $env_doc снова предлагает исполнить env-файл шеллом (source/.)"
|
||||
fi
|
||||
if fenced_code_of "$env_doc" \
|
||||
| grep -qE 'eval[[:space:]]+[^|]*(hy2xs\.env|bootstrap-admin\.secret)'; then
|
||||
fail "acceptance: $env_doc снова предлагает eval над env-файлом"
|
||||
fi
|
||||
done <<EOF
|
||||
$(find docs package/docs -name '*.md' 2>/dev/null; echo README.md)
|
||||
EOF
|
||||
|
||||
if grep -rqE '(^|[;&|[:space:]])(source|\.)[[:space:]]+[^[:space:]]*(/etc/hy2xs/|bootstrap-admin\.secret)' \
|
||||
--include='*.sh' tools package 2>/dev/null; then
|
||||
fail "acceptance: скрипт снова исполняет env-файл шеллом"
|
||||
fi
|
||||
|
||||
log_step "Acceptance: the panel is never stricter than the server"
|
||||
[ -f apps/frontend/src/constants/credentials.ts ] \
|
||||
|| fail "acceptance: контракт учётных данных панели отсутствует"
|
||||
|
||||
@@ -832,10 +832,31 @@ describe("контракт учётных данных администрато
|
||||
"я".repeat(ADMIN_PASSWORD_MAX_LENGTH),
|
||||
"abcde\n",
|
||||
"abcde\t",
|
||||
// Cc целиком, а не только C0 и DEL: документация обещала «без
|
||||
// управляющих символов», а проверка знала лишь про часть из них.
|
||||
`abcde${String.fromCodePoint(0x7f)}`,
|
||||
`abcde${String.fromCodePoint(0x85)}`,
|
||||
`abcde${String.fromCodePoint(0x9f)}`,
|
||||
// Домен systemd: шесть символов, ни одного управляющего — и юнит после
|
||||
// такого пароля не стартует, потому что EnvironmentFile не загрузится.
|
||||
`abcde${String.fromCodePoint(0xfdd0)}`,
|
||||
`abcde${String.fromCodePoint(0xffff)}`,
|
||||
`abcde${String.fromCodePoint(0x1ffff)}`,
|
||||
// U+FEFF формат несёт — его отвергает политика продукта.
|
||||
`abcde${String.fromCodePoint(0xfeff)}`,
|
||||
// Одиночный суррогат: без проверки TextEncoder молча подменил бы его на
|
||||
// U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ.
|
||||
`abcde${String.fromCharCode(0xd800)}`,
|
||||
]) {
|
||||
expect(isValidAdminPassword(rejected)).toBe(false);
|
||||
}
|
||||
|
||||
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
|
||||
// множество systemd, а не окрестность подозрительных значений.
|
||||
for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd]) {
|
||||
expect(isValidAdminPassword(`abcde${String.fromCodePoint(accepted)}`)).toBe(true);
|
||||
}
|
||||
|
||||
// Именно тот случай, ради которого встроенные min/max убраны: три эмодзи —
|
||||
// это 3 code points и 6 единиц UTF-16.
|
||||
expect("😀😀😀".length).toBe(6);
|
||||
|
||||
Reference in New Issue
Block a user