diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aec498..853e99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,15 @@ Debian 13. Тема: **разница между «поднялось» и «р переживающим произвольное значение, хотя его читает не только оркестратор, но и systemd. +Шестнадцатый проход — разбор пятнадцатого, со сверкой по исходникам systemd +v257.13, то есть той самой линии, что стоит на Debian 13. Тема та же и на слой +глубже: **контракт, объявленный шире, чем его принимает чужая сторона**. +Пятнадцатый проход сделал транспорт lossless для значений, которые systemd +принимает, но не спросил, какие значения он принимает вообще. Вместе с этим +закрыты рецепт восстановления доступа, ставший опасным ровно оттого, что файл +научился нести произвольные значения, и порядок, при котором детерминированная +ошибка конфигурации обнаруживалась уже после мутаций хоста. + ### Исправлено — вход в панель - **`POST /api/auth/login` паниковал до проверки учётных данных.** На @@ -191,6 +200,77 @@ systemd. ставится в `service.Login` сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. +### Исправлено — домен значений файла окружения + +- **Контракт не знал, какие значения systemd вообще принимает.** Перед тем как + принять пару, 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`. + Пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного + управляющего — проходил панель, оркестратор, DTO и хеширование, записывался в + `hy2xs.env`, и после этого админка не поднималась: тот же класс дефекта, ради + уничтожения которого контракт и существует, только слоем ниже. Введён + `IsEnvTransportableText` (Go) / `isEnvTransportable` (TS), повторяющий + множество systemd точно — не шире и не уже. + +- **Одиночный суррогат подменял бы секрет молча.** Строка JavaScript вправе + содержать непарный суррогат, а `TextEncoder` заменяет его на `U+FFFD`. Без + отдельной проверки в файл уехал бы ДРУГОЙ пароль, а не отказ. Панель и + оркестратор отвергают такие значения явно. + +- **Проверка транспорта приписывала формату чужие запреты.** Она отвергала C0 и + DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри + двойных кавычек перевод строки накапливается как обычный байт и переживает + round-trip. Именно эта подмена и позволила проверке не знать про + noncharacters. Домен транспорта и политика продукта теперь разделены и + проверяются отдельными тестами. + +- **Политика была шире кода ровно на C1.** Документация обещала «без + управляющих символов», а проверялись только `U+0000..U+001F` и `U+007F`. + Запрещена категория `Cc` целиком, плюс `U+FEFF` — последний отдельным + решением продукта, а не форматом: `0xFEFF & 0xFFFE` это `0xFEFE`, и systemd + такое значение принимает (комментарий `/* BOM */` в его исходнике относится к + `U+xFFFE` и является неточностью самого systemd). + +- **Порт автомата расходился с upstream на `\`.** В состоянии + `DOUBLE_QUOTE_VALUE_ESCAPE` systemd пишет `c != '\n'`, а не проверку на любой + перевод строки (в `VALUE_ESCAPE` — наоборот, `strchr(NEWLINE, c)`). Порт + съедал и `\`, и `\`, то есть прочитал бы такое значение иначе, чем + тот, для кого файл в конечном счёте написан. + +- **Комментарий обещал одно намеренное расхождение с systemd, а их два.** Кроме + строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла, тогда + как systemd принимает накопленное. Оба расхождения fail-closed и теперь + названы оба. + +### Исправлено — рецепт восстановления выполнял env-файл как код + +- **`set -a; . /etc/hy2xs/hy2xs.env` в инструкции «Забыт пароль + администратора».** Строка стала опасной ровно тогда, когда файл научился нести + произвольные значения. Для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(…)"` — + буквальное значение: подстановок в `EnvironmentFile=` нет вовсе. Но `.` + обрабатывает файл bash, а bash внутри двойных кавычек выполняет подстановку + команд — от root, прямо в рецепте восстановления доступа. Причём соседний + раздел той же страницы уже правильно запрещал `source`/`eval` для + `bootstrap-admin.secret`: документ запрещал действие и тут же его предлагал. + Рецепт читает нужные значения как ДАННЫЕ; поставлен гейт приёмки, запрещающий + возврат `source`/`.`/`eval` над этими файлами в командах документации и в + скриптах. + +### Исправлено — отказ приходил после мутаций хоста + +- **Проверка транспорта жила только внутри `renderRuntimeEnv`.** Она срабатывала + на шаге «write runtime env» — уже после bootstrap оркестратора, установки + пакетов и раскладки файловой системы, — а read-only `preflight-install` + говорил PASS: он зовёт `parseRuntimeEnv` и ничего не рендерит. + Детерминированно известная ошибка конфигурации роняла операцию, оставив за + собой изменённый хост, что прямо противоречит контракту PHASE 0. + `validateRuntimeEnvTransport` вызывается теперь из `parseRuntimeEnv` и + проходит по ВСЕМ парам `runtimeEnvEntries`: ограничение принадлежит формату, а + не полю пароля, и `HY2XS_ADMIN_CON_PASS` сломал бы загрузку юнита так же. + ### Исправлено — контракт пароля администратора - **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64 diff --git a/apps/credential/admin.go b/apps/credential/admin.go index b14b110..62822b2 100644 --- a/apps/credential/admin.go +++ b/apps/credential/admin.go @@ -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 } } diff --git a/apps/credential/admin_test.go b/apps/credential/admin_test.go index ef1e356..f6f5755 100644 --- a/apps/credential/admin_test.go +++ b/apps/credential/admin_test.go @@ -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, diff --git a/apps/frontend/src/constants/credentials.ts b/apps/frontend/src/constants/credentials.ts index 28e4e8b..65e286a 100644 --- a/apps/frontend/src/constants/credentials.ts +++ b/apps/frontend/src/constants/credentials.ts @@ -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; } diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index 307b76e..8ee51df 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -278,7 +278,8 @@ control plane показывал всех пиров офлайн. Теперь | Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` | | Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | | Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` | -| Набор символов пароля | не ограничен, кроме управляющих | — | +| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` | +| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` | | Пробелы по краям пароля | часть значения, не снимаются | — | Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не @@ -305,12 +306,41 @@ control plane показывал всех пиров офлайн. Теперь bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только отвергнуть пароль, который сервер принял бы. -Единственное исключение — **управляющие символы** (C0 и DEL). Они запрещены не -формой, а транспортом: первый пароль администратора уезжает в -`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и у -перевода строки там нет представления, переживающего запись и чтение. Такой -пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он -умеет ровно одно — запереть оператора снаружи. +Исключения два, и они **разного происхождения**. Их важно не путать: одно +описывает чужое ограничение, другое — наше решение. + +**Домен systemd — не наше правило.** Первый пароль администратора уезжает в +`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`. Перед тем +как принять пару, systemd прогоняет ключ и значение через `utf8_is_valid` +(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает +`-EINVAL`: это **незагруженный файл окружения**, то есть юнит, который не +стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`) +отвергает: + +```text +U+D800..U+DFFF суррогаты +U+FDD0..U+FDEF noncharacters +(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF +``` + +плюс встроенный NUL и любую невалидную последовательность UTF-8. + +Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь +байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, +записывался в `hy2xs.env`, и после этого админка не поднималась. Тот же класс +дефекта, ради уничтожения которого контракт и существует, только слоем ниже. + +На стороне панели и оркестратора отдельно отвергаются **одиночные суррогаты**: +строка JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный +суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое +изменение пароля по дороге в файл. + +**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие +символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`, +`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`, +и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в +поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть +оператора снаружи. ### Почему границ у пароля две diff --git a/docs/operations/12-operations-and-troubleshooting.md b/docs/operations/12-operations-and-troubleshooting.md index 8cd83ae..9bea398 100644 --- a/docs/operations/12-operations-and-troubleshooting.md +++ b/docs/operations/12-operations-and-troubleshooting.md @@ -600,13 +600,43 @@ machine-auth, поэтому придуманный секрет разошёл ```bash systemctl stop hy2xs-admin -set -a; . /etc/hy2xs/hy2xs.env; set +a + +# Значения читаются КАК ДАННЫЕ. Обоснование — ниже, оно существенно. +read_runtime_field() { + sed -n "s/^$1=//p" /etc/hy2xs/hy2xs.env | head -n1 \ + | sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g' +} + +HY2XS_INSTALL_DIR="$(read_runtime_field HY2XS_INSTALL_DIR)" +HY2XS_DATA_DIR="$(read_runtime_field HY2XS_DATA_DIR)" +HY2XS_LOG_DIR="$(read_runtime_field HY2XS_LOG_DIR)" +export HY2XS_DATA_DIR HY2XS_LOG_DIR + "$HY2XS_INSTALL_DIR/hy2xs-admin" reset-admin systemctl start hy2xs-admin ``` -Runtime env подключается намеренно: из него берутся пути к базе и журналу -(`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) — те же, с которыми работает юнит. +Пути к базе и журналу (`HY2XS_DATA_DIR`, `HY2XS_LOG_DIR`) берутся из runtime env +намеренно: это те же значения, с которыми работает юнит. + +**Почему не `set -a; . /etc/hy2xs/hy2xs.env`.** Здесь стояла именно эта строка, и +она стала опасной ровно тогда, когда файл научился нести произвольные значения. +Оператор задаёт `HY2XS_ADMIN_INITIAL_PASSWORD`, набор символов у пароля не +ограничен, и запись в файле выглядит так: + +```text +HY2XS_ADMIN_INITIAL_PASSWORD="$(touch /tmp/pwn)" +``` + +Для systemd это **буквальное значение**: подстановки в `EnvironmentFile=` нет +вовсе, `$` там обычный символ. Но `.` (`source`) обрабатывает файл **bash**, а +bash внутри двойных кавычек выполняет подстановку команд — и выполнил бы её от +root, вместе с рецептом восстановления доступа. + +То же правило действует и для `/etc/hy2xs/bootstrap-admin.secret` (см. «Как +посмотреть bootstrap-пароль» выше): файлы этого формата читаются как ДАННЫЕ. +Результат `$(read_runtime_field …)` повторно как код не исполняется — он +становится значением переменной, и это принципиальная разница. Команда печатает новые логин и пароль в консоль и требует смены пароля при первом входе. Работает поверх существующей установки; на машине без базы она diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index 293a353..00fcac2 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -577,14 +577,51 @@ production-профилем, а не ищет подстроки. Проверя релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'` продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`. -Одно намеренное расхождение с systemd: строка без `=` — **отказ**, а не -пропуск. systemd такую строку молча отбрасывает; молчаливая потеря строки из -`hy2xs.env` означала бы установку с настройкой, которую оператор задал, а -продукт не увидел. Расхождение в сторону отказа останавливает установку там, где -её можно починить. +Расхождений с systemd ровно два, оба намеренные и оба **fail-closed**: -Управляющие символы формат не несёт — перевод строки является границей записи, а -не данными, — поэтому они запрещены и контрактом пароля. +1. строка без `=` — **отказ**, а не пропуск. systemd такую строку молча + отбрасывает; молчаливая потеря строки из `hy2xs.env` означала бы установку с + настройкой, которую оператор задал, а продукт не увидел; +2. незакрытая кавычка или escape в конце файла — **отказ**. systemd в + состояниях `VALUE_ESCAPE` / `SINGLE_QUOTE_VALUE` / `DOUBLE_QUOTE_VALUE` + принимает на EOF то, что успел накопить; для конфигурации, от которой зависит + доступ в панель, «что успели накопить» — не ответ. + +Оба останавливают операцию там, где её можно починить, вместо того чтобы +применить не то, что написано в файле. + +### Домен значений принадлежит systemd, а не нам + +Формат несёт не всякую строку, и граница здесь чужая. Перед тем как принять +пару, systemd прогоняет ключ и значение через `utf8_is_valid` +(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный +файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает +суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам +`utf8_is_valid` — встроенный NUL и невалидный UTF-8. + +`isEnvTransportable` в `lib/envFile.ts` повторяет ровно это множество и ничего +сверх него. Управляющие символы формат несёт — внутри двойных кавычек перевод +строки накапливается как обычный байт и переживает round-trip, — и запрещает их +контракт учётных данных, а не транспорт. Приписывать формату чужие запреты +нельзя: именно так проверка и пропустила noncharacters, о которых ничего не +знала. + +Одиночные суррогаты проверяются отдельно и по своей причине: строка JavaScript +вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на +`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ. + +### Непригодная конфигурация отвергается до первой мутации + +`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи +файла, и проходит по **всем** парам `runtimeEnvEntries` — не только по паролю +администратора. + +Раньше проверка жила только внутри `renderRuntimeEnv`, то есть срабатывала на +шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов и +раскладки файловой системы. Read-only `preflight-install` при этом говорил PASS: +он зовёт `parseRuntimeEnv` и ничего не рендерит. Детерминированно известная +ошибка конфигурации роняла операцию, оставив за собой изменённый хост, — что +прямо противоречит контракту PHASE 0. ## Smoke проверяет, что панель ВПУСКАЕТ diff --git a/docs/runtime/09-post-install-env.md b/docs/runtime/09-post-install-env.md index 7cbc255..1257f0d 100644 --- a/docs/runtime/09-post-install-env.md +++ b/docs/runtime/09-post-install-env.md @@ -153,7 +153,7 @@ anycast. Отсутствие A-записи фатально при любом | Переменная | Требование | Значение по умолчанию | | --- | --- | --- | | `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` | -| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; набор не ограничен, кроме управляющих символов | генерируется | +| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; значение, загружаемое systemd из `EnvironmentFile=`; набор не ограничен, кроме `Cc` и `U+FEFF` | генерируется | Значение вне контракта **роняет установку** с явным текстом, называющим границы и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой @@ -190,9 +190,23 @@ HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце " слеш, закавычьте значение тем же способом. Обычные значения (порты, пути, домены) кавычек не требуют и записываются как раньше. -Управляющие символы (перевод строки, табуляция) в пароле запрещены контрактом: -формат `KEY=VALUE` их не несёт, а ввести такой пароль в форму входа всё равно -нельзя. +Есть два ограничения набора символов, и они разного происхождения. + +**Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`: +валидный UTF-8 из Unicode scalar values, без NUL, без суррогатов и без +noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило — +systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть +файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет +домен на **каждом** значении файла, а не только на пароле: `HY2XS_ADMIN_CON_PASS` +или obfs-пароль сломали бы загрузку юнита ровно так же. + +**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode +(категория `Cc`) и `U+FEFF`. Формат их несёт, но ввести такой пароль в +однострочное поле формы входа всё равно нельзя. + +Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до +первой необратимой операции над хостом: `preflight-install` и `install` видят +его одинаково. Значение по умолчанию совпадает в трёх местах и обязано совпадать: `package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное diff --git a/docs/testing/11-2-builder-layer.md b/docs/testing/11-2-builder-layer.md index 59ea52b..02047ed 100644 --- a/docs/testing/11-2-builder-layer.md +++ b/docs/testing/11-2-builder-layer.md @@ -105,15 +105,34 @@ | `KEY="a\nb"` | `a\nb` — `n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется | | `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет | | `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе | -| строка без `=` | отказ (единственное намеренное расхождение: systemd её отбрасывает молча) | -| незакрытая кавычка | отказ | +| строка без `=` | отказ (намеренное расхождение: systemd её отбрасывает молча) | +| незакрытая кавычка на EOF | отказ (второе намеренное расхождение: systemd принял бы накопленное) | И обратимость: любое значение — с краевыми пробелами, кавычками, обратными слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без кавычек, чтобы релизные гейты и инструкции оператора продолжали работать. -Управляющий символ в значении — отказ ЗАПИСИ, а не потеря части секрета. +Отдельно проверяется **домен** значений — чужое множество, а не наша политика: + +| Вход | Ожидание | +| --- | --- | +| NUL, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: `utf8_is_valid` отвергает такое значение, и EnvironmentFile не загрузится | +| одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет | +| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность | +| `\n`, `\r`, `\t`, `U+007F`, `U+0085`, `U+FEFF` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт | + +### A4b. Непригодная конфигурация отвергается до первой мутации + +Там же. `validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, поэтому +`preflight-install` и `install` видят отказ одинаково — до bootstrap +оркестратора, apt и раскладки файловой системы. Проверяется: + +- `parseRuntimeEnv` отвергает значение вне домена systemd; +- проверяется КАЖДОЕ значение файла, а не только пароль администратора + (`HY2XS_ADMIN_CON_PASS`, `HY2XS_HYSTERIA_BANDWIDTH_UP`, `HY2XS_ACME_EMAIL`); +- запись и проверка ходят по одному списку `runtimeEnvEntries`; +- всё, что `parseRuntimeEnv` принял, записывается без отказа. Рендер конфига (`orchestrator/test/render-config.test.ts`): @@ -569,7 +588,20 @@ wildcard-маршрутом фронтенда или дублирующая р последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался; - пробел по краям — часть пароля, шесть пробелов являются корректным паролем; -- управляющие символы (`\n`, `\r`, `\t`, `NUL`, `DEL`) отвергаются. +- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`, + `DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а + документация обещала «без управляющих символов» — то есть была шире кода; +- значения вне домена systemd (`U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, + `U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними + `/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует; +- соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются: + правило описывает множество systemd, а не окрестность подозрительных значений; +- `U+FEFF` отвергается **политикой продукта**, а не форматом: + `0xFEFF & 0xFFFE == 0xFEFE`, и systemd такое значение принимает. Отдельный + тест `TestEnvTransportDomainMatchesSystemd` закрепляет, что домен транспорта + не шире и не уже множества systemd, а + `TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются + различимы. `apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги `credentialStr` и `adminPassword` прогоняются через production-валидатор и diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index b0b9dad..762169b 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -55,6 +55,7 @@ 35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken` 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой 37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным +37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне домена systemd (`U+FDD0`, `U+FFFF`, невалидный UTF-8) **роняет `preflight-install`** — то есть отказ приходит до первой мутации хоста, а не после установки пакетов; сервер остаётся нетронутым 38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки ## C0. Панель обязана впускать, а не слушать порт diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 5f22487..607787d 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context"; -import { parseEnvFile, renderEnvFile } from "../lib/envFile"; +import { assertEnvTransportable, parseEnvFile, renderEnvFile } from "../lib/envFile"; import { GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE, @@ -309,6 +309,18 @@ export function parseRuntimeEnv(content: string): RuntimeConfig { }; validateRuntimeConfig(config); + // Проверка транспорта идёт ЗДЕСЬ, а не при записи файла. + // + // Раньше она жила только внутри renderRuntimeEnv, то есть срабатывала на шаге + // «write runtime env» — уже ПОСЛЕ bootstrap оркестратора, установки пакетов и + // раскладки файловой системы. Детерминированно известная ошибка конфигурации + // роняла операцию, оставив за собой изменённый хост, а read-only + // `preflight-install` про неё говорил PASS: он вызывает parseRuntimeEnv и не + // рендерит ничего. + // + // Это противоречит контракту PHASE 0: всё, что про конфигурацию известно + // детерминированно, обязано быть отвергнуто ДО первой необратимой мутации. + validateRuntimeEnvTransport(config); return config; } @@ -393,19 +405,15 @@ export function validateRuntimeConfig(config: RuntimeConfig): void { } /** - * Пишет /etc/hy2xs/hy2xs.env. + * Пары `KEY=VALUE`, которые уезжают в /etc/hy2xs/hy2xs.env. * - * Каждое значение проходит через formatEnvAssignment, а не подставляется в - * строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом - * только для значений без пробелов по краям, кавычек и обратных слешей: пароль - * администратора, у которого набор символов объявлен неограниченным, не - * пережил бы обратного чтения — ни нашего, ни systemd'ного. - * - * Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и - * остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile. + * Вынесены из renderRuntimeEnv, потому что у списка ДВА потребителя: запись + * файла и проверка транспорта, выполняемая задолго до неё. Пока список + * существовал только внутри рендера, единственным способом узнать, что + * конфигурация не запишется, было её записать. */ -export function renderRuntimeEnv(config: RuntimeConfig): string { - const entries: [string, string][] = [ +export function runtimeEnvEntries(config: RuntimeConfig): [string, string][] { + return [ ["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)], ["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)], ["HY2XS_DOMAIN", config.domain], @@ -445,5 +453,35 @@ export function renderRuntimeEnv(config: RuntimeConfig): string { ["HY2XS_DATA_DIR", config.dataDir], ["HY2XS_LOG_DIR", config.logDir] ]; - return `# HY2XS runtime config (editable)\n${renderEnvFile(entries)}`; +} + +/** + * Отвергает конфигурацию, которую нельзя записать в файл окружения так, чтобы + * systemd её прочитал. + * + * Проверяются ВСЕ значения, а не только пароль администратора. Ограничение + * принадлежит формату, а не одному полю: `HY2XS_ADMIN_CON_PASS`, + * `HY2XS_HYSTERIA_OBFS_PASSWORD` и любой будущий параметр сломали бы загрузку + * юнита ровно тем же способом. + */ +export function validateRuntimeEnvTransport(config: RuntimeConfig): void { + for (const [key, value] of runtimeEnvEntries(config)) { + assertEnvTransportable(key, value); + } +} + +/** + * Пишет /etc/hy2xs/hy2xs.env. + * + * Каждое значение проходит через formatEnvAssignment, а не подставляется в + * строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом + * только для значений без пробелов по краям, кавычек и обратных слешей: пароль + * администратора, у которого набор символов объявлен неограниченным, не + * пережил бы обратного чтения — ни нашего, ни systemd'ного. + * + * Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и + * остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile. + */ +export function renderRuntimeEnv(config: RuntimeConfig): string { + return `# HY2XS runtime config (editable)\n${renderEnvFile(runtimeEnvEntries(config))}`; } diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index ba610e5..d2a03da 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -1,4 +1,5 @@ import type { HysteriaObfsType, RuntimeConfig } from "../types/context"; +import { isEnvTransportable } from "../lib/envFile"; /** * HY2XS production profile: единственное место, где определены значения @@ -144,10 +145,23 @@ export function assertValidAdminUsername(name: string, value: string): string { * Пробелы по краям — часть пароля и не снимаются. */ export function isValidAdminPassword(value: string): boolean { + // Домен транспорта проверяется ПЕРВЫМ: значение вне него не доедет до админки + // вовсе — systemd откажется загружать /etc/hy2xs/hy2xs.env, и юнит не + // стартует. Это отказ более грубого рода, чем нарушение границ длины, и + // раньше контракт про него не знал: `abcde` плюс U+FDD0 — шесть символов, + // восемь байт, ни одного управляющего — проходило здесь, записывалось в файл + // и оставляло сервер без работающей панели. + if (!isEnvTransportable(value)) { + return false; + } + let characters = 0; for (const character of value) { const code = character.codePointAt(0) ?? 0; - if (code < 0x20 || code === 0x7f) { + // Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL, + // C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из + // них невозможно ни увидеть в поле ввода, ни повторить при следующем входе. + if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) { return false; } characters += 1; diff --git a/orchestrator/src/lib/envFile.ts b/orchestrator/src/lib/envFile.ts index 3dc76ea..25f114f 100644 --- a/orchestrator/src/lib/envFile.ts +++ b/orchestrator/src/lib/envFile.ts @@ -73,11 +73,18 @@ type State = /** * Разбирает содержимое env-файла ровно так, как это делает systemd. * - * Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча - * отбрасывает, и здесь это единственное намеренное расхождение: молчаливая - * потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой, - * которую оператор задал, а продукт не увидел. Расхождение в сторону отказа - * безопасно — оно останавливает установку там, где её можно починить. + * Расхождений с upstream ровно два, оба намеренные и оба FAIL-CLOSED: + * + * 1. строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча + * отбрасывает; молчаливая потеря строки из /etc/hy2xs/hy2xs.env означала + * бы установку с настройкой, которую оператор задал, а продукт не увидел; + * 2. незакрытая кавычка или escape в конце файла — ОШИБКА. systemd в + * состояниях VALUE_ESCAPE / SINGLE_QUOTE_VALUE / DOUBLE_QUOTE_VALUE + * принимает на EOF то, что успел накопить; для конфигурации, от которой + * зависит доступ в панель, «что успели накопить» — не ответ. + * + * Оба расхождения останавливают операцию там, где её можно починить, вместо + * того чтобы применить не то, что написано в файле. */ export function parseEnvFile(content: string): Record { const result: Record = {}; @@ -210,9 +217,16 @@ export function parseEnvFile(content: string): Record { state = "DOUBLE_QUOTE_VALUE"; if (SHELL_NEED_ESCAPE.includes(c)) { value += c; - } else if (!NEWLINE.includes(c)) { + } else if (c !== "\n") { // Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает // настоящий shell», по формулировке самого systemd. + // + // Условие здесь `c !== "\n"`, а НЕ проверка на любой перевод строки. + // Это не описка upstream и не описка порта: в состоянии VALUE_ESCAPE + // systemd пишет `!strchr(NEWLINE, c)` и съедает и LF, и CR, а здесь — + // `c != '\n'`, то есть `\` даёт `\` + CR. Порт обязан повторять + // это буквально: иначе значение с `\` мы прочитали бы иначе, чем + // тот, для кого файл в конечном счёте написан. value += "\\" + c; } else { line += 1; @@ -273,23 +287,83 @@ export function parseEnvFile(content: string): Record { */ const UNQUOTED_SAFE_VALUE = /^[A-Za-z0-9_\-.\/:@,=+%]+(?: [A-Za-z0-9_\-.\/:@,=+%]+)*$/; +/** Печатает код символа так, как его принято называть в отчётах об ошибке. */ +function describeCodePoint(code: number): string { + return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; +} + /** - * Проверяет, что значение вообще представимо в этом формате. + * Домен значений, которые systemd СМОЖЕТ загрузить из EnvironmentFile. * - * Управляющих символов формат не несёт: перевод строки — граница записи, а не - * данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета + * Это чужое множество, а не наша политика, и оно проверяется отдельно именно + * поэтому: нарушение здесь — не «некрасивое значение», а НЕзагруженный файл + * окружения и, следовательно, юнит, который не стартует. + * + * Перед тем как принять пару, systemd прогоняет ключ и значение через + * `utf8_is_valid` (src/basic/env-file.c, `check_utf8ness_and_warn`), и отказ там + * возвращает `-EINVAL`. `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 + * + * Одиночные суррогаты проверяются ОТДЕЛЬНО и по своей причине. Строка + * JavaScript — это последовательность единиц UTF-16, и она вправе содержать + * непарный суррогат; `TextEncoder` при кодировании молча заменит его на U+FFFD. + * То есть без этой проверки отказа не было бы вовсе — было бы тихое ИЗМЕНЕНИЕ + * секрета по дороге в файл. + * + * Продуктовых ограничений здесь нет: управляющие символы формат несёт, и + * запрещает их контракт учётных данных, а не транспорт. + */ +export function isEnvTransportable(value: string): boolean { + for (const character of value) { + const code = character.codePointAt(0) ?? 0; + if (code === 0) { + return false; + } + if (code >= 0xd800 && code <= 0xdfff) { + return false; + } + if (code >= 0xfdd0 && code <= 0xfdef) { + return false; + } + if ((code & 0xfffe) === 0xfffe) { + return false; + } + } + return true; +} + +/** + * Проверяет, что значение вообще представимо в этом формате, и называет + * причину. + * + * Раньше здесь проверялись только C0 и DEL, а сообщение утверждало, что формат + * «управляющих символов не несёт». Оба утверждения были неверны: управляющие + * символы формат несёт (их запрещает продуктовая политика), а НЕ несёт он + * noncharacters и суррогаты — ровно то, чего проверка не знала. Значение вроде + * `abcde﷐` проходило все двери HY2XS, попадало в /etc/hy2xs/hy2xs.env, и + * админка после этого не стартовала. + * + * Отказ здесь громкий намеренно: молчаливая потеря или подмена части секрета * означала бы установку, после которой невозможно войти, и причину, которой * негде увидеться. */ export function assertEnvTransportable(name: string, value: string): string { for (const character of value) { const code = character.codePointAt(0) ?? 0; - if (code < 0x20 || code === 0x7f) { - throw new Error( - `${name} contains a control character (U+${code.toString(16).toUpperCase().padStart(4, "0")}): ` + - `формат KEY=VALUE, который читают systemd и оркестратор, управляющих символов не несёт` - ); + if (isEnvTransportable(character)) { + continue; } + throw new Error( + `${name} contains ${describeCodePoint(code)}, which systemd refuses to load from an ` + + `EnvironmentFile: значение обязано быть валидным UTF-8 из Unicode scalar values, ` + + `без NUL, без суррогатов и без noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` + + `Файл окружения с таким значением не загрузится, и юнит не стартует.` + ); } return value; } diff --git a/orchestrator/test/env-file.test.ts b/orchestrator/test/env-file.test.ts index 36b8380..3612e3a 100644 --- a/orchestrator/test/env-file.test.ts +++ b/orchestrator/test/env-file.test.ts @@ -3,10 +3,16 @@ import { describe, expect, test } from "bun:test"; import { assertEnvTransportable, formatEnvAssignment, + isEnvTransportable, parseEnvFile, renderEnvFile } from "../src/lib/envFile"; -import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env"; +import { + parseRuntimeEnv, + renderRuntimeEnv, + runtimeEnvEntries, + validateRuntimeEnvTransport +} from "../src/config/env"; import { baselineConfig, envText } from "./fixtures"; /** @@ -141,14 +147,63 @@ describe("запись обратима разбором", () => { expect(formatEnvAssignment("K", "a$b`c")).toBe('K="a$b`c"'); }); - test("управляющий символ — отказ записи, а не потеря части значения", () => { - expect(() => assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", "a\nb")).toThrow( - /control character/ - ); - expect(() => formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", "a\tb")).toThrow( - /control character/ + test("непредставимое значение — отказ записи, а не потеря части секрета", () => { + // Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится. + for (const rejected of [ + String.fromCodePoint(0x0000), + String.fromCodePoint(0xfdd0), + String.fromCodePoint(0xffff), + String.fromCodePoint(0x10ffff), + ]) { + expect(() => + assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) + ).toThrow(/systemd refuses to load/); + expect(() => + formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) + ).toThrow(/systemd refuses to load/); + } + }); + + // Одиночный суррогат — единственный случай, где без проверки не было бы даже + // отказа: `TextEncoder` молча заменил бы его на U+FFFD, то есть в файл уехал + // бы ДРУГОЙ секрет, а не сломанный. + test("одиночный суррогат отвергается, а не подменяется на U+FFFD", () => { + const lone = String.fromCharCode(0xd800); + expect(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd])); + expect(isEnvTransportable(lone)).toBe(false); + expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow( + /systemd refuses to load/ ); }); + + // Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества systemd. + // + // Управляющие символы формат несёт: внутри двойных кавычек перевод строки + // накапливается как обычный байт и переживает round-trip. Запрещает их + // контракт учётных данных, а не транспорт, и приписывать этот запрет формату + // было бы неправдой — именно так проверка и пропустила noncharacters, о + // которых ничего не знала. + test("управляющие символы формат несёт: их запрещает контракт, а не транспорт", () => { + for (const control of ["\n", "\r", "\t", String.fromCodePoint(0x7f), String.fromCodePoint(0x85)]) { + const value = `abcde${control}fghij`; + expect(isEnvTransportable(value)).toBe(true); + const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]); + expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value); + } + + // U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid + // его принимает. Комментарий `/* BOM */` в исходнике systemd относится к + // U+xFFFE и является его собственной неточностью. + expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true); + }); + + // Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно + // множество systemd, а не окрестность подозрительных значений. + test("соседи noncharacters принимаются", () => { + for (const accepted of [0xfdcf, 0xfdf0, 0xfffd, 0x10fffd, 0x1f600]) { + expect(isEnvTransportable(String.fromCodePoint(accepted))).toBe(true); + } + }); }); describe("пароль администратора доезжает до админки неизменным", () => { @@ -189,3 +244,58 @@ describe("пароль администратора доезжает до адм expect(config.adminInitialPassword).toBe("abcdef"); }); }); + +describe("непредставимая конфигурация отвергается до первой мутации", () => { + // Проверка транспорта жила ТОЛЬКО внутри renderRuntimeEnv, то есть + // срабатывала на шаге «write runtime env» — уже после bootstrap оркестратора, + // установки пакетов и раскладки файловой системы. Read-only + // `preflight-install` при этом говорил PASS: он зовёт parseRuntimeEnv и + // ничего не рендерит. Детерминированно известная ошибка конфигурации роняла + // операцию, оставив за собой изменённый хост. + + test("parseRuntimeEnv отвергает значение, которое systemd не загрузит", () => { + const noncharacter = String.fromCodePoint(0xfdd0); + expect(() => + parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: `"abcde${noncharacter}"` })) + ).toThrow(); + }); + + // Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы + // загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через + // requireValue и никаких проверок содержимого раньше не имел вовсе. + test("проверяется каждое значение, а не только пароль администратора", () => { + const noncharacter = String.fromCodePoint(0xffff); + for (const key of [ + "HY2XS_ADMIN_CON_PASS", + "HY2XS_HYSTERIA_BANDWIDTH_UP", + "HY2XS_ACME_EMAIL" + ]) { + expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow( + /systemd refuses to load/ + ); + } + }); + + // Список пар — один на запись и на проверку. Пока он существовал только + // внутри рендера, единственным способом узнать, что конфигурация не + // запишется, было её записать. + test("проверка и запись ходят по одному списку пар", () => { + const config = baselineConfig(); + const entries = runtimeEnvEntries(config); + const rendered = renderRuntimeEnv(config); + + expect(entries.length).toBeGreaterThan(20); + for (const [key] of entries) { + expect(rendered).toContain(`\n${key}=`); + } + expect(() => validateRuntimeEnvTransport(config)).not.toThrow(); + }); + + // Гарантия целиком: всё, что parseRuntimeEnv принял, обязано записаться. + test("принятая конфигурация записывается без отказа", () => { + const config = parseRuntimeEnv( + envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом "' }) + ); + expect(() => renderRuntimeEnv(config)).not.toThrow(); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 3daf26d..b6542de 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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'`, а не проверку на любой + # перевод строки, - иначе `\` мы прочитали бы иначе, чем 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 </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: контракт учётных данных панели отсутствует" diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 10def8a..1face29 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -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);