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:
2026-09-06 23:38:57 +05:00
parent 65042ee335
commit ab788725cf
16 changed files with 860 additions and 88 deletions
+95 -21
View File
@@ -110,14 +110,75 @@ func IsValidAdminUsername(username string) bool {
return adminUsernamePattern.MatchString(strings.TrimSpace(username))
}
// Noncharacters и суррогаты — то, что НЕ ПРИМЕТ systemd.
//
// `EnvironmentFile=` разбирается systemd, и перед тем как принять пару, он
// прогоняет ключ и значение через `utf8_is_valid` (src/basic/env-file.c,
// `check_utf8ness_and_warn`). Отказ там — это `-EINVAL`, то есть НЕ загруженный
// environment file, то есть юнит, который не стартует; предупреждением это не
// является.
//
// `utf8_is_valid` отвергает встроенный NUL и всё, что не является Unicode
// scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх того отвергает:
//
// U+D800..U+DFFF суррогаты
// U+FDD0..U+FDEF noncharacters
// (cp & 0xFFFE) == 0xFFFE — U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
//
// Пока контракт этого не знал, пароль вроде `abcde﷐` — шесть символов,
// восемь байт, ни одного управляющего — проходил ВСЕ проверки продукта,
// записывался в /etc/hy2xs/hy2xs.env, и админка после этого не стартовала. Тот
// же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже.
//
// U+FEFF в этот список НЕ входит: `0xFEFF & 0xFFFE` — это `0xFEFE`, и systemd
// такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
// U+xFFFE и является неточностью самого systemd). Запрет U+FEFF — отдельное
// продуктовое решение, см. hasForbiddenRune.
func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF {
return true
}
return r&0xFFFE == 0xFFFE
}
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
//
// Это ДОМЕН systemd, а не политика HY2XS: здесь перечислено ровно то, что
// `utf8_is_valid` отвергает, и ничего сверх того. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool {
// Невалидный UTF-8 сюда попадает не только из файла: Go-строка — это
// произвольные байты, и `[]byte` из внешнего источника может оказаться
// чем угодно. Проверка идёт ПЕРВОЙ, потому что `range` по невалидной строке
// молча отдаёт U+FFFD и скрыл бы причину.
if !utf8.ValidString(value) {
return false
}
for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`).
if r == 0 {
return false
}
if isUnicodeNoncharacter(r) {
return false
}
}
return true
}
// IsValidAdminPassword — единственное правило пароля администратора.
//
// Правило состоит из трёх частей и НИ ОДНА из них не является набором
// Правило состоит из четырёх частей и НИ ОДНА из них не является набором
// символов:
//
// домен systemd — значение обязано быть загружаемым из EnvironmentFile
// (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// без управляющих — граница, которую ставит транспорт.
// Cc и U+FEFF — продуктовая политика, см. hasForbiddenRune.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
@@ -128,16 +189,7 @@ func IsValidAdminUsername(username string) bool {
// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у
// 64 символов длина от 64 до 256 байт в зависимости от алфавита.
//
// Управляющие символы запрещены, и это единственное ограничение НАБОРА в
// контракте. Оно введено не ради строгости: первый пароль администратора
// уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile, и
// у перевода строки там нет представления, переживающего запись и чтение. При
// этом такой пароль всё равно невозможно ввести в однострочное поле формы
// входа, то есть он умеет ровно одно — запереть оператора снаружи. Запрет
// делает транспорт ПОЛНЫМ: всякий пароль, принятый этим правилом, доезжает до
// bcrypt побайтово тем же, каким его задал оператор.
//
// Набор символов НЕ ограничивается ничем сверх этого. Пароль назначает
// Набор символов НЕ ограничивается ничем сверх перечисленного. Пароль назначает
// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, —
// и сервер нигде его набор не проверяет. Ограничение на форме входа не
// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает
@@ -146,6 +198,12 @@ func IsValidAdminUsername(username string) bool {
// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в
// нормализации DTO, ни при чтении bootstrap-окружения.
func IsValidAdminPassword(password string) bool {
// Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до
// админки вовсе — systemd откажется загружать файл окружения, и юнит не
// стартует. Это отказ более грубого рода, чем нарушение границ длины.
if !IsEnvTransportableText(password) {
return false
}
runes := utf8.RuneCountInString(password)
if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength {
return false
@@ -153,19 +211,35 @@ func IsValidAdminPassword(password string) bool {
if len(password) > AdminPasswordMaxBytes {
return false
}
return !hasControlRune(password)
return !hasForbiddenRune(password)
}
// hasControlRune сообщает, есть ли в строке управляющий символ.
// hasForbiddenRune — продуктовая политика HY2XS поверх домена systemd.
//
// Проверяются C0 (U+0000-U+001F) и DEL (U+007F). Управляющие символы Unicode
// вне ASCII сюда не попадают намеренно: у systemd границей строки является
// байт `\n` или `\r`, и U+2028 для него — три обычных байта, а не перевод
// строки. Правило описывает ровно то, что ломает транспорт, и ни символом
// больше.
func hasControlRune(value string) bool {
// Запрещены УПРАВЛЯЮЩИЕ символы Unicode целиком, то есть категория Cc:
//
// U+0000..U+001F C0
// U+007F DEL
// U+0080..U+009F C1
//
// Раньше здесь стояли только C0 и DEL, а комментарий обещал «без управляющих
// символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано.
//
// И отдельно U+FEFF. Формат его несёт: `0xFEFF & 0xFFFE` — это `0xFEFE`, и
// `unichar_is_valid` такое значение ПРИНИМАЕТ (комментарий `/* BOM */` в
// исходнике systemd относится к U+xFFFE и является его собственной
// неточностью). Запрет здесь — решение продукта: невидимый символ нулевой
// ширины в пароле нельзя ни увидеть в поле ввода, ни повторить при следующем
// входе, то есть он умеет ровно одно — запереть оператора снаружи. Ровно тем же
// обоснованием запрещены и управляющие символы: перевод строки невозможно
// ввести в однострочное поле формы входа.
func hasForbiddenRune(value string) bool {
for _, r := range value {
if r < 0x20 || r == 0x7F {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true
}
if r == 0xFEFF {
return true
}
}
+90
View File
@@ -122,6 +122,33 @@ func TestAdminPasswordBoundaries(t *testing.T) {
{"табуляция", "abcde\t", false},
{"нулевой байт", "abcde\x00", false},
{"DEL", "abcde\x7f", false},
// C1: раньше документация обещала «без управляющих символов», а код
// проверял только C0 и DEL — то есть обещание было шире проверки.
{"C1 NEL", "abcde" + string(rune(0x85)), false},
{"C1 верхняя граница", "abcde" + string(rune(0x9F)), false},
// Домен systemd. Каждое из этих значений — шесть символов, ни одного
// управляющего, укладывается в 72 байта, — и при этом делает
// /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn`
// отвечает -EINVAL, и юнит hy2xs-admin не стартует.
{"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false},
{"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false},
{"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false},
{"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false},
{"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false},
{"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false},
{"невалидный UTF-8", "abcde\xff", false},
// Соседи noncharacters приниматься обязаны: правило описывает ровно
// множество systemd, а не «всё подозрительное рядом».
{"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true},
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF формат несёт (0xFEFF & 0xFFFE == 0xFEFE), и отвергается он
// продуктовой политикой, а не systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
}
for _, testCase := range cases {
@@ -160,6 +187,69 @@ func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
}
}
// Домен транспорта повторяет множество systemd ТОЧНО — не шире и не уже.
//
// Шире означало бы, что HY2XS отвергает значение, которое systemd загрузил бы;
// уже — что значение проходит все двери продукта и делает файл окружения
// незагружаемым, а юнит — не стартующим. Второе и было дефектом: контракт знал
// про управляющие символы и не знал про noncharacters.
//
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
rejected := []rune{
0x0000, // embedded NUL — отдельная ветка utf8_is_valid
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF,
0x10FFFE, 0x10FFFF,
}
for _, r := range rejected {
if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят: systemd откажется загружать такой EnvironmentFile", r)
}
}
accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFEFF, // 0xFEFF & 0xFFFE == 0xFEFE — systemd принимает
0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀
}
for _, r := range accepted {
if !IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X отвергнут: правило шире множества systemd", r)
}
}
// Невалидный UTF-8 — не «строка со странными символами», а байты, которые
// systemd отвергает первой же проверкой.
for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} {
if IsEnvTransportableText(invalid) {
t.Errorf("невалидный UTF-8 %q принят", invalid)
}
}
}
// Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно.
//
// Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что
// одно из двух правил отсутствует.
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS.
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F, 0xFEFF} {
password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
}
if IsValidAdminPassword(password) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
}
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
//
// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace,
+38 -7
View File
@@ -76,20 +76,51 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
* итератор строки (он идёт по code points, а не по единицам UTF-16) и байты
* через `TextEncoder`, который кодирует именно в UTF-8.
*
* Управляющие символы (C0 и DEL) — единственное ограничение НАБОРА в
* контракте, и оно принадлежит не форме, а транспорту: первый пароль
* администратора уезжает в EnvironmentFile systemd, где у перевода строки нет
* представления, переживающего запись и чтение. Проверяются они перебором кодов,
* а не классом регекспа: литеральные управляющие символы в исходнике невидимы,
* и такое правило нельзя проверить глазами при ревью.
* Ограничений НАБОРА в контракте два, и они разного происхождения.
*
* Первое — домен systemd. Первый пароль администратора уезжает в
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
* это не предупреждение, а незагруженный файл окружения и, следовательно,
* юнит, который не стартует. Отвергаются NUL, суррогаты, `U+FDD0..U+FDEF` и все
* code points вида `*FFFE`/`*FFFF`. Панель обязана знать это множество: иначе
* она предложила бы оператору назначить пароль, после которого сервер
* перестанет подниматься.
*
* Одиночные суррогаты проверяются отдельно и по своей причине: строка
* JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
* суррогат на U+FFFD — то есть без проверки на сервер уехал бы ДРУГОЙ пароль, а
* не отказ.
*
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
* DEL, C1) и U+FEFF. Формат их несёт, запрещает их HY2XS: ни один из них
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
* они умеют ровно одно — запереть оператора снаружи.
*
* Проверяются они перебором кодов, а не классом регекспа: литеральные
* управляющие символы в исходнике невидимы, и такое правило нельзя проверить
* глазами при ревью.
*/
export function isValidAdminPassword(value: string): boolean {
let characters = 0;
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
// Домен systemd: значение вне него не доедет до сервера вовсе.
if (
code === 0 ||
(code >= 0xd800 && code <= 0xdfff) ||
(code >= 0xfdd0 && code <= 0xfdef) ||
(code & 0xfffe) === 0xfffe
) {
return false;
}
// Политика HY2XS: Cc и U+FEFF.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
return false;
}
characters += 1;
}