From ab788725cf27d292eed9c5a81ba0c17f401eebd0 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 6 Sep 2026 23:38:57 +0500 Subject: [PATCH] =?UTF-8?q?fix(env):=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=20=D0=B1=D1=8B=D0=BB=20=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=B0,=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D0=B5=D1=82=20systemd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор предыдущего прохода со сверкой по исходникам 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)). Порт съедал и \, и \; - комментарий обещал одно намеренное расхождение с 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 --- CHANGELOG.md | 80 +++++++++++ apps/credential/admin.go | 116 +++++++++++++--- apps/credential/admin_test.go | 90 +++++++++++++ apps/frontend/src/constants/credentials.ts | 45 ++++++- docs/admin/15-ui-contracts.md | 44 ++++++- .../12-operations-and-troubleshooting.md | 36 ++++- docs/runtime/08-orchestrator-spec.md | 51 ++++++- docs/runtime/09-post-install-env.md | 22 +++- docs/testing/11-2-builder-layer.md | 40 +++++- docs/testing/11-3-target-and-runtime.md | 1 + orchestrator/src/config/env.ts | 64 +++++++-- orchestrator/src/config/profile.ts | 16 ++- orchestrator/src/lib/envFile.ts | 102 ++++++++++++-- orchestrator/test/env-file.test.ts | 124 +++++++++++++++++- tools/build/lib/acceptance.sh | 96 ++++++++++++++ tools/test/frontend-contract.test.ts | 21 +++ 16 files changed, 860 insertions(+), 88 deletions(-) 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);