From 65042ee335fc9f4812233063bdfef642f3f8bf5c Mon Sep 17 00:00:00 2001 From: Crimson Date: Sun, 6 Sep 2026 18:38:04 +0500 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BB=D1=81=D1=8F=20=D1=81=20bcrypt=20=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D1=8B=D1=80=D1=91=D1=85=20=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от 64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция пакета до v0.28. Следствие: пароль из 64 кириллических букв (128 байт) проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на штатной смене пароля, а при установке падением старта админки, то есть сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта было то, что тест закреплял это значение как ожидаемое. Вместе с ним закрыты три соседних расхождения того же контракта. Пароль триммился вопреки собственному контракту. util.HashPassword вёл проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение "abcde " принимали все двери продукта и не мог захешировать никто, а первая учётная запись создавалась не с тем паролем, который оператор записал в hy2xs.env. Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы async-validator, а он сравнивает min/max с String.prototype.length: пароль из трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера, который панель не могла объяснить. hy2xs.env не был форматом. Значения писались интерполяцией, а читались split("=") с trim(); при этом файл читает не только оркестратор — он объявлен EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd срезает краевые пробелы и трактует обратный слеш как escape. Что сделано: - контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword и dao, а service импортирует util — обратный импорт был бы циклическим, и именно поэтому HashPassword завёл собственную копию правила; - AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в оркестраторе и панели; сверяется тестами, читающими Go-исходник; - одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в байтах тегом валидатора не выразить) и код причины admin_password_format, называющий обе границы; - TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет контракт сам и падает с текстом, называющим переменную и файл; - панель считает code points и UTF-8 байты общим adminPasswordFormRule на обеих формах вместо встроенных min/max; - orchestrator/src/lib/envFile.ts — порт конечного автомата parse_env_file_internal из systemd и обратный ему кодировщик; экранируются только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же кодировщиком пишется bootstrap-admin.secret; - управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а ввести такой пароль в форму входа всё равно нельзя; - отрицательная проба smoke сверяет конверт отказа (code 50000, invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль генерирует, а не берёт из литерала; - положительная проба читает bootstrap-секрет парсером формата вместо grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы. Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я», «abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами инвертирован; round-trip env-формата на значениях с кавычками, слешами и краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов приёмки. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 95 +++++- README.md | 2 +- apps/controller/auth_test.go | 182 +++++++--- apps/controller/validator.go | 46 ++- apps/controller/validator_test.go | 126 +++---- apps/credential/admin.go | 173 ++++++++++ apps/credential/admin_test.go | 178 ++++++++++ apps/dao/bootstrap_admin_test.go | 104 ++++++ apps/dao/sqlite.go | 34 +- apps/frontend/src/constants/credentials.ts | 97 +++++- apps/frontend/src/lang/package/en.ts | 5 +- apps/frontend/src/lang/package/ru.ts | 9 +- apps/frontend/src/utils/api-error.ts | 5 + .../src/views/admin/change-password/index.vue | 20 +- apps/frontend/src/views/login/index.vue | 21 +- apps/model/constant/error.go | 19 +- apps/model/dto/admin_user.go | 34 +- apps/model/dto/auth.go | 18 +- apps/model/dto/normalize_test.go | 52 +++ apps/service/admin_credentials.go | 90 ----- apps/util/encrypt.go | 33 +- apps/util/encrypt_test.go | 81 ++++- docs/admin/04-admin-panel.md | 12 + docs/admin/15-ui-contracts.md | 91 ++++- .../12-operations-and-troubleshooting.md | 50 +++ docs/runtime/08-orchestrator-spec.md | 61 +++- docs/runtime/09-post-install-env.md | 48 ++- docs/testing/11-2-builder-layer.md | 87 ++++- docs/testing/11-3-target-and-runtime.md | 37 +- orchestrator/src/config/env.ts | 119 ++++--- orchestrator/src/config/profile.ts | 70 +++- orchestrator/src/lib/envFile.ts | 322 ++++++++++++++++++ orchestrator/src/steps/env.ts | 20 +- orchestrator/src/steps/smoke.ts | 131 ++++++- orchestrator/test/admin-credentials.test.ts | 169 ++++++++- orchestrator/test/env-file.test.ts | 191 +++++++++++ tools/build/lib/acceptance.sh | 92 ++++- tools/test/frontend-contract.test.ts | 122 ++++++- 38 files changed, 2646 insertions(+), 400 deletions(-) create mode 100644 apps/credential/admin.go create mode 100644 apps/credential/admin_test.go create mode 100644 apps/dao/bootstrap_admin_test.go delete mode 100644 apps/service/admin_credentials.go create mode 100644 orchestrator/src/lib/envFile.ts create mode 100644 orchestrator/test/env-file.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4aacd..2aec498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,25 @@ Debian 13. Тема: **разница между «поднялось» и «р настоящий вход. Прогон задокументирован в [docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md](docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md). +Пятнадцатый проход — разбор того, что четырнадцатый построил, со сверкой по +исходникам используемых библиотек, а не по их описаниям. Тема прохода: **новый +контракт, объявленный точнее, чем он есть на самом деле**. Четырнадцатый проход +свёл правило о пароле в одно место и объявил его границей в 64 символа, +обосновав выбор пределом bcrypt в 72 байта. Обоснование верно только для ASCII: +`golang.org/x/crypto/bcrypt` считает БАЙТЫ и отвечает `ErrPasswordTooLong`, а не +«молча отбрасывает остаток», как утверждал комментарий, — так вела себя редакция +пакета до v0.28. Пароль из 64 кириллических букв (128 байт) проходил панель, +оркестратор и DTO и падал в хешировании: системной ошибкой на штатной смене +пароля, а после установки — отсутствием администратора вовсе. Хуже самого +дефекта было то, что новый тест закреплял это значение как ожидаемое. + +Вместе с ним закрыты три соседних расхождения того же контракта: `HashPassword` +и bootstrap триммили пароль, у которого краевой пробел объявлен частью значения; +панель считала длину в единицах UTF-16 (`async-validator`), тогда как сервер +считает code points; и сам транспорт — `hy2xs.env` — не был форматом, +переживающим произвольное значение, хотя его читает не только оркестратор, но и +systemd. + ### Исправлено — вход в панель - **`POST /api/auth/login` паниковал до проверки учётных данных.** На @@ -144,7 +163,7 @@ Debian 13. Тема: **разница между «поднялось» и «р смены пароля назначала до 64: пароль, назначенный штатной операцией, после этого не вводился. Набор символов на пароле отвергал значение, которое сервер принял бы, — сервер его не ограничивает нигде. Контракт учётных данных теперь - объявлен один раз в `apps/service/admin_credentials.go`, а копии в панели и + объявлен один раз в `apps/credential/admin.go`, а копии в панели и оркестраторе сверяются с ним тестами, читающими Go-исходник. - **Класс символов логина был записан диапазоном по опечатке.** Неэкранированный @@ -172,6 +191,80 @@ Debian 13. Тема: **разница между «поднялось» и «р ставится в `service.Login` сразу после успешной проверки пароля; отказ записи вход не отменяет, но попадает в журнал. +### Исправлено — контракт пароля администратора + +- **Граница пароля не совпадала с границей bcrypt.** Предел был объявлен в 64 + СИМВОЛА и обоснован пределом bcrypt в 72 БАЙТА — верно только для ASCII. У 64 + символов длина от 64 до 256 байт: `64 x "я"` — это 128 байт, `19 x "😀"` — 76. + `bcrypt.GenerateFromPassword` (`golang.org/x/crypto@v0.55.0`, `bcrypt.go:96`) + отвечает на такое `ErrPasswordTooLong`. Отказ приходил не с формы, а из + хеширования: на смене пароля — системной ошибкой, при установке — падением + старта админки, то есть сервером без администратора после + `INSTALL EXIT CODE: 0`. Введена отдельная константа `AdminPasswordMaxBytes = + 72`, зеркальная в оркестраторе и панели; граничные значения (36 x `я`, 18 x + `😀` = 72 байта) закреплены тестами во всех четырёх слоях. + +- **Тест закреплял дефект как ожидаемое поведение.** Проверка «длина считается в + символах, а не в байтах» требовала ПРИЁМА пароля из 64 кириллических букв — + ровно того значения, на котором продукт ломался. Тест инвертирован. + +- **Пароль триммился в двух местах вопреки собственному контракту.** Контракт + объявляет краевой пробел частью пароля и не снимает его ни на форме входа, ни + при смене. При этом `util.HashPassword` вёл собственную проверку + `len(strings.TrimSpace(password)) < 6`, а bootstrap читал + `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Следствия: + пароль `abcde ` принимали все двери продукта и не мог захешировать никто, а + первая учётная запись создавалась не с тем паролем, который оператор записал в + `hy2xs.env`. Оба тримминга убраны. + +- **Контракт переехал в leaf-пакет.** Собственная проверка в `HashPassword` + существовала не по недосмотру: `service` импортирует `util`, и позвать оттуда + контракт было невозможно. Правило переехало в `apps/credential`, ниже `util`, + `dao` и `service`, — теперь у него физически не может появиться второго + экземпляра. + +- **Границы пароля стояли рядом с правилом, а не внутри него, и были неполны.** + `min=6,max=64` в тегах DTO — та же конструкция, от которой уже отказались на + логине; у пароля она вдобавок не могла выразить границу в байтах, потому что + правила длины в байтах у `go-playground/validator` нет. Введено одно правило + `adminPassword` и код причины `admin_password_format`, называющий обе границы. + +- **Панель считала длину в единицах UTF-16.** Element Plus делегирует правила + формы библиотеке `async-validator`, а она сравнивает `min`/`max` строки с + `String.prototype.length`. Пароль из трёх эмодзи имел `length === 6`, проходил + минимум формы и получал отказ сервера, который панель не могла объяснить: + сервер видит три руны. Встроенные `min`/`max` на пароле заменены общим + правилом `adminPasswordFormRule`, считающим code points и UTF-8 байты. + +- **`hy2xs.env` не был форматом.** Значения писались интерполяцией, а читались + построчным `split("=")` с `trim()`; совпадение поведения держалось на том, что + в них не встречалось ни пробелов по краям, ни кавычек, ни обратных слешей. При + этом файл читает не только оркестратор: он объявлен `EnvironmentFile=` в юните + `hy2xs-admin`, и у незакавыченного значения systemd срезает краевые пробелы и + трактует `\` как escape. Пароль с краевым пробелом терялся ещё до запуска + админки. Добавлен `orchestrator/src/lib/envFile.ts` — порт конечного автомата + `parse_env_file_internal` из systemd и обратный ему кодировщик; тем же + кодировщиком пишется `bootstrap-admin.secret`. Обычные значения остаются без + кавычек, поэтому релизные гейты и инструкции оператора не меняются. + +- **Управляющие символы запрещены контрактом.** Формат `KEY=VALUE` их не несёт — + перевод строки является границей записи, — а ввести такой пароль в + однострочное поле формы входа всё равно нельзя: он умел только запереть + оператора. Запрет делает транспорт полным: всякий принятый контрактом пароль + доезжает до bcrypt побайтово тем же, каким его задал оператор. + +- **Отрицательная проба smoke не доказывала отказа.** Она сверяла `%{http_code}` + с `200` и доказывала ровно одно: что запрос не закончился пятисоткой — админка + отвечает `200 OK` и на успешный вход тоже. Пароль пробы вдобавок был записан + литералом и теоретически мог оказаться настоящим. Теперь пароль генерируется в + момент пробы, а результат сверяется с конвертом: `code: 50000`, причина + `invalid_credentials`, отсутствие `accessToken`. + +- **Положительная проба smoke читала пароль шелл-конвейером.** `grep … | cut -d= + -f2-` с `.trim()` был третьим по счёту слоем, срезающим краевые пробелы: + проба взяла бы не тот пароль и объявила бы рабочую установку сломанной. + Bootstrap-секрет читается парсером собственного формата, минуя shell. + ### Исправлено — правило доступа - **Исчерпанная квота не отключала пира никогда.** Правило доступа diff --git a/README.md b/README.md index 026e133..aefd123 100644 --- a/README.md +++ b/README.md @@ -635,7 +635,7 @@ hy2xs-orchestrator status \ | `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` | | `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` | | `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` | -| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа, набор символов не ограничен; `__GENERATE__` генерируется при install | `__GENERATE__` | +| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); набор символов не ограничен, кроме управляющих; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` | | `HY2XS_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` | | `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` | | `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` | diff --git a/apps/controller/auth_test.go b/apps/controller/auth_test.go index ada6d13..de7a788 100644 --- a/apps/controller/auth_test.go +++ b/apps/controller/auth_test.go @@ -16,12 +16,12 @@ import ( "time" "github.com/gin-gonic/gin" + "hy2xs-admin/credential" "hy2xs-admin/dao" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/entity" "hy2xs-admin/model/vo" - "hy2xs-admin/service" "hy2xs-admin/util" ) @@ -174,11 +174,16 @@ func TestLoginDtoReportsReasonInsteadOfPanic(t *testing.T) { {"кириллица в логине", "админ12", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"пробел в логине", "hy2xs admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"логин короче минимума", "admin", "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, - {"логин длиннее максимума", strings.Repeat("a", service.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, + {"логин длиннее максимума", strings.Repeat("a", credential.AdminUsernameMaxLength+1), "bootstrap-password", constant.ErrCodeCredentialStr, "username"}, {"пустой логин", " ", "bootstrap-password", constant.ErrCodeRequired, "username"}, {"пустой пароль", "hy2xsadmin", "", constant.ErrCodeRequired, "pass"}, - {"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeMinLength, "pass"}, - {"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", service.AdminPasswordMaxLength+1), constant.ErrCodeMaxLength, "pass"}, + {"пароль короче минимума", "hy2xsadmin", "short", constant.ErrCodeAdminPassword, "pass"}, + {"пароль длиннее максимума", "hy2xsadmin", strings.Repeat("a", credential.AdminPasswordMaxLength+1), constant.ErrCodeAdminPassword, "pass"}, + // Укладывается в 64 символа и НЕ укладывается в 72 байта. Прежний + // контракт принимал это значение, и отказ приходил из bcrypt уже после + // всех проверок — системной ошибкой, а не причиной на поле формы. + {"пароль длиннее предела bcrypt", "hy2xsadmin", strings.Repeat("я", credential.AdminPasswordMaxLength), constant.ErrCodeAdminPassword, "pass"}, + {"управляющий символ в пароле", "hy2xsadmin", "bootstrap\npassword", constant.ErrCodeAdminPassword, "pass"}, } for _, testCase := range cases { @@ -226,13 +231,13 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) { if reason.Code != constant.ErrCodeCredentialStr { continue } - if reason.Params["charset"] != service.AdminUsernameCharset { + if reason.Params["charset"] != credential.AdminUsernameCharset { t.Errorf("причина не называет набор символов: %+v", reason.Params) } - if reason.Params["min"] != strconv.Itoa(service.AdminUsernameMinLength) { + if reason.Params["min"] != strconv.Itoa(credential.AdminUsernameMinLength) { t.Errorf("причина не называет нижнюю границу: %+v", reason.Params) } - if reason.Params["max"] != strconv.Itoa(service.AdminUsernameMaxLength) { + if reason.Params["max"] != strconv.Itoa(credential.AdminUsernameMaxLength) { t.Errorf("причина не называет верхнюю границу: %+v", reason.Params) } return @@ -242,44 +247,88 @@ func TestCredentialReasonNamesCharsetAndBounds(t *testing.T) { // ---------------------------------------------------------------- контракты --- -// Границы пароля объявлены в трёх местах и обязаны совпадать. +// passwordFields — все поля продукта, несущие пароль администратора. +var passwordFields = []struct { + name string + typ reflect.Type + field string +}{ + {"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"}, + {"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"}, + {"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"}, +} + +// Правило пароля ОДНО, и границы живут внутри него. // -// Пока они расходились, панель запирала оператора снаружи: форма смены пароля -// принимала 64 символа, форма входа — 32. Тег структуры не умеет ссылаться на -// константу, поэтому равенство удерживается здесь. -func TestPasswordBoundsAreOneContract(t *testing.T) { - for _, subject := range []struct { - name string - typ reflect.Type - field string - }{ - {"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"}, - {"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"}, - {"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"}, - } { +// Здесь стояли `min=6,max=64` прямо в тегах, и проверка следила за тем, чтобы +// числа совпадали с контрактом. Совпадали они честно — и всё равно закрепляли +// неполное правило: тег считает символы Unicode, а bcrypt считает байты, и +// границы в байтах тегом не выразить вовсе. Поэтому теперь проверяется не +// равенство чисел, а ОТСУТСТВИЕ чисел: длина принадлежит правилу, а не соседям +// по тегу. Тот же барьер уже стоит на логине (TestUsernameLengthLivesInsideItsRule). +func TestPasswordLengthLivesInsideItsRule(t *testing.T) { + for _, subject := range passwordFields { field, ok := subject.typ.FieldByName(subject.field) if !ok { t.Fatalf("%s: поле не найдено", subject.name) } - bounds := map[string]int{} - for _, rule := range splitValidationRules(field.Tag.Get("validate")) { - name, param, found := strings.Cut(rule, "=") - if !found || (name != "min" && name != "max") { - continue + rules := splitValidationRules(field.Tag.Get("validate")) + + for _, rule := range rules { + if strings.HasPrefix(rule, "min=") || strings.HasPrefix(rule, "max=") { + t.Errorf( + "%s: отдельная граница %q рядом с правилом. Длина принадлежит adminPassword: "+ + "тег умеет считать только символы, а у пароля есть ещё граница в байтах", + subject.name, rule, + ) } - value, err := strconv.Atoi(param) - if err != nil { - t.Fatalf("%s: граница %q не число", subject.name, rule) - } - bounds[name] = value } - if bounds["min"] != service.AdminPasswordMinLength { - t.Errorf("%s: min=%d, а контракт — %d", subject.name, bounds["min"], service.AdminPasswordMinLength) + var hasPasswordRule bool + for _, rule := range rules { + if rule == "adminPassword" { + hasPasswordRule = true + } } - if bounds["max"] != service.AdminPasswordMaxLength { - t.Errorf("%s: max=%d, а контракт — %d", subject.name, bounds["max"], service.AdminPasswordMaxLength) + if !hasPasswordRule { + t.Errorf("%s: проверяется правилами %v — среди них нет adminPassword", subject.name, rules) + } + } +} + +// Все поля пароля проверяются ТЕМ ЖЕ правилом, что и bootstrap-установка. +// +// Проверка идёт через production-валидатор на граничных значениях: совпадения +// имён тегов мало, потому что именно расхождение реализаций за одинаковыми +// именами и было исходным дефектом. +func TestEveryPasswordFieldFollowsTheContract(t *testing.T) { + samples := []string{ + strings.Repeat("a", credential.AdminPasswordMinLength), + strings.Repeat("a", credential.AdminPasswordMinLength-1), + strings.Repeat("a", credential.AdminPasswordMaxLength), + strings.Repeat("я", 36), + strings.Repeat("я", 37), + strings.Repeat("😀", 18), + strings.Repeat("😀", 19), + strings.Repeat("я", credential.AdminPasswordMaxLength), + "abcde ", + "abcde\n", + } + + for _, subject := range passwordFields { + field, _ := subject.typ.FieldByName(subject.field) + rules := strings.Join(splitValidationRules(field.Tag.Get("validate")), ",") + rules = strings.ReplaceAll(rules, "required,", "") + + for _, sample := range samples { + expected := credential.IsValidAdminPassword(sample) + if got := validate.Var(sample, rules) == nil; got != expected { + t.Errorf( + "%s: значение из %d символов / %d байт принято=%v, контракт=%v", + subject.name, len([]rune(sample)), len(sample), got, expected, + ) + } } } } @@ -291,15 +340,7 @@ func TestPasswordBoundsAreOneContract(t *testing.T) { // который сервер принял бы. Панель обязана держаться этого же контракта, и // проверка существует, чтобы правило не «навели» обратно. func TestPasswordHasNoCharsetRule(t *testing.T) { - for _, subject := range []struct { - name string - typ reflect.Type - field string - }{ - {"LoginDto.Pass", reflect.TypeOf(dto.LoginDto{}), "Pass"}, - {"AdminChangePasswordDto.OldPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "OldPassword"}, - {"AdminChangePasswordDto.NewPassword", reflect.TypeOf(dto.AdminChangePasswordDto{}), "NewPassword"}, - } { + for _, subject := range passwordFields { field, _ := subject.typ.FieldByName(subject.field) for _, rule := range splitValidationRules(field.Tag.Get("validate")) { if rule == "credentialStr" || rule == "peerName" { @@ -556,7 +597,7 @@ func TestLoginEndpointDoesNotTrimPassword(t *testing.T) { // Ровно этот путь был закрыт: форма входа ограничивала пароль 32 символами при // серверном пределе в 64, и оператор терял доступ после штатной смены пароля. func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) { - password := strings.Repeat("a", service.AdminPasswordMaxLength) + password := strings.Repeat("a", credential.AdminPasswordMaxLength) newAuthTestDB(t, "hy2xsadmin", password) _, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) @@ -565,6 +606,57 @@ func TestLoginEndpointAcceptsFullLengthPassword(t *testing.T) { } } +// Пароль, упирающийся в предел bcrypt ровно, обязан работать целиком: он +// назначается формой смены пароля и вводится формой входа. +// +// 36 кириллических букв — это ровно 72 байта, то есть последнее значение, +// которое GenerateFromPassword принимает. Проверяется весь путь: хеширование +// при создании учётной записи и вход этим же значением. +func TestLoginEndpointAcceptsPasswordAtBcryptByteLimit(t *testing.T) { + password := strings.Repeat("я", 36) + if len(password) != credential.AdminPasswordMaxBytes { + t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes) + } + newAuthTestDB(t, "hy2xsadmin", password) + + _, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) + if result.Code != constant.CodeSuccess { + t.Fatalf("пароль в 72 байта отклонён: %+v", result) + } +} + +// А на символ длиннее — отказ ПРАВИЛА, с причиной на поле, а не системная +// ошибка из bcrypt. +// +// Ровно этого не было: 37 кириллических букв (74 байта) укладывались в 64 +// символа, проходили все проверки и упирались в ErrPasswordTooLong уже внутри +// хеширования. На форме смены пароля оператор видел «system error». +func TestLoginEndpointRejectsPasswordOverBcryptByteLimitWithFieldReason(t *testing.T) { + newAuthTestDB(t, "hy2xsadmin", "bootstrap-password") + + password := strings.Repeat("я", 37) + if len(password) <= credential.AdminPasswordMaxBytes { + t.Fatalf("подготовка: %d байт, ожидалось больше %d", len(password), credential.AdminPasswordMaxBytes) + } + + status, result := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) + if status != http.StatusOK { + t.Fatalf("HTTP %d: обработчик обязан отвечать конвертом", status) + } + if result.Code != constant.CodeInvalidError { + t.Fatalf("код ответа %d, ожидался отказ проверки данных %d: %+v", result.Code, constant.CodeInvalidError, result) + } + var hasReason bool + for _, reason := range result.Errors { + if reason.Field == "pass" && reason.Code == constant.ErrCodeAdminPassword { + hasReason = true + } + } + if !hasReason { + t.Fatalf("отказ не назван причиной %q на поле pass: %+v", constant.ErrCodeAdminPassword, result) + } +} + // Отметка о входе обязана писаться: колонка есть в схеме, а писать её было // некому — UpdateAdminLastLoginAt не вызывался ниоткуда. func TestLoginEndpointRecordsLastLoginAt(t *testing.T) { diff --git a/apps/controller/validator.go b/apps/controller/validator.go index 5f56be3..acde6b6 100644 --- a/apps/controller/validator.go +++ b/apps/controller/validator.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "hy2xs-admin/credential" "hy2xs-admin/model/constant" "hy2xs-admin/model/dto" "hy2xs-admin/model/vo" @@ -33,6 +34,7 @@ func init() { mustRegister("peerName", validatePeerName) mustRegister("credentialStr", validateCredentialStr) + mustRegister("adminPassword", validateAdminPassword) } func mustRegister(tag string, fn validator.Func) { @@ -74,7 +76,23 @@ func validatePeerName(f validator.FieldLevel) bool { // его в «не задано», и получает отказ `required` — то есть ту причину, которая // у него на самом деле. func validateCredentialStr(f validator.FieldLevel) bool { - return service.IsValidAdminUsername(f.Field().String()) + return credential.IsValidAdminUsername(f.Field().String()) +} + +// validateAdminPassword — единственное правило пароля администратора. +// +// Здесь стояли `min=6,max=64` прямо в теге, и это была та же конструкция, от +// которой уже отказались на логине: границы жили РЯДОМ с правилом, а не внутри +// него. У пароля она вдобавок была неполна. Тег `max=64` считает символы +// Unicode, а bcrypt считает байты, поэтому пароль из 64 кириллических букв — +// 128 байт — проходил проверку целиком и падал только в хешировании, уже как +// системная ошибка на штатной смене пароля. +// +// Границу в байтах тегом не выразить: у валидатора нет правила длины в байтах. +// Поэтому всё правило целиком переехало в contract-функцию, и теперь у формы +// входа, формы смены пароля, bootstrap-установки и `reset-admin` она одна. +func validateAdminPassword(f validator.FieldLevel) bool { + return credential.IsValidAdminPassword(f.Field().String()) } // validateField разбирает запрос, приводит его к каноничному виду и проверяет @@ -208,13 +226,31 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError { // отказ по длине этой фразой описывался как отказ по символам. described.Code = constant.ErrCodeCredentialStr described.Params = map[string]string{ - "min": fmt.Sprintf("%d", service.AdminUsernameMinLength), - "max": fmt.Sprintf("%d", service.AdminUsernameMaxLength), - "charset": service.AdminUsernameCharset, + "min": fmt.Sprintf("%d", credential.AdminUsernameMinLength), + "max": fmt.Sprintf("%d", credential.AdminUsernameMaxLength), + "charset": credential.AdminUsernameCharset, } described.Message = fmt.Sprintf( "поле %q: от %d до %d символов из набора %s", - field, service.AdminUsernameMinLength, service.AdminUsernameMaxLength, service.AdminUsernameCharset, + field, credential.AdminUsernameMinLength, credential.AdminUsernameMaxLength, credential.AdminUsernameCharset, + ) + case "adminPassword": + // Причина называет ОБЕ границы, потому что их две и они в разных + // единицах. Отказ «не длиннее 64 символов» на пароле из 40 эмодзи был + // бы неправдой: символов там 40, а байтов 160, и оператор, послушав + // такую причину, сокращал бы пароль, который отвергнут не за это. + described.Code = constant.ErrCodeAdminPassword + described.Params = map[string]string{ + "min": fmt.Sprintf("%d", credential.AdminPasswordMinLength), + "max": fmt.Sprintf("%d", credential.AdminPasswordMaxLength), + "maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes), + } + described.Message = fmt.Sprintf( + "поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов", + field, + credential.AdminPasswordMinLength, + credential.AdminPasswordMaxLength, + credential.AdminPasswordMaxBytes, ) default: described.Code = constant.ErrCodeRuleUnknown diff --git a/apps/controller/validator_test.go b/apps/controller/validator_test.go index 3a1703c..aee491f 100644 --- a/apps/controller/validator_test.go +++ b/apps/controller/validator_test.go @@ -4,99 +4,67 @@ import ( "strings" "testing" + "hy2xs-admin/credential" "hy2xs-admin/service" ) -// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством. +// Правила валидатора обязаны спрашивать КОНТРАКТ, а не собственную копию. // -// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный -// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`. -// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя -// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила -// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать -// оператора в панель. -// -// Тест существует, чтобы это решение было явным: попытка «навести порядок» в -// классе символов уронит его, а не вход администратора на живом сервере. -// -// Спрашивается service.IsValidAdminUsername, а не регексп рядом: правило -// объявлено там один раз, и проверка обязана спрашивать ровно то, что работает -// в бою. Прежняя версия обращалась к копии внутри слоя контроллеров — то есть -// не увидела бы расхождения между копией и правилом. -func TestAdminUsernameCharsetIsUnchanged(t *testing.T) { - const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<=" - - for _, symbol := range strings.Split(historical, "") { - candidate := "admin" + symbol - if !service.IsValidAdminUsername(candidate) { - t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol) - } - } - - for _, rejected := range []string{ - "", // не задано - "admi", // короче шести символов - strings.Repeat("a", 33), // длиннее тридцати двух - "admin пробел", // пробел - "админ1", // кириллица - "admin\n1", // перевод строки - "admin'1", // апостроф вне набора +// Прежние версии этих проверок обращались к регекспу внутри слоя контроллеров и +// потому не увидели бы расхождения между копией и правилом — а именно так этот +// класс дефектов и появлялся: набор символов логина существовал в четырёх +// местах, и все четыре разошлись. Сам контракт проверяется в пакете credential; +// здесь проверяется ПРОВОДКА: тег валидатора и функция контракта отвечают +// одинаково на одних и тех же значениях. +func TestCredentialStrTagDelegatesToContract(t *testing.T) { + for _, candidate := range []string{ + "hy2xsadmin", + "admin.ops", + "admin+1", + "admi", + "админ12", + "admin пробел", + strings.Repeat("a", credential.AdminUsernameMaxLength+1), } { - if service.IsValidAdminUsername(rejected) { - t.Errorf("значение %q принято логином, ожидался отказ", rejected) + expected := credential.IsValidAdminUsername(candidate) + if got := validate.Var(candidate, "credentialStr") == nil; got != expected { + t.Errorf("тег credentialStr на %q дал %v, контракт — %v", candidate, got, expected) } } } -// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор -// обязан описывать тот же класс, что и регексп. +// Тот же вопрос про пароль, и он важнее. // -// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию: -// оператор читает набор, которого правило не принимает. -func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) { - if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength-1)) { - t.Errorf("логин длиной %d принят при минимуме %d", service.AdminUsernameMinLength-1, service.AdminUsernameMinLength) - } - if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMinLength)) { - t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMinLength) - } - if !service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength)) { - t.Errorf("логин длиной ровно %d отклонён", service.AdminUsernameMaxLength) - } - if service.IsValidAdminUsername(strings.Repeat("a", service.AdminUsernameMaxLength+1)) { - t.Errorf("логин длиной %d принят при максимуме %d", service.AdminUsernameMaxLength+1, service.AdminUsernameMaxLength) - } - - // Человекочитаемый набор перечисляет ровно те же знаки препинания, что и - // класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ. - punctuation := strings.Fields(service.AdminUsernameCharset) - for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") { - if !service.IsValidAdminUsername("admin" + symbol) { - t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol) +// Границы пароля стояли прямо в теге (`min=6,max=64`), и тег НЕ МОГ выразить +// предел bcrypt в байтах — правила длины в байтах у go-playground/validator +// нет. Поэтому пароль из 64 кириллических букв проходил валидацию формы и +// падал в хешировании, возвращаясь оператору системной ошибкой. Проверка +// закрепляет, что правило теперь одно и что оно то же самое, которым +// пользуются bootstrap-установка и `reset-admin`. +func TestAdminPasswordTagDelegatesToContract(t *testing.T) { + for _, candidate := range []string{ + "bootstrap-password", + "abcde ", + strings.Repeat("a", credential.AdminPasswordMinLength-1), + strings.Repeat("a", credential.AdminPasswordMaxLength), + strings.Repeat("a", credential.AdminPasswordMaxLength+1), + strings.Repeat("я", 36), + strings.Repeat("я", 37), + strings.Repeat("😀", 18), + strings.Repeat("😀", 19), + strings.Repeat("я", credential.AdminPasswordMaxLength), + "abcde\n", + } { + expected := credential.IsValidAdminPassword(candidate) + if got := validate.Var(candidate, "adminPassword") == nil; got != expected { + t.Errorf( + "тег adminPassword на значении из %d символов / %d байт дал %v, контракт — %v", + len([]rune(candidate)), len(candidate), got, expected, + ) } } } -// Длина пароля считается в РУНАХ — ровно так её считает go-playground/validator -// в правилах `min`/`max` на строке. -// -// Расхождение означало бы, что пароль с кириллицей проходит проверку на одной -// двери и отказывается на другой: 32 кириллические буквы — это 64 БАЙТА. -func TestAdminPasswordLengthIsCountedInRunes(t *testing.T) { - if !service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength)) { - t.Errorf("пароль из %d кириллических букв отклонён: длина считается в байтах", service.AdminPasswordMaxLength) - } - if service.IsValidAdminPassword(strings.Repeat("я", service.AdminPasswordMaxLength+1)) { - t.Error("пароль длиннее максимума принят") - } - if service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength-1)) { - t.Error("пароль короче минимума принят") - } - if !service.IsValidAdminPassword(strings.Repeat("a", service.AdminPasswordMinLength)) { - t.Error("пароль длиной ровно в минимум отклонён") - } -} - // Имя пира проверяется ОДНИМ правилом на весь продукт: панель и импорт ведут в // одну таблицу и не имеют права требовать разного. func TestPeerNameRuleIsSharedWithImport(t *testing.T) { diff --git a/apps/credential/admin.go b/apps/credential/admin.go new file mode 100644 index 0000000..b14b110 --- /dev/null +++ b/apps/credential/admin.go @@ -0,0 +1,173 @@ +// Package credential объявляет контракт учётных данных администратора — ОДИН +// на весь продукт. +// +// Почему это отдельный пакет, а не файл в service. Контракт нужен четырём +// слоям сразу, и два из них лежат НИЖЕ service по графу импортов: +// +// controller -> service -> util (хеширование пароля) +// dao -> util (создание первой учётной записи) +// cmd -> dao, util (reset-admin) +// +// Пока контракт жил в service, `util.HashPassword` не мог его позвать — импорт +// был бы циклическим, — и завёл собственную проверку `len(TrimSpace(p)) < 6`. +// Эта третья копия правила разошлась с остальными ровно тем способом, ради +// предотвращения которого контракт и объявлен: LoginDto считал `"abcde "` +// корректным паролем, а HashPassword отказывался его хешировать. Пакет-лист +// ниже всех потребителей — единственная запись, при которой у правила +// физически не может появиться второго экземпляра. +package credential + +import ( + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +// Контракт учётных данных администратора. +// +// Про логин и пароль администратора одновременно знали четыре места: регексп в +// слое контроллеров, границы длины в тегах LoginDto, правило формы входа в +// панели и правило формы смены пароля в ней же. Ни одно из них не ссылалось на +// остальные, и все четыре успели разойтись: +// +// - тег `validateStr` на Username пережил переименование правила и остался +// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе, +// а gin.Recovery превращал панику в HTTP 500; +// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64, +// сервер — тоже 64. То есть пароль, назначенный штатной формой смены, +// форма входа отказывалась отправлять: панель запирала оператора снаружи +// после операции, которую сама же и предложила; +// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в +// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка +// завершалась успешно, а войти в панель было нельзя. +// +// Общее у всех трёх — контракт существовал только как совпадение литералов, +// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт +// IsValidAdminUsername и IsValidAdminPassword, а расхождение с панелью и +// оркестратором ловится тестами, читающими этот же файл. +const ( + AdminUsernameMinLength = 6 + AdminUsernameMaxLength = 32 + + // Границы пароля в СИМВОЛАХ Unicode (code points). + AdminPasswordMinLength = 6 + AdminPasswordMaxLength = 64 + + // AdminPasswordMaxBytes — предел, установленный bcrypt. + // + // Он объявлен ОТДЕЛЬНО от AdminPasswordMaxLength, потому что это ДРУГАЯ + // единица измерения, и подменять одну другой нельзя. + // + // Здесь стоял ровно этот дефект. Верхняя граница в 64 символа была выбрана + // «заведомо ниже 72 байт», и обоснование выглядело убедительно, пока пароль + // оставался ASCII. Но `golang.org/x/crypto/bcrypt` считает БАЙТЫ: + // + // 64 x "a" = 64 байта -> GenerateFromPassword принимает + // 64 x "я" = 128 байт -> ErrPasswordTooLong + // 19 x "😀" = 76 байт -> ErrPasswordTooLong + // + // причём отказ приходил НЕ на форме, а из хеширования: смена пароля на 64 + // кириллические буквы проходила все проверки продукта и заканчивалась + // системной ошибкой на штатной операции. Комментарий в прежней редакции + // вдобавок утверждал, что bcrypt «молча отбрасывает остаток» — так вело + // себя издание пакета до v0.28; действующее (v0.55.0, bcrypt.go:96) + // отвечает ошибкой: + // + // if len(password) > 72 { return nil, ErrPasswordTooLong } + AdminPasswordMaxBytes = 72 +) + +// adminUsernameCharacterClass — набор символов логина в записи регекспа. +// +// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая +// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=` +// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь +// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и +// установка с логином вроде `admin.ops` перестала бы пускать оператора в +// панель. Набор и проверка на стороне оркестратора меняются только вместе. +const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=` + +// AdminUsernameCharset — тот же набор в том виде, в каком его показывают +// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило +// собственную копию того же множества. +const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` + +// adminUsernamePattern собирается из констант, а не пишется литералом: границы +// длины обязаны быть одними и теми же для правила и для сообщения о нём. +var adminUsernamePattern = regexp.MustCompile( + "^[" + adminUsernameCharacterClass + "]{" + + strconv.Itoa(AdminUsernameMinLength) + "," + + strconv.Itoa(AdminUsernameMaxLength) + "}$", +) + +// IsValidAdminUsername сообщает, пригодно ли имя администратора. +// +// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты +// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда +// значение приходит из файла окружения, а не из DTO. +func IsValidAdminUsername(username string) bool { + return adminUsernamePattern.MatchString(strings.TrimSpace(username)) +} + +// IsValidAdminPassword — единственное правило пароля администратора. +// +// Правило состоит из трёх частей и НИ ОДНА из них не является набором +// символов: +// +// 6-64 code points — граница, которую видит оператор; +// <= 72 UTF-8 bytes — граница, которую ставит bcrypt; +// без управляющих — граница, которую ставит транспорт. +// +// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает +// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её +// видит оператор: «пароль из 64 символов» обязано означать одно и то же для +// латиницы и для кириллицы. +// +// Предел в БАЙТАХ существует одновременно с ним, потому что это ограничение +// другого слоя — хранилища пароля, — и выразить его через символы нельзя: у +// 64 символов длина от 64 до 256 байт в зависимости от алфавита. +// +// Управляющие символы запрещены, и это единственное ограничение НАБОРА в +// контракте. Оно введено не ради строгости: первый пароль администратора +// уезжает в /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile, и +// у перевода строки там нет представления, переживающего запись и чтение. При +// этом такой пароль всё равно невозможно ввести в однострочное поле формы +// входа, то есть он умеет ровно одно — запереть оператора снаружи. Запрет +// делает транспорт ПОЛНЫМ: всякий пароль, принятый этим правилом, доезжает до +// bcrypt побайтово тем же, каким его задал оператор. +// +// Набор символов НЕ ограничивается ничем сверх этого. Пароль назначает +// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, — +// и сервер нигде его набор не проверяет. Ограничение на форме входа не +// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает +// пароль, который сервер принял бы. +// +// Пробелы по краям — ЧАСТЬ пароля и не снимаются нигде: ни здесь, ни в +// нормализации DTO, ни при чтении bootstrap-окружения. +func IsValidAdminPassword(password string) bool { + runes := utf8.RuneCountInString(password) + if runes < AdminPasswordMinLength || runes > AdminPasswordMaxLength { + return false + } + if len(password) > AdminPasswordMaxBytes { + return false + } + return !hasControlRune(password) +} + +// hasControlRune сообщает, есть ли в строке управляющий символ. +// +// Проверяются C0 (U+0000-U+001F) и DEL (U+007F). Управляющие символы Unicode +// вне ASCII сюда не попадают намеренно: у systemd границей строки является +// байт `\n` или `\r`, и U+2028 для него — три обычных байта, а не перевод +// строки. Правило описывает ровно то, что ломает транспорт, и ни символом +// больше. +func hasControlRune(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7F { + return true + } + } + return false +} diff --git a/apps/credential/admin_test.go b/apps/credential/admin_test.go new file mode 100644 index 0000000..ef1e356 --- /dev/null +++ b/apps/credential/admin_test.go @@ -0,0 +1,178 @@ +package credential + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством. +// +// Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный +// дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`. +// Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя +// администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила +// означало бы, что установка с логином вроде `admin.ops` перестаёт пускать +// оператора в панель. +// +// Тест существует, чтобы это решение было явным: попытка «навести порядок» в +// классе символов уронит его, а не вход администратора на живом сервере. +func TestAdminUsernameCharsetIsUnchanged(t *testing.T) { + const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<=" + + for _, symbol := range strings.Split(historical, "") { + candidate := "admin" + symbol + if !IsValidAdminUsername(candidate) { + t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol) + } + } + + for _, rejected := range []string{ + "", // не задано + "admi", // короче шести символов + strings.Repeat("a", 33), // длиннее тридцати двух + "admin пробел", // пробел + "админ1", // кириллица + "admin\n1", // перевод строки + "admin'1", // апостроф вне набора + } { + if IsValidAdminUsername(rejected) { + t.Errorf("значение %q принято логином, ожидался отказ", rejected) + } + } +} + +// Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор +// обязан описывать тот же класс, что и регексп. +// +// Расхождение здесь тихо превращает сообщение об отказе в дезинформацию: +// оператор читает набор, которого правило не принимает. +func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) { + if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) { + t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength) + } + if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) { + t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength) + } + if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) { + t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength) + } + if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) { + t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength) + } + + // Человекочитаемый набор перечисляет ровно те же знаки препинания, что и + // класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ. + punctuation := strings.Fields(AdminUsernameCharset) + for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") { + if !IsValidAdminUsername("admin" + symbol) { + t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol) + } + } +} + +// Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные +// границы. +// +// Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72 +// байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок +// требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым +// фиксировал как ожидаемое ровно то значение, на котором продукт ломался: +// оркестратор его пропускал, DTO пропускало, а bcrypt отвечал +// ErrPasswordTooLong уже при создании учётной записи. +func TestAdminPasswordBoundaries(t *testing.T) { + // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» — + // четырёхбайтовым, случаи ниже проверяли бы не то, что написано. + if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes { + t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) + } + if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes { + t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) + } + + cases := []struct { + name string + password string + accepted bool + }{ + {"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true}, + {"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false}, + {"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true}, + {"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false}, + + // Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов. + {"36 x «я» = 72 байта", strings.Repeat("я", 36), true}, + {"37 x «я» = 74 байта", strings.Repeat("я", 37), false}, + {"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true}, + {"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false}, + + // Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА. + {"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false}, + + // Пробелы по краям — часть пароля, а не мусор ввода. + {"пробел на конце", "abcde ", true}, + {"пробел в начале", " abcde", true}, + {"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true}, + + // Набор символов не ограничен ничем, кроме управляющих. + {"кириллица", "пароль-администратора", true}, + {"знаки препинания", `p@$$w0rd"\'#;`, true}, + {"перевод строки", "abcde\n", false}, + {"возврат каретки", "abcde\r", false}, + {"табуляция", "abcde\t", false}, + {"нулевой байт", "abcde\x00", false}, + {"DEL", "abcde\x7f", false}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := IsValidAdminPassword(testCase.password); got != testCase.accepted { + t.Errorf( + "IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)", + got, testCase.accepted, + utf8.RuneCountInString(testCase.password), len(testCase.password), + ) + } + }) + } +} + +// Предел в байтах не выводится из предела в символах, и обратное неверно тоже. +// +// Проверка защищает от «упрощения», при котором одну из двух констант объявят +// производной от другой: у 64 символов длина от 64 до 256 байт. +func TestAdminPasswordLimitsAreIndependent(t *testing.T) { + if AdminPasswordMaxBytes != 72 { + t.Fatalf( + "предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72", + AdminPasswordMaxBytes, + ) + } + + // Пароль, укладывающийся в символы и не укладывающийся в байты, обязан + // существовать — иначе байтовая граница недостижима и ничего не проверяет. + long := strings.Repeat("я", AdminPasswordMaxLength) + if utf8.RuneCountInString(long) > AdminPasswordMaxLength { + t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница") + } + if IsValidAdminPassword(long) { + t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется") + } +} + +// Пароль НЕ триммится нигде, включая сам контракт. +// +// Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace, +// значение `"abcde "` считалось корректным всеми дверями продукта и не могло +// быть захешировано. +func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) { + // Шесть символов, из которых последний — пробел. По длине без пробела это + // пять символов, то есть прежняя проверка отказала бы. + const password = "abcde " + if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength { + t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то") + } + if !IsValidAdminPassword(password) { + t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения") + } +} diff --git a/apps/dao/bootstrap_admin_test.go b/apps/dao/bootstrap_admin_test.go new file mode 100644 index 0000000..a904be1 --- /dev/null +++ b/apps/dao/bootstrap_admin_test.go @@ -0,0 +1,104 @@ +package dao + +import ( + "path/filepath" + "strings" + "testing" + + "hy2xs-admin/credential" + "hy2xs-admin/util" +) + +// Bootstrap-путь администратора обязан подчиняться ОБЩЕМУ контракту пароля. +// +// Здесь было два расхождения, и оба ломали установку молча. +// +// Первое: `strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))`. Продукт +// объявляет пробел по краям частью пароля и не снимает его ни на форме входа, +// ни при смене, ни в оркестраторе — а здесь снимал. Учётная запись создавалась +// с паролем, отличным от того, который оператор записал в hy2xs.env и которым +// он потом пытался войти. +// +// Второе: контракт не проверялся вовсе, поэтому непригодный для bcrypt пароль +// (64 кириллические буквы = 128 байт) доходил до GenerateFromPassword, и старт +// службы падал с «bcrypt: password length exceeds 72 bytes» — сообщением, по +// которому нельзя понять ни что чинить, ни где. + +func startAdminBootstrap(t *testing.T, password string) error { + t.Helper() + + t.Setenv("HY2XS_ADMIN_USER", "hy2xsadmin") + t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", password) + t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret) + t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken) + + err := InitSqlAt(filepath.Join(t.TempDir(), "hy2xs-admin-test.db")) + t.Cleanup(func() { _ = CloseSqliteDB() }) + return err +} + +// Пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом. +func TestBootstrapAdminKeepsSurroundingWhitespaceInPassword(t *testing.T) { + const password = "bootstrap-admin-password " + + if err := startAdminBootstrap(t, password); err != nil { + t.Fatalf("запуск сервиса не удался: %v", err) + } + + admin, err := GetAdminUser("username = ?", "hy2xsadmin") + if err != nil { + t.Fatalf("учётная запись администратора не создана: %v", err) + } + if admin.PasswordHash == nil { + t.Fatal("у созданной учётной записи нет хеша пароля") + } + + if !util.VerifyPassword(password, *admin.PasswordHash) { + t.Error("вход исходным значением невозможен: пароль обрезан при создании учётной записи") + } + if util.VerifyPassword(strings.TrimSpace(password), *admin.PasswordHash) { + t.Error("вход обрезанным значением возможен: пробел по краям снят вопреки контракту") + } +} + +// Пароль, не проходящий контракт, роняет старт с ПРИЧИНОЙ, а не с ошибкой +// bcrypt, и учётной записи после этого не остаётся. +func TestBootstrapAdminRefusesPasswordOutsideTheContract(t *testing.T) { + // 64 символа — в границе; 128 байт — за пределом bcrypt. + password := strings.Repeat("я", credential.AdminPasswordMaxLength) + if credential.IsValidAdminPassword(password) { + t.Fatal("подготовка: контракт принимает значение, проверяется не то") + } + + err := startAdminBootstrap(t, password) + if err == nil { + t.Fatal("старт с непригодным паролем завершился успешно") + } + if !strings.Contains(err.Error(), "HY2XS_ADMIN_INITIAL_PASSWORD") { + t.Errorf("отказ не называет переменную, которую надо чинить: %v", err) + } + if !strings.Contains(err.Error(), "hy2xs.env") { + t.Errorf("отказ не называет файл, в котором чинится причина: %v", err) + } +} + +// Пароль ровно в предел bcrypt (72 байта) — законное значение, и установка с +// ним обязана проходить целиком. +func TestBootstrapAdminAcceptsPasswordAtBcryptByteLimit(t *testing.T) { + password := strings.Repeat("я", 36) + if len(password) != credential.AdminPasswordMaxBytes { + t.Fatalf("подготовка: %d байт вместо %d", len(password), credential.AdminPasswordMaxBytes) + } + + if err := startAdminBootstrap(t, password); err != nil { + t.Fatalf("установка с паролем в 72 байта не удалась: %v", err) + } + + admin, err := GetAdminUser("username = ?", "hy2xsadmin") + if err != nil { + t.Fatalf("учётная запись администратора не создана: %v", err) + } + if !util.VerifyPassword(password, *admin.PasswordHash) { + t.Error("пароль в 72 байта не проходит проверку собственным хешем") + } +} diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 66eb0d0..5b4817b 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -3,11 +3,13 @@ package dao import ( "encoding/base64" "errors" + "fmt" "github.com/glebarez/sqlite" "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/logger" "gorm.io/gorm/schema" + "hy2xs-admin/credential" "hy2xs-admin/model/constant" "hy2xs-admin/model/entity" "hy2xs-admin/util" @@ -132,7 +134,14 @@ func ensureSecureBootstrapAdmin() error { return nil } - adminPassword := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")) + // Пароль читается КАК ЕСТЬ. + // + // Здесь стоял strings.TrimSpace, и он противоречил контракту продукта: + // пробел по краям объявлен частью пароля и не снимается ни на форме входа, + // ни при смене пароля, ни в оркестраторе. То есть bootstrap-путь + // гарантированно создавал учётную запись НЕ с тем паролем, который оператор + // записал в hy2xs.env, а войти в неё он пытался бы исходным значением. + adminPassword := os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD") if adminPassword == "" { return errors.New( "HY2XS_ADMIN_INITIAL_PASSWORD не задан, а учётной записи администратора в базе нет.\n" + @@ -144,6 +153,28 @@ func ensureSecureBootstrapAdmin() error { "`hy2xs-orchestrator repair --allow-partial-state`.", ) } + + // Контракт проверяется ЗДЕСЬ, а не только при хешировании. + // + // Отказ bcrypt на непригодном пароле выглядел бы как «bcrypt: password + // length exceeds 72 bytes» в journal — сообщение, по которому оператор не + // поймёт ни что чинить, ни где. Причина же чинится одной строкой в + // hy2xs.env, и назвать её обязан тот, кто знает про этот файл. + if !credential.IsValidAdminPassword(adminPassword) { + return fmt.Errorf( + "HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+ + "от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+ + "Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+ + "Учётная запись администратора с таким значением создана НЕ будет: "+ + "установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+ + "Исправьте значение в /etc/hy2xs/hy2xs.env и запустите\n"+ + "`hy2xs-orchestrator repair --allow-partial-state`.", + credential.AdminPasswordMinLength, + credential.AdminPasswordMaxLength, + credential.AdminPasswordMaxBytes, + ) + } + forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1) status := int64(1) tokenVersion := int64(1) @@ -709,6 +740,7 @@ func tableExists(tableName string) bool { } return count > 0 } + // ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ // придумывает его сам. // diff --git a/apps/frontend/src/constants/credentials.ts b/apps/frontend/src/constants/credentials.ts index 335fb87..28e4e8b 100644 --- a/apps/frontend/src/constants/credentials.ts +++ b/apps/frontend/src/constants/credentials.ts @@ -15,7 +15,7 @@ * принял бы, — панель была строже сервера там, где она не имеет на это права. * * Правило теперь одно на обе формы, и оно сверяется с Go-контрактом - * (apps/service/admin_credentials.go) тестом tools/test/frontend-contract.test.ts. + * (apps/credential/admin.go) тестом tools/test/frontend-contract.test.ts. */ export const ADMIN_USERNAME_MIN_LENGTH = 6; @@ -41,9 +41,98 @@ export const ADMIN_USERNAME_PATTERN = new RegExp( export const ADMIN_USERNAME_CHARSET = "a-z A-Z 0-9 !@#$%^&*()_+,-./:;<="; /** - * Границы пароля. Набора символов у пароля НЕТ: сервер его не ограничивает ни - * при установке, ни при смене, и панель не имеет права отвергать значение, - * которое сервер принял бы. + * Границы пароля в СИМВОЛАХ Unicode. Набора символов у пароля НЕТ: сервер его + * не ограничивает ни при установке, ни при смене, и панель не имеет права + * отвергать значение, которое сервер принял бы. */ export const ADMIN_PASSWORD_MIN_LENGTH = 6; export const ADMIN_PASSWORD_MAX_LENGTH = 64; + +/** + * Предел bcrypt — в БАЙТАХ, и он существует одновременно с границей в символах. + * + * `golang.org/x/crypto/bcrypt` отвечает `ErrPasswordTooLong` на пароль длиннее + * 72 байт. Через границу в символах это не выражается: у 64 символов длина от + * 64 до 256 байт в зависимости от алфавита. + */ +export const ADMIN_PASSWORD_MAX_BYTES = 72; + +/** + * Единственное правило пароля на стороне панели. + * + * Почему не встроенные `min`/`max` Element Plus. Правила формы Element Plus + * делегирует библиотеке `async-validator`, а она сравнивает `min`/`max` строки + * с `String.prototype.length`, то есть с числом единиц UTF-16. Go и оркестратор + * считают code points. Расхождение не теоретическое: + * + * "😀😀😀" -> Go: 3 руны -> сервер отказывает (минимум 6) + * JS: value.length = 6 -> форма считает минимум достигнутым + * + * то есть панель отправляла бы заведомо отвергаемый пароль и не могла бы + * объяснить отказ. Обратная сторона того же — пароль из 40 эмодзи: 40 code + * points укладываются в границу символов и не укладываются в 72 байта. + * + * Поэтому правило одно и считает ровно то же, что сервер: code points через + * итератор строки (он идёт по code points, а не по единицам UTF-16) и байты + * через `TextEncoder`, который кодирует именно в UTF-8. + * + * Управляющие символы (C0 и DEL) — единственное ограничение НАБОРА в + * контракте, и оно принадлежит не форме, а транспорту: первый пароль + * администратора уезжает в EnvironmentFile systemd, где у перевода строки нет + * представления, переживающего запись и чтение. Проверяются они перебором кодов, + * а не классом регекспа: литеральные управляющие символы в исходнике невидимы, + * и такое правило нельзя проверить глазами при ревью. + */ +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) { + return false; + } + characters += 1; + } + + if ( + characters < ADMIN_PASSWORD_MIN_LENGTH || + characters > ADMIN_PASSWORD_MAX_LENGTH + ) { + return false; + } + + return new TextEncoder().encode(value).length <= ADMIN_PASSWORD_MAX_BYTES; +} + +/** + * Правило пароля в том виде, в каком его принимает `el-form`. + * + * Собрано здесь, а не в каждой форме, по той же причине, по которой здесь живёт + * сам контракт: у формы входа и формы смены пароля правило обязано быть одним и + * тем же объектом кода, а не двумя совпадающими записями. Пока они были + * двумя, они разошлись — 32 против 64 — и панель заперла оператора снаружи + * после штатной смены пароля. + * + * Пустое значение пропускается: о нём отчитывается правило `required`, и без + * этого оператор, отправивший пустую форму, читал бы про границы длины вместо + * «поле обязательно». Сервер ведёт себя ровно так же — DTO приводит пустой + * пароль к «не задано» до проверки правил. + * + * `message` передаётся аргументом: фраза принадлежит словарю локализации, а + * `t()` доступен только внутри компонента. + */ +export function adminPasswordFormRule(message: string) { + return { + validator: ( + _rule: unknown, + value: string, + callback: (error?: Error) => void + ) => { + if ((value ?? "") === "" || isValidAdminPassword(value)) { + callback(); + return; + } + callback(new Error(message)); + }, + trigger: ["change", "blur"] as string[], + }; +} diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 75a2671..4bea818 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -26,7 +26,8 @@ export default { // смены пароля. См. комментарий в ru.ts. credentials: { usernameFormat: "Username: {min} to {max} characters from {charset}", - passwordLength: "Password: {min} to {max} characters", + passwordFormat: + "Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters", }, dashboard: { stale: "Dashboard data is stale. Retrying automatically...", @@ -166,6 +167,8 @@ export default { peer_name: "“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed", credential_format: "“{field}”: {min} to {max} characters from {charset}", + admin_password_format: + "“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters", rule_violated: "“{field}”: value is not acceptable", validation_failed: "Validation failed", body_invalid: diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index b86691e..66349b4 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -31,7 +31,12 @@ export default { // приходит из hy2xs.env, и посмотреть его в панели негде. credentials: { usernameFormat: "Логин: от {min} до {max} символов из набора {charset}", - passwordLength: "Пароль: от {min} до {max} символов", + // Фраза называет ОБЕ границы, потому что их две и они в разных единицах. + // Пароль из 40 эмодзи укладывается в 64 символа и не укладывается в 72 + // байта; сообщение «не длиннее {max} символов» отправило бы оператора + // сокращать пароль, отвергнутый не за это. + passwordFormat: + "Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", }, dashboard: { stale: @@ -183,6 +188,8 @@ export default { // словами отказ по ДЛИНЕ: правило одно, и оно проверяет и то, и другое. credential_format: "«{field}»: от {min} до {max} символов из набора {charset}", + admin_password_format: + "«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", rule_violated: "«{field}»: значение не подходит", validation_failed: "Проверка данных не пройдена", body_invalid: "Запрос не разобран: проверьте формат и типы полей", diff --git a/apps/frontend/src/utils/api-error.ts b/apps/frontend/src/utils/api-error.ts index 0b39d4d..5f5797f 100644 --- a/apps/frontend/src/utils/api-error.ts +++ b/apps/frontend/src/utils/api-error.ts @@ -44,6 +44,11 @@ export const ERR_CODE = { ruleViolated: "rule_violated", peerName: "peer_name", credentialFormat: "credential_format", + // Правило пароля администратора целиком: длина в символах, длина в байтах + // (предел bcrypt) и запрет управляющих символов. Отдельный код нужен потому, + // что min_length/max_length не умеют назвать отказ по БАЙТАМ, а он бывает у + // пароля, укладывающегося в границу символов. + adminPasswordFormat: "admin_password_format", peerNameTaken: "peer_name_taken", peerNameReserved: "peer_name_reserved", peerBootstrapLocked: "peer_bootstrap_identity_locked", diff --git a/apps/frontend/src/views/admin/change-password/index.vue b/apps/frontend/src/views/admin/change-password/index.vue index 7cdfa9a..ffcd39e 100644 --- a/apps/frontend/src/views/admin/change-password/index.vue +++ b/apps/frontend/src/views/admin/change-password/index.vue @@ -34,8 +34,10 @@ import { useRoute, useRouter } from "vue-router"; import { adminChangePasswordApi } from "@/api/admin"; import { useAdminStore } from "@/store/modules/admin"; import { + ADMIN_PASSWORD_MAX_BYTES, ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MIN_LENGTH, + adminPasswordFormRule, } from "@/constants/credentials"; const { t } = useI18n(); @@ -60,18 +62,22 @@ const form = reactive({ // Границы совпадают с формой входа не случайно: пока они расходились, длинный // пароль, назначенный здесь, невозможно было ввести там. // +// Правило — собственное, а не встроенные `min`/`max` Element Plus: те +// делегируются `async-validator`, который считает длину строки в единицах +// UTF-16, тогда как сервер считает code points и вдобавок ограничивает длину в +// БАЙТАХ пределом bcrypt. Пароль из 64 кириллических букв проходил здесь и +// возвращался с сервера системной ошибкой хеширования. +// // Комментарий записан строчными `//`, а не блоком: скан релизных гейтов // отбрасывает только их, и объяснение, называющее убранную конструкцию по // имени, иначе роняет проверку «этой конструкции здесь больше нет». -const passwordRule = { - min: ADMIN_PASSWORD_MIN_LENGTH, - max: ADMIN_PASSWORD_MAX_LENGTH, - message: t("credentials.passwordLength", { +const passwordRule = adminPasswordFormRule( + t("credentials.passwordFormat", { min: ADMIN_PASSWORD_MIN_LENGTH, max: ADMIN_PASSWORD_MAX_LENGTH, - }), - trigger: ["change", "blur"] as string[], -}; + maxBytes: ADMIN_PASSWORD_MAX_BYTES, + }) +); const rules: FormRules = { oldPassword: [ diff --git a/apps/frontend/src/views/login/index.vue b/apps/frontend/src/views/login/index.vue index 1d02f95..6b951e3 100644 --- a/apps/frontend/src/views/login/index.vue +++ b/apps/frontend/src/views/login/index.vue @@ -84,12 +84,14 @@ import { useAdminStore } from "@/store/modules/admin"; import { LocationQuery, LocationQueryValue, useRoute } from "vue-router"; import { AdminLoginDto } from "@/api/admin/types"; import { + ADMIN_PASSWORD_MAX_BYTES, ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MIN_LENGTH, ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_PATTERN, + adminPasswordFormRule, } from "@/constants/credentials"; const adminStore = useAdminStore(); @@ -129,6 +131,13 @@ const loginForm = ref({ * символов, из-за чего форма входа отказывалась отправлять пароль, который * сервер принимает: набор пароля сервер не ограничивает нигде. Проверка, * которая умеет только запереть оператора и ничего не защищает, — не проверка. + * + * Правило пароля — собственное, а не встроенные `min`/`max` Element Plus. + * Element Plus делегирует правила формы библиотеке `async-validator`, а она + * сравнивает `min`/`max` строки с `String.prototype.length`, то есть считает + * единицы UTF-16, тогда как сервер считает code points. Пароль из трёх эмодзи + * имел `value.length === 6` и проходил минимум формы, а сервер видел три руны и + * отказывал. См. isValidAdminPassword. */ const loginRules = { username: [ @@ -153,15 +162,13 @@ const loginRules = { message: t("common.required"), trigger: ["change", "blur"], }, - { - min: ADMIN_PASSWORD_MIN_LENGTH, - max: ADMIN_PASSWORD_MAX_LENGTH, - message: t("credentials.passwordLength", { + adminPasswordFormRule( + t("credentials.passwordFormat", { min: ADMIN_PASSWORD_MIN_LENGTH, max: ADMIN_PASSWORD_MAX_LENGTH, - }), - trigger: ["change", "blur"], - }, + maxBytes: ADMIN_PASSWORD_MAX_BYTES, + }) + ), ], }; diff --git a/apps/model/constant/error.go b/apps/model/constant/error.go index f2eefd1..5ca08d8 100644 --- a/apps/model/constant/error.go +++ b/apps/model/constant/error.go @@ -43,15 +43,24 @@ const ( // валидатора у них один. Оператору это разные фразы: «не меньше 1 // устройства» и «не короче 6 символов», — и панель обязана уметь их // различить, не заводя у себя таблицу «какое поле какого рода». - ErrCodeMin string = "min" - ErrCodeMax string = "max" - ErrCodeMinLength string = "min_length" - ErrCodeMaxLength string = "max_length" - ErrCodeLen string = "len" + ErrCodeMin string = "min" + ErrCodeMax string = "max" + ErrCodeMinLength string = "min_length" + ErrCodeMaxLength string = "max_length" + ErrCodeLen string = "len" ErrCodeOneOf string = "oneof" ErrCodeGreaterThan string = "gt" ErrCodePeerName string = "peer_name" ErrCodeCredentialStr string = "credential_format" + // ErrCodeAdminPassword — пароль администратора не удовлетворяет контракту. + // + // Отдельный код, а не пара min_length/max_length, потому что правило одно и + // проверяет три вещи сразу: длину в символах, длину в байтах (предел + // bcrypt) и отсутствие управляющих символов. Пока границы стояли двумя + // тегами, отказ по байтам было нечем назвать — его просто не существовало, + // и пароль из 64 кириллических букв доходил до хеширования, где становился + // системной ошибкой. + ErrCodeAdminPassword string = "admin_password_format" ErrCodeRuleUnknown string = "rule_violated" // Доменные коды: правило соблюдено, но операция всё равно невозможна. diff --git a/apps/model/dto/admin_user.go b/apps/model/dto/admin_user.go index 5731568..991379c 100644 --- a/apps/model/dto/admin_user.go +++ b/apps/model/dto/admin_user.go @@ -1,17 +1,37 @@ package dto -// Границы пароля здесь и в LoginDto обязаны совпадать. +// Правило пароля здесь и в LoginDto обязано быть ОДНИМ И ТЕМ ЖЕ. // -// Пока они расходились, панель запирала оператора снаружи после операции, +// Пока границы расходились, панель запирала оператора снаружи после операции, // которую сама же и предлагала: форма смены пароля принимала 64 символа, а // форма входа — 32, и назначенный длинный пароль становился паролем, с которым -// невозможно войти. Числа в тегах сверяются с service.AdminPassword*Length в -// TestAdminPasswordBoundsAreOneContract — в теге структуры константу не -// подставить. +// невозможно войти. +// +// Числа из тегов ушли внутрь правила `adminPassword`. Записанные тегом +// `min=6,max=64` они, кроме дублирования, были ещё и неполны: тег считает +// символы Unicode, а bcrypt — байты, поэтому новый пароль из 64 кириллических +// букв проходил проверку формы, доходил до util.HashPassword и возвращался +// оператору системной ошибкой. Отказ по байтам тегом валидатора выразить +// нечем — правила длины в байтах у него нет. // // Набора символов здесь нет и не было: пароль принадлежит оператору целиком. type AdminChangePasswordDto struct { - OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"` - NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"` + OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,adminPassword"` + NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,adminPassword"` +} + +// Normalize: пароль НЕ триммится ни в одном из полей. +// +// Пустое поле обязано выглядеть незаполненным и получить отказ `required`: у +// поля-указателя `required` считает значение заданным по самому факту +// ненулевого указателя, поэтому `{"oldPassword":""}` иначе получило бы отказ по +// длине вместо «поле обязательно». +// +// Пробел по краям при этом остаётся частью значения. Форма входа ведёт себя так +// же (LoginDto.Normalize), и иначе быть не может: пароль, назначенный здесь, +// вводится там. +func (d *AdminChangePasswordDto) Normalize() { + emptyToNil(&d.OldPassword) + emptyToNil(&d.NewPassword) } diff --git a/apps/model/dto/auth.go b/apps/model/dto/auth.go index c9d87c8..f823489 100644 --- a/apps/model/dto/auth.go +++ b/apps/model/dto/auth.go @@ -18,17 +18,21 @@ package dto // и отвергалось другим, а оператор видел «invalid». Здесь та же конструкция // была бы той же ошибкой. // -// У пароля набора символов НЕТ, и это осознанно: сервер нигде его не +// У пароля границы длины живут внутри правила `adminPassword` по той же +// причине. Здесь стояло `min=6,max=64`, и эта запись была не только вторым +// правилом на одном поле, но и НЕПОЛНОЙ: `max` считает символы Unicode, а +// bcrypt — байты, поэтому 64 кириллические буквы (128 байт) проходили проверку +// и падали в хешировании. Границу в байтах тегом валидатора не выразить. +// +// Набора символов у пароля НЕТ, и это осознанно: сервер нигде его не // ограничивает — ни при установке, ни при смене пароля. Ограничение на входе // не защищало бы ничего (значение всё равно сравнивается с хешем) и умело -// только запирать оператора с паролем, который сервер принял бы. -// -// Числа в тегах обязаны совпадать с service.AdminPassword*Length; за этим -// следит TestLoginPasswordBoundsMatchContract — в теге структуры константу не -// подставить. +// только запирать оператора с паролем, который сервер принял бы. Единственное +// исключение — управляющие символы, и оно принадлежит контракту, а не форме: +// см. credential.IsValidAdminPassword. type LoginDto struct { Username *string `json:"username" form:"username" validate:"required,credentialStr"` - Pass *string `json:"pass" form:"pass" validate:"required,min=6,max=64"` + Pass *string `json:"pass" form:"pass" validate:"required,adminPassword"` } // Normalize: незаполненное поле обязано выглядеть незаполненным. diff --git a/apps/model/dto/normalize_test.go b/apps/model/dto/normalize_test.go index 693f4d4..5a350ee 100644 --- a/apps/model/dto/normalize_test.go +++ b/apps/model/dto/normalize_test.go @@ -119,4 +119,56 @@ func TestNormalizableIsImplemented(t *testing.T) { var _ Normalizable = (*PeerPageDto)(nil) var _ Normalizable = (*BaseDto)(nil) var _ Normalizable = (*LogDto)(nil) + var _ Normalizable = (*LoginDto)(nil) + var _ Normalizable = (*AdminChangePasswordDto)(nil) +} + +// Пароль НЕ триммится ни на одной из двух форм. +// +// Набор его символов не ограничен, пробел по краям — часть значения, и +// молчаливое его удаление означало бы, что на сервер уезжает не то, что ввёл +// оператор. Логин при этом триммится: скопированное из bootstrap-admin.secret +// значение регулярно приезжает с пробелом или переводом строки на конце. +func TestPasswordIsNeverTrimmedByNormalization(t *testing.T) { + login := LoginDto{Username: strPtr(" hy2xsadmin\n"), Pass: strPtr(" пароль ")} + login.Normalize() + + if login.Username == nil || *login.Username != "hy2xsadmin" { + t.Fatalf("логин не приведён к каноничному виду: %v", login.Username) + } + if login.Pass == nil || *login.Pass != " пароль " { + t.Fatalf("пароль изменён нормализацией: %q", *login.Pass) + } + + change := AdminChangePasswordDto{ + OldPassword: strPtr("abcde "), + NewPassword: strPtr(" abcdef"), + } + change.Normalize() + + if change.OldPassword == nil || *change.OldPassword != "abcde " { + t.Fatalf("старый пароль изменён нормализацией: %v", change.OldPassword) + } + if change.NewPassword == nil || *change.NewPassword != " abcdef" { + t.Fatalf("новый пароль изменён нормализацией: %v", change.NewPassword) + } +} + +// Пустой пароль обязан выглядеть НЕЗАДАННЫМ. +// +// У поля-указателя `required` считает значение заданным по самому факту +// ненулевого указателя, поэтому без этого шага оператор, отправивший пустую +// форму, получал отказ по длине вместо «поле обязательно». +func TestEmptyPasswordBecomesAbsent(t *testing.T) { + login := LoginDto{Username: strPtr("hy2xsadmin"), Pass: strPtr("")} + login.Normalize() + if login.Pass != nil { + t.Fatalf("пустой пароль остался заданным: %q", *login.Pass) + } + + change := AdminChangePasswordDto{OldPassword: strPtr(""), NewPassword: strPtr("")} + change.Normalize() + if change.OldPassword != nil || change.NewPassword != nil { + t.Fatal("пустые пароли формы смены остались заданными") + } } diff --git a/apps/service/admin_credentials.go b/apps/service/admin_credentials.go deleted file mode 100644 index a8bc014..0000000 --- a/apps/service/admin_credentials.go +++ /dev/null @@ -1,90 +0,0 @@ -package service - -import ( - "regexp" - "strconv" - "strings" - "unicode/utf8" -) - -// Контракт учётных данных администратора — ОДИН на весь продукт. -// -// Зачем он вынесен сюда. Про логин и пароль администратора одновременно знали -// четыре места: регексп в слое контроллеров, границы длины в тегах LoginDto, -// правило формы входа в панели и правило формы смены пароля в ней же. Ни одно -// из них не ссылалось на остальные, и все четыре успели разойтись: -// -// - тег `validateStr` на Username пережил переименование правила и остался -// ссылкой на несуществующую функцию — валидатор паниковал на КАЖДОМ входе, -// а gin.Recovery превращал панику в HTTP 500; -// - форма входа ограничивала пароль 32 символами, форма смены пароля — 64, -// сервер — тоже 64. То есть пароль, назначенный штатной формой смены, -// форма входа отказывалась отправлять: панель запирала оператора снаружи -// после операции, которую сама же и предложила; -// - оркестратор не проверял HY2XS_ADMIN_USER вовсе и по умолчанию писал в -// hy2xs.env `admin` — пять символов при минимуме в шесть. Установка -// завершалась успешно, а войти в панель было нельзя. -// -// Общее у всех трёх — контракт существовал только как совпадение литералов, -// которое ничто не проверяло. Теперь он объявлен здесь, слой контроллеров зовёт -// IsValidAdminUsername, а расхождение с панелью и оркестратором ловится -// тестами, читающими эти же константы. -const ( - AdminUsernameMinLength = 6 - AdminUsernameMaxLength = 32 - - // Границы пароля. Верхняя выбрана не круглым числом: bcrypt читает первые - // 72 БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо - // ниже — иначе два разных пароля, совпадающие в начале, окажутся одним и - // тем же паролем. - AdminPasswordMinLength = 6 - AdminPasswordMaxLength = 64 -) - -// adminUsernameCharacterClass — набор символов логина в записи регекспа. -// -// Класс записан ЯВНО и повторяет прежнее ФАКТИЧЕСКОЕ множество, включая -// последствия неэкранированного дефиса в исходной записи `_+-=`: там `+-=` -// образовывал ДИАПАЗОН и впускал `, - . / 0-9 : ; < =`. Сужать набор здесь -// нельзя в одиночку — имя администратора приходит из HY2XS_ADMIN_USER, и -// установка с логином вроде `admin.ops` перестала бы пускать оператора в -// панель. Набор и проверка на стороне оркестратора меняются только вместе. -const adminUsernameCharacterClass = `a-zA-Z0-9!@#$%^&*()_+,\-./:;<=` - -// AdminUsernameCharset — тот же набор в том виде, в каком его показывают -// оператору. Объявлен рядом с регекспом, чтобы сообщение об отказе не заводило -// собственную копию того же множества. -const AdminUsernameCharset = `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` - -// adminUsernamePattern собирается из констант, а не пишется литералом: границы -// длины обязаны быть одними и теми же для правила и для сообщения о нём. -var adminUsernamePattern = regexp.MustCompile( - "^[" + adminUsernameCharacterClass + "]{" + - strconv.Itoa(AdminUsernameMinLength) + "," + - strconv.Itoa(AdminUsernameMaxLength) + "}$", -) - -// IsValidAdminUsername сообщает, пригодно ли имя администратора. -// -// Пробелы по краям снимаются здесь повторно: через форму входа они уже сняты -// нормализацией DTO, но эту же функцию зовёт проверка HY2XS_ADMIN_USER, куда -// значение приходит из файла окружения, а не из DTO. -func IsValidAdminUsername(username string) bool { - return adminUsernamePattern.MatchString(strings.TrimSpace(username)) -} - -// IsValidAdminPassword проверяет ТОЛЬКО границы длины. -// -// Набор символов пароля не ограничен, и это осознанно. Пароль назначает -// оператор — установкой через HY2XS_ADMIN_INITIAL_PASSWORD или формой смены, — -// и сервер нигде его набор не проверяет. Ограничение на форме входа не -// защищает ничего: значение всё равно сравнивается с хешем, — зато отвергает -// пароль, который сервер принял бы, то есть умеет только запереть оператора. -// -// Длина считается в РУНАХ: ровно так её считает go-playground/validator в -// правилах `min`/`max` на строке, и расхождение означало бы, что пароль с -// кириллицей проходит одну проверку и отказывается на другой. -func IsValidAdminPassword(password string) bool { - length := utf8.RuneCountInString(password) - return length >= AdminPasswordMinLength && length <= AdminPasswordMaxLength -} diff --git a/apps/util/encrypt.go b/apps/util/encrypt.go index bf5482c..ff92260 100644 --- a/apps/util/encrypt.go +++ b/apps/util/encrypt.go @@ -13,6 +13,7 @@ import ( "strings" "golang.org/x/crypto/bcrypt" + "hy2xs-admin/credential" ) // Функции SHA224String здесь больше нет, как и ветки сравнения паролей по ней. @@ -32,9 +33,37 @@ import ( // осталась запасным путём проверки пароля со слабым алгоритмом — в обработчике // логина, доступном без аутентификации. +// HashPassword проверяет пароль ОБЩИМ контрактом и хеширует его. +// +// Здесь стояла собственная проверка: +// +// if len(strings.TrimSpace(password)) < 6 { … } +// +// и она была третьей копией правила о пароле — вдобавок расходящейся с двумя +// остальными сразу по двум признакам. +// +// Первое: `TrimSpace`. Контракт продукта прямо объявляет пробел по краям частью +// пароля и не снимает его нигде — ни в нормализации DTO, ни при чтении +// bootstrap-окружения. Пароль `"abcde "` считался корректным формой входа, +// формой смены и оркестратором, а здесь превращался в `"abcde"` и получал +// отказ. То есть значение, принятое всеми дверями продукта, невозможно было +// захешировать. +// +// Второе: у проверки не было верхней границы вовсе, поэтому предел bcrypt в 72 +// БАЙТА всплывал ошибкой хеширования уже после того, как все проверки пройдены. +// +// Теперь правило одно на весь продукт. Возможность позвать его отсюда — прямая +// причина, по которой контракт живёт в пакете `credential`, а не в `service`: +// `service` импортирует `util`, и обратный импорт был бы циклическим. func HashPassword(password string) (string, error) { - if len(strings.TrimSpace(password)) < 6 { - return "", errors.New("password too short") + if !credential.IsValidAdminPassword(password) { + return "", fmt.Errorf( + "пароль администратора не удовлетворяет контракту: от %d до %d символов, "+ + "не более %d байт в UTF-8, без управляющих символов", + credential.AdminPasswordMinLength, + credential.AdminPasswordMaxLength, + credential.AdminPasswordMaxBytes, + ) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { diff --git a/apps/util/encrypt_test.go b/apps/util/encrypt_test.go index afecd29..408a418 100644 --- a/apps/util/encrypt_test.go +++ b/apps/util/encrypt_test.go @@ -5,6 +5,8 @@ import ( "encoding/hex" "strings" "testing" + + "hy2xs-admin/credential" ) // Здесь был TestSHA224String, который печатал хеш в stdout и ничего не @@ -41,12 +43,81 @@ func TestHashPasswordIsSalted(t *testing.T) { } } -func TestHashPasswordRejectsShortPassword(t *testing.T) { - if _, err := HashPassword("12345"); err == nil { - t.Fatal("слишком короткий пароль принят") +// HashPassword проверяет ОБЩИЙ контракт, а не собственную копию правила. +// +// Здесь стояло `len(strings.TrimSpace(password)) < 6`, и это была третья копия +// правила о пароле, расходящаяся с двумя остальными. Тест закреплял её обе +// ошибки: он требовал ОТКАЗА на шести пробелах — то есть на значении, которое +// контракт продукта принимает, — и не проверял верхнюю границу вовсе. +// +// Шесть пробелов принимаются намеренно. Пробел объявлен частью пароля, набор +// символов не ограничен, и «слабый пароль» здесь не тема: значение назначает +// оператор, а хеширование не является местом, где оценивают стойкость. +func TestHashPasswordFollowsTheCredentialContract(t *testing.T) { + cases := []struct { + name string + password string + }{ + {"ровно минимум", strings.Repeat("a", credential.AdminPasswordMinLength)}, + {"на символ короче минимума", strings.Repeat("a", credential.AdminPasswordMinLength-1)}, + {"шесть пробелов", strings.Repeat(" ", credential.AdminPasswordMinLength)}, + {"краевой пробел", "abcde "}, + {"ровно максимум в ASCII", strings.Repeat("a", credential.AdminPasswordMaxLength)}, + {"на символ длиннее максимума", strings.Repeat("a", credential.AdminPasswordMaxLength+1)}, + {"ровно предел bcrypt", strings.Repeat("я", 36)}, + {"на букву длиннее предела bcrypt", strings.Repeat("я", 37)}, + {"эмодзи ровно в предел", strings.Repeat("😀", 18)}, + {"эмодзи за пределом", strings.Repeat("😀", 19)}, + {"64 кириллические буквы", strings.Repeat("я", credential.AdminPasswordMaxLength)}, + {"перевод строки", "abcde\n"}, + {"пусто", ""}, } - if _, err := HashPassword(" "); err == nil { - t.Fatal("пароль из пробелов принят") + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := HashPassword(testCase.password) + accepted := err == nil + expected := credential.IsValidAdminPassword(testCase.password) + if accepted != expected { + t.Fatalf( + "HashPassword принял=%v, контракт=%v (%d символов, %d байт): %v", + accepted, expected, + len([]rune(testCase.password)), len(testCase.password), err, + ) + } + }) + } +} + +// Контракт обязан быть НЕ ШИРЕ того, что умеет bcrypt. +// +// Это и было исходным дефектом: контракт принимал 64 символа любого алфавита, а +// bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине +// больше 72 БАЙТ. Проверяются граничные значения, на которых расхождение +// проявляется: всё, что контракт принял, обязано хешироваться. +func TestEveryPasswordAcceptedByContractCanBeHashed(t *testing.T) { + candidates := []string{ + strings.Repeat("a", credential.AdminPasswordMinLength), + strings.Repeat("a", credential.AdminPasswordMaxLength), + strings.Repeat("я", 36), + strings.Repeat("😀", 18), + strings.Repeat(" ", credential.AdminPasswordMinLength), + "пароль-администратора", + `p@$$w0rd"\'#;`, + "abcde ", + } + + for _, password := range candidates { + if !credential.IsValidAdminPassword(password) { + t.Fatalf("подготовка: контракт отверг значение из %d байт", len(password)) + } + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("контракт принял пароль из %d байт, а хеширование отказало: %v", len(password), err) + } + if !VerifyPassword(password, hash) { + t.Fatalf("пароль из %d байт не проходит проверку собственным хешем", len(password)) + } } } diff --git a/docs/admin/04-admin-panel.md b/docs/admin/04-admin-panel.md index 8735575..f5d087c 100644 --- a/docs/admin/04-admin-panel.md +++ b/docs/admin/04-admin-panel.md @@ -1117,6 +1117,18 @@ B входит со своим устаревшим 0 -> lastOnline снова 0 Если переменной нет, а создавать учётную запись нужно, админка **отказывает в старте** с сообщением, называющим причину и способ починки. +То же и при значении вне контракта пароля: `HY2XS_ADMIN_INITIAL_PASSWORD` +проверяется против того же правила, что и форма входа +(`apps/credential/admin.go`), и непригодное значение роняет старт с внятным +текстом, а не доходит до `bcrypt.GenerateFromPassword`, чтобы вернуться оттуда +строкой `password length exceeds 72 bytes`. Учётная запись при этом не +создаётся: установка иначе завершилась бы успешно, а войти было бы нельзя. + +Пароль читается **как есть**: пробелы по краям объявлены его частью и не +снимаются ни здесь, ни при хешировании, ни на форме входа. Раньше bootstrap +делал `strings.TrimSpace`, и учётная запись создавалась не с тем паролем, +который оператор записал в `hy2xs.env`. + Раньше она в этом случае придумывала пароль сама и печатала его двумя `logrus.Warnf` — открытым текстом в `/var/log/hy2xs/hy2xs-admin.log`, то есть в файл, который отдаётся кнопкой выгрузки и попадает в diagnostics-бандл. Помимо diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index f81a835..307b76e 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -270,24 +270,33 @@ control plane показывал всех пиров офлайн. Теперь ### Где живёт контракт Требования к логину и паролю администратора объявлены **один раз**, в -`apps/service/admin_credentials.go`: +`apps/credential/admin.go`: | Что | Значение | Владелец | | --- | --- | --- | | Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` | | Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` | -| Длина пароля | 6-64 символа | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | -| Набор символов пароля | не ограничен | — | +| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | +| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` | +| Набор символов пароля | не ограничен, кроме управляющих | — | +| Пробелы по краям пароля | часть значения, не снимаются | — | -Остальные три стороны продукта только повторяют этот контракт, и каждая копия +Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не +вкусовщина. Его зовут `util.HashPassword` и слой данных при создании первой +учётной записи, а `service` импортирует `util` — обратный импорт был бы +циклическим. Пока контракт лежал в `service`, `HashPassword` завёл собственную +проверку `len(strings.TrimSpace(password)) < 6`, и она разошлась с остальным +продуктом. + +Остальные стороны продукта только повторяют этот контракт, и каждая копия сверяется с оригиналом тестом, читающим Go-исходник: * панель — `apps/frontend/src/constants/credentials.ts` (`tools/test/frontend-contract.test.ts`); * оркестратор — `orchestrator/src/config/profile.ts` (`orchestrator/test/admin-credentials.test.ts`); -* правило валидатора — `credentialStr` в `apps/controller/validator.go`, длина - живёт ВНУТРИ него. +* правила валидатора — `credentialStr` и `adminPassword` в + `apps/controller/validator.go`, длина живёт ВНУТРИ них. ### Почему у пароля нет набора символов @@ -296,12 +305,64 @@ control plane показывал всех пиров офлайн. Теперь bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только отвергнуть пароль, который сервер принял бы. -Верхняя граница в 64 символа выбрана не круглым числом: bcrypt читает первые 72 -БАЙТА и молча отбрасывает остаток, поэтому предел обязан быть заведомо ниже. +Единственное исключение — **управляющие символы** (C0 и DEL). Они запрещены не +формой, а транспортом: первый пароль администратора уезжает в +`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и у +перевода строки там нет представления, переживающего запись и чтение. Такой +пароль всё равно невозможно ввести в однострочное поле формы входа, то есть он +умеет ровно одно — запереть оператора снаружи. -Длина считается в **символах**, а не в байтах: `go-playground/validator` считает -`min`/`max` на строке в рунах, и проверка по байтам отвергла бы пароль из 32 -кириллических букв, который сервер принимает. +### Почему границ у пароля две + +Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя. + +Граница в символах — та, которую видит оператор. Она считается в code points, а +не в байтах и не в единицах UTF-16: `go-playground/validator` считает `min`/`max` +на строке через `utf8.RuneCountInString`, и «пароль из 64 символов» обязано +означать одно и то же для латиницы и для кириллицы. + +Граница в байтах — та, которую ставит bcrypt. `golang.org/x/crypto/bcrypt` +отвечает `ErrPasswordTooLong` на пароль длиннее **72 байт** +(`GenerateFromPassword`, `bcrypt.go:96`). У 64 символов длина от 64 до 256 байт: + +```text +64 x "a" = 64 байта -> принимается +36 x "я" = 72 байта -> принимается (граница) +37 x "я" = 74 байта -> отвергается +18 x "😀" = 72 байта -> принимается (граница) +19 x "😀" = 76 байт -> отвергается +64 x "я" = 128 байт -> отвергается +``` + +Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72 +байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt +«молча отбрасывает остаток». Так вела себя редакция пакета до v0.28; +действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв +проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной +ошибкой на штатной смене пароля, а после установки — отсутствием администратора +вовсе. + +### Панель считает длину так же, как сервер + +Встроенных `min`/`max` Element Plus у пароля **нет**. Правила формы Element Plus +делегирует библиотеке `async-validator`, а та сравнивает `min`/`max` строки с +`String.prototype.length`, то есть считает единицы UTF-16: + +```text +"😀😀😀" Go: 3 руны -> сервер отказывает (минимум 6) + JS: length === 6 -> форма считала минимум достигнутым +``` + +Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ. +Поэтому у обеих форм одно общее правило `adminPasswordFormRule`, и оно считает +code points итератором строки, а байты — через `TextEncoder`. + +### Границы обеих форм обязаны совпадать + +Форма входа и форма смены пароля предъявляют к паролю **одно и то же** +требование. Расхождение здесь запирает оператора снаружи после операции, +которую панель ему же и предложила: пароль длиннее предела формы входа +назначается успешно и после этого не вводится. ### Границы обеих форм обязаны совпадать @@ -337,7 +398,7 @@ Element Plus рисует состояние отказа на `el-input__wrappe ### Требование называется, а не нарушается -Фразы `credentials.usernameFormat` и `credentials.passwordLength` перечисляют +Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и посмотреть его в панели больше негде — сообщение «Неверный формат логина» не давало оператору ни одного способа узнать, что от него хотят. @@ -346,3 +407,9 @@ Element Plus рисует состояние отказа на `el-input__wrappe (`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно и проверяет и длину, и набор, поэтому описывать его только через символы — значит описывать отказ по длине неверно. + +У пароля причина отдельная — `admin_password_format` с `params` +(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль +из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение +«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый +не за это. diff --git a/docs/operations/12-operations-and-troubleshooting.md b/docs/operations/12-operations-and-troubleshooting.md index 560804a..8cd83ae 100644 --- a/docs/operations/12-operations-and-troubleshooting.md +++ b/docs/operations/12-operations-and-troubleshooting.md @@ -541,6 +541,56 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret оркестратору, он же приводит `hy2xs.env` и `bootstrap-admin.secret` в согласованное состояние. +### `hy2xs-admin` не стартует: «HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели» + +Значение задано, но панель его не приняла бы на форме входа, поэтому учётная +запись администратора с ним не создаётся: установка иначе завершилась бы +успешно, а войти было бы нельзя. + +Контракт пароля — **две** границы в разных единицах и один запрет: + +| Требование | Кто его ставит | +| --- | --- | +| 6-64 символа Unicode | форма входа и форма смены пароля | +| не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) | +| без управляющих символов | формат `KEY=VALUE`, который читает systemd | + +Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64 +кириллических букв — это 128 байт, и он отвергается, хотя в границу символов +укладывается. Практический предел: 36 кириллических букв или 18 эмодзи. + +Набор символов не ограничен ничем сверх этого, а пробелы по краям являются +частью пароля. Именно поэтому такое значение записывается в `hy2xs.env` в +двойных кавычках: + +```text +HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце " +``` + +Без кавычек пробелы по краям срежет **systemd** — файл объявлен +`EnvironmentFile=` в юните, — и админка получит не то значение, которое вы +записали. + +Починка: исправьте значение в `/etc/hy2xs/hy2xs.env` и выполните +`hy2xs-orchestrator repair --allow-partial-state`. + +### Как посмотреть bootstrap-пароль + +`/etc/hy2xs/bootstrap-admin.secret` — файл того же формата `KEY=VALUE`, и +значения в нём могут быть закавычены. Читать их `cut -d= -f2-` нельзя: кавычки +уедут в пароль. `source` и `eval` тоже не годятся — shell выполнит подстановку +команд внутри двойных кавычек, чего сам systemd не делает. + +```bash +read_bootstrap_field() { + sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \ + | sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g' +} + +read_bootstrap_field ADMIN_USER +read_bootstrap_field ADMIN_INITIAL_PASSWORD +``` + Аналогичное сообщение про `HY2XS_ADMIN_CON_PASS` относится к пиру установщика. Его секрет продублирован в `bootstrap-admin.secret`, откуда его читает проверка machine-auth, поэтому придуманный секрет разошёлся бы с файлом и первая же diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index b7c5357..293a353 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -542,6 +542,50 @@ production-профилем, а не ищет подстроки. Проверя Сообщение об ошибке для `auth.http.url` намеренно не печатает сам токен: текст уходит в логи и в diagnostics-бандл. +## Формат env-файлов: у него два читателя + +`/etc/hy2xs/hy2xs.env` разбирает не только оркестратор. Файл объявлен +`EnvironmentFile=` в юните `hy2xs-admin`, то есть его читает **systemd**, и +формат обязан совпадать у обоих. Пока значения писались интерполяцией +(`` `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` ``), а читались +построчным `split("=")` с `trim()`, форматом это не являлось: совпадение +поведения держалось на том, что в значениях не встречалось ни пробелов по краям, +ни кавычек, ни обратных слешей. Продукт при этом обещает оператору, что набор +символов пароля не ограничен, а краевой пробел — часть значения. + +Запись и разбор живут в `orchestrator/src/lib/envFile.ts` и повторяют конечный +автомат `parse_env_file_internal` из systemd (`src/basic/env-file.c`). +Существенны четыре его свойства: + +1. у **незакавыченного** значения срезаются пробелы в конце, `\` уводит в + escape, а `\<перевод строки>` склеивает строки; +2. в **одинарных** кавычках всё literal до закрывающей кавычки — escape там + нет (отличие от `sh`); +3. в **двойных** кавычках `\` уводит в escape, и обратный слеш снимается только + перед `"`, `\`, `` ` `` и `$` (`SHELL_NEED_ESCAPE`); перед любым другим + символом он СОХРАНЯЕТСЯ; +4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — обычный + символ. + +Из (3) и (4) следует кодирование, которое переживает любое издание systemd: +двойные кавычки и экранирование **только** `\` и `"`. Оба входят в +`SHELL_NEED_ESCAPE` и разворачиваются одинаково в действующей редакции и в тех, +где escape в двойных кавычках снимался безусловно. + +Кавычки ставятся только там, где они нужны: обычные значения (порты, пути, +домены, `50 mbps`, base64url-секреты) остаются побайтово прежними, поэтому +релизные гейты и инструкции оператора вида `grep '^HY2XS_UI_PORT=8080$'` +продолжают работать. Тем же кодировщиком пишется `bootstrap-admin.secret`. + +Одно намеренное расхождение с systemd: строка без `=` — **отказ**, а не +пропуск. systemd такую строку молча отбрасывает; молчаливая потеря строки из +`hy2xs.env` означала бы установку с настройкой, которую оператор задал, а +продукт не увидел. Расхождение в сторону отказа останавливает установку там, где +её можно починить. + +Управляющие символы формат не несёт — перевод строки является границей записи, а +не данными, — поэтому они запрещены и контрактом пароля. + ## Smoke проверяет, что панель ВПУСКАЕТ Открытый порт — это не работающая панель. @@ -557,7 +601,7 @@ production-профилем, а не ищет подстроки. Проверя | Проба | Когда | Что требуется | | --- | --- | --- | -| заведомо неверные учётные данные | всегда | HTTP 200 с конвертом отказа | +| настоящий логин + СЛУЧАЙНЫЙ пароль | всегда | `code: 50000`, причина `invalid_credentials`, `accessToken` отсутствует | | bootstrap-учётные данные из `bootstrap-admin.secret` | только `install` | `code: 20000` и непустой `accessToken` | Детали, которые здесь существенны: @@ -565,6 +609,21 @@ production-профилем, а не ищет подстроки. Проверя - **успех определяется конвертом, а не кодом HTTP.** Админка отвечает `200 OK` и на отказ тоже — причина живёт в поле `code`. Проверка «HTTP 200» приняла бы за успешный вход любой отказ, то есть не проверяла бы ничего; +- **отказ определяется конвертом по той же причине.** Отрицательная проба + сверяла `%{http_code}` с `200` и доказывала ровно одно — что запрос не + закончился пятисоткой. Теперь требуются три признака сразу: код конверта + `50000` (отказ операции, а не успех и не отказ валидации, который означал бы + негодный запрос), доменная причина `invalid_credentials` и ОТСУТСТВИЕ + `accessToken`; +- **пароль отрицательной пробы генерируется**, а не записан литералом. Записанное + в исходнике значение теоретически может оказаться настоящим паролем — и тогда + проверка «неверные данные отвергаются» отчиталась бы об успешном входе. На + `install`, где настоящий пароль известен, дополнительно утверждается, что + проба ему не равна; +- **bootstrap-секрет читается парсером формата**, а не `grep … | cut -d= -f2-`. + Набор символов пароля не ограничен, пробелы по краям являются его частью, и + шелл-конвейер срезал бы их — положительная проба взяла бы не тот пароль и + объявила бы рабочую установку сломанной; - **токен требуется отдельно.** `code: 20000` без `accessToken` означал бы панель, которая пускает и не выдаёт сессию; - **тело собирается `JSON.stringify`**, а не интерполяцией в строку: пароль diff --git a/docs/runtime/09-post-install-env.md b/docs/runtime/09-post-install-env.md index 06bbef4..7cbc255 100644 --- a/docs/runtime/09-post-install-env.md +++ b/docs/runtime/09-post-install-env.md @@ -140,7 +140,7 @@ anycast. Отсутствие A-записи фатально при любом Важно: - `HY2XS_ADMIN_INITIAL_PASSWORD` используется только для первичного bootstrap seed; - `HY2XS_ADMIN_CON_PASS` — отдельная runtime-сущность для Hysteria auth/smoke; -- bootstrap secret хранится в явном формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; +- bootstrap secret хранится в том же формате `KEY=VALUE` (`ADMIN_USER`, `ADMIN_INITIAL_PASSWORD`, `ADMIN_CON_PASS`), права `0600`; значения с пробелами по краям, кавычками или обратными слешами записываются в двойных кавычках — читать файл следует парсером формата, а не `cut -d= -f2-`; - `HY2XS_FORCE_PASSWORD_CHANGE` в production baseline установлен в `false` (forced UX-flow пока не реализован); - после первичного seed перезапуски `hy2xs-admin` не должны переопределять пароль admin и `con_pass`. @@ -148,12 +148,12 @@ anycast. Отсутствие A-записи фатально при любом `HY2XS_ADMIN_USER` и `HY2XS_ADMIN_INITIAL_PASSWORD` — это значения, которые потом принимает **форма входа в панель**. Оркестратор проверяет их против того -же контракта, что и админка (`apps/service/admin_credentials.go`): +же контракта, что и админка (`apps/credential/admin.go`): | Переменная | Требование | Значение по умолчанию | | --- | --- | --- | | `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` | -| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа, набор не ограничен | генерируется | +| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; набор не ограничен, кроме управляющих символов | генерируется | Значение вне контракта **роняет установку** с явным текстом, называющим границы и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой @@ -162,16 +162,58 @@ anycast. Отсутствие A-записи фатально при любом Проверяется и сгенерированный пароль, а не только заданный оператором: генератор — такой же источник значения. +Границ у пароля **две**, и они в разных единицах. Предел в 72 байта ставит +bcrypt: `GenerateFromPassword` отвечает `ErrPasswordTooLong` на пароль длиннее +72 байт, а у 64 символов длина от 64 до 256 байт в зависимости от алфавита. +Пока байтовой границы здесь не было, `HY2XS_ADMIN_INITIAL_PASSWORD` из 64 +кириллических букв (128 байт) проходил установку целиком, а первая учётная +запись администратора не создавалась вовсе — админка падала при старте. Подробно +границы описаны в `docs/admin/15-ui-contracts.md`. + Окружающие пробелы у `HY2XS_ADMIN_USER` снимаются. Иначе они уезжали бы в имя учётной записи в SQLite, и вход отказывал бы «неверным логином или паролем» — отказом, который невозможно связать с причиной. +У `HY2XS_ADMIN_INITIAL_PASSWORD` пробелы по краям, наоборот, **являются частью +пароля** и не снимаются нигде — ни оркестратором, ни админкой, ни хешированием. +Чтобы такое значение пережило запись и чтение, оно записывается **в двойных +кавычках** с экранированием `\` и `"`: + +```text +HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце " +``` + +Кавычки здесь не украшение. Файл читает не только оркестратор, но и systemd — +он объявлен `EnvironmentFile=` в юните `hy2xs-admin`, — а у **незакавыченного** +значения systemd срезает пробелы по краям и трактует `\` как escape. Если вы +правите `hy2xs.env` руками и в пароле есть пробел по краям, кавычка или обратный +слеш, закавычьте значение тем же способом. Обычные значения (порты, пути, +домены) кавычек не требуют и записываются как раньше. + +Управляющие символы (перевод строки, табуляция) в пароле запрещены контрактом: +формат `KEY=VALUE` их не несёт, а ввести такой пароль в форму входа всё равно +нельзя. + Значение по умолчанию совпадает в трёх местах и обязано совпадать: `package/config/hy2xs.env`, `orchestrator/src/config/env.ts` и запасное значение в `apps/dao/sqlite.go`. Раньше оркестратор писал `admin` — пять символов при минимуме панели в шесть, — и установка завершалась `INSTALL EXIT CODE: 0`, оставляя панель, в которую невозможно войти. +### Backlog: секреты в окружении + +Документация systemd отдельно рекомендует **не** передавать секреты через +переменные окружения и предлагает для них `LoadCredential=` / +`LoadCredentialEncrypted=`: окружение процесса видно шире, чем файл с правами +`0600`. + +HY2XS v1 этим не пользуется, и это осознанное решение по срокам, а не +недосмотр: переход затрагивает модель секретов всего продукта (машинный токен +Hysteria, obfs-пароль, `con_pass`), а не только пароль администратора. +Действующая защита — права `0600`, владелец `root:root` и отсутствие доступа у +служебных пользователей (`hy2xs-admin` и `hysteria` файл прочитать не могут, что +проверяет smoke). Пункт остаётся в backlog как отдельная работа. + ### Immutable-bootstrap контракт - `/etc/hy2xs/bootstrap-admin.secret` создаётся оркестратором только при первичной установке. diff --git a/docs/testing/11-2-builder-layer.md b/docs/testing/11-2-builder-layer.md index 5e6d481..59ea52b 100644 --- a/docs/testing/11-2-builder-layer.md +++ b/docs/testing/11-2-builder-layer.md @@ -89,6 +89,32 @@ Отдельно — round-trip `parse(render(config)) == config`. Этот тест ловит класс ошибок «в рендер runtime-конфига попал литерал вместо значения из конфигурации». +### A4a. Формат env-файла совпадает с systemd + +`orchestrator/test/env-file.test.ts`. У `hy2xs.env` два читателя, и один из них +не наш: файл объявлен `EnvironmentFile=` в юните `hy2xs-admin`. Поэтому +проверяется не «работает на наших данных», а совпадение с правилами systemd +(`src/basic/env-file.c`) на тех значениях, где прежний разбор расходился: + +| Вход | Ожидание | +| --- | --- | +| `KEY= value ` | `value` — незакавыченное значение теряет краевые пробелы, как и у systemd | +| `KEY=" value "` | ` value ` — в кавычках сохраняются | +| `KEY="a\"b"` | `a"b` | +| `KEY="a\\b"` | `a\b` | +| `KEY="a\nb"` | `a\nb` — `n` не входит в `SHELL_NEED_ESCAPE`, слеш сохраняется | +| `KEY="$HOME"` | `$HOME` — подстановок в env-файле нет | +| `KEY='a\b'` | `a\b` — в одинарных кавычках escape нет вовсе | +| строка без `=` | отказ (единственное намеренное расхождение: systemd её отбрасывает молча) | +| незакрытая кавычка | отказ | + +И обратимость: любое значение — с краевыми пробелами, кавычками, обратными +слешами, `$`, `` ` ``, `#`, эмодзи — переживает `render -> parse` побайтово, а +обычные значения (`8080`, `/etc/hysteria/server.crt`, `50 mbps`) остаются без +кавычек, чтобы релизные гейты и инструкции оператора продолжали работать. + +Управляющий символ в значении — отказ ЗАПИСИ, а не потеря части секрета. + Рендер конфига (`orchestrator/test/render-config.test.ts`): - Gecko рендерит **только** gecko-подблок; @@ -508,7 +534,66 @@ wildcard-маршрутом фронтенда или дублирующая р - `HashPassword` выдаёт bcrypt и солит: два хеша одного пароля различаются; - вход по несолёному SHA-224 (формат предыдущего поколения) **невозможен**; -- любая не-bcrypt строка в поле хеша отклоняется. +- любая не-bcrypt строка в поле хеша отклоняется; +- `HashPassword` следует ОБЩЕМУ контракту, а не собственной проверке. Раньше + здесь стояло `len(strings.TrimSpace(password)) < 6` — третья копия правила, и + она расходилась с двумя остальными: значение `abcde ` считалось корректным + всеми дверями продукта и не могло быть захешировано, а верхней границы у + копии не было вовсе; +- **всё, что контракт принял, обязано хешироваться.** Проверяются граничные + значения (36 x `я` = 72 байта, 18 x `😀` = 72 байта): именно здесь + расхождение с bcrypt и проявлялось. + +## A9c1. Контракт учётных данных администратора (unit) + +Контракт объявлен один раз в `apps/credential/admin.go` — в leaf-пакете, потому +что его зовут и `util.HashPassword`, и слой данных при создании первой учётной +записи, а `service` импортирует `util`. + +`apps/credential/admin_test.go` — сам контракт: + +- набор символов логина закреплён ФАКТИЧЕСКИМ множеством: сужение уронит тест, а + не вход администратора на живом сервере; +- границы пароля проверяются таблицей, и она включает случаи, где границы в + символах и в байтах расходятся: + + | значение | символов | байт | результат | + | --- | --- | --- | --- | + | 64 x `a` | 64 | 64 | принят | + | 36 x `я` | 36 | 72 | принят (граница bcrypt) | + | 37 x `я` | 37 | 74 | **отвергнут** | + | 18 x `😀` | 18 | 72 | принят (граница bcrypt) | + | 19 x `😀` | 19 | 76 | **отвергнут** | + | 64 x `я` | 64 | 128 | **отвергнут** | + + последняя строка — исходный дефект: прежний тест требовал ПРИЁМА этого + значения, то есть закреплял как ожидаемое ровно то, на чём продукт ломался; +- пробел по краям — часть пароля, шесть пробелов являются корректным паролем; +- управляющие символы (`\n`, `\r`, `\t`, `NUL`, `DEL`) отвергаются. + +`apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги +`credentialStr` и `adminPassword` прогоняются через production-валидатор и +обязаны отвечать так же, как функции контракта, на тех же граничных значениях. + +`apps/controller/auth_test.go`: + +- ни один тег валидации ни в одном DTO не ссылается на незарегистрированное + правило (обход исходников, а не проверка одного экземпляра); +- границы пароля **не стоят рядом** с правилом: тег умеет считать только + символы, а у пароля есть ещё граница в байтах, которую тегом не выразить; +- пароль в 72 байта пускает в панель, а на символ длиннее — получает конверт + валидации с причиной `admin_password_format` на поле `pass`, а не системную + ошибку из bcrypt; +- пароль не триммится: `bootstrap-password ` и `bootstrap-password` — разные + пароли. + +`apps/dao/bootstrap_admin_test.go` — bootstrap-путь на настоящей SQLite: + +- пароль с краевым пробелом создаёт учётную запись С ЭТИМ пробелом, и вход + обрезанным значением невозможен; +- пароль вне контракта роняет старт с текстом, называющим переменную и файл, а + не сообщением bcrypt; +- пароль ровно в 72 байта проходит установку целиком. `apps/util/rand_test.go` — отсутствие modulo bias: на выборке 200 000 символов частоты первых восьми символов алфавита не отличаются от остальных более чем на diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index 0a2e56b..b0b9dad 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -52,9 +52,10 @@ 32. дашборд различает «служба остановлена» и «состояние службы неизвестно»; доступность Traffic Stats API показывается независимо от ответа systemd 33. страница журнала Hysteria показывает разобранные `level`/`time`/`msg` и структурный контекст, а не сырой JSON 34. страница конфигурации показывает фактические значения `/etc/hysteria/config.yaml`, перечисляет секции вне production-профиля и не содержит паролей и токенов -35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные дают HTTP 200 с конвертом отказа, а не 500 -36. пароль предельной длины (64 символа), назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными -37. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки +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` в двойных кавычках, и вход выполняется ровно им, а не обрезанным +38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки ## C0. Панель обязана впускать, а не слушать порт @@ -70,15 +71,39 @@ HTTP 500 на каждый запрос, потому что валидатор Ручной эквивалент: ```bash -# Пароль в переменную, чтобы он не попал ни в историю shell, ни в вывод. -read -r -s BOOTSTRAP_PASS < <(sudo grep '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-) -BOOTSTRAP_USER="$(sudo grep '^ADMIN_USER=' /etc/hy2xs/bootstrap-admin.secret | cut -d= -f2-)" +# Значение читается ПО ФОРМАТУ, а не `cut -d= -f2-`. +# +# Набор символов пароля не ограничен, а пробелы по краям являются его частью, +# поэтому такое значение записано в файле в двойных кавычках с экранированием +# `\` и `"`. `cut` отдал бы кавычки как часть пароля, а `read -r` вдобавок +# срезал бы пробелы — и проверка объявила бы рабочую установку сломанной. +# +# `source` и `eval` здесь НЕ годятся: внутри двойных кавычек shell выполняет +# подстановку команд, то есть пароль вида `$(...)` был бы исполнен. У самого +# systemd подстановок в EnvironmentFile нет, и снимать кавычки надо без shell. +read_bootstrap_field() { + sudo sed -n "s/^$1=//p" /etc/hy2xs/bootstrap-admin.secret | head -n1 \ + | sed -e 's/^"//' -e 's/"$//' -e 's/\\\(["\\]\)/\1/g' +} +BOOTSTRAP_USER="$(read_bootstrap_field ADMIN_USER)" +BOOTSTRAP_PASS="$(read_bootstrap_field ADMIN_INITIAL_PASSWORD)" + +# Положительная проба: конверт успеха и выданный токен. curl -sS --max-time 5 -X POST \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$BOOTSTRAP_PASS" '{username:$u,pass:$p}')" \ http://127.0.0.1:8080/api/auth/login | jq '.code, (.data.accessToken | length)' +# Отрицательная проба: пароль СЛУЧАЙНЫЙ, а проверяется конверт отказа целиком. +# HTTP 200 сам по себе ничего не доказывает — админка отвечает 200 и на успех. +curl -sS --max-time 5 -X POST \ + -H 'Content-Type: application/json' \ + --data "$(jq -nc --arg u "$BOOTSTRAP_USER" --arg p "$(head -c 18 /dev/urandom | base64)" '{username:$u,pass:$p}')" \ + http://127.0.0.1:8080/api/auth/login \ + | jq '{code, reason: (.errors[0].code), token: (.data.accessToken // null)}' +# Ожидается: {"code":50000,"reason":"invalid_credentials","token":null} + unset BOOTSTRAP_PASS ``` diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 89f9304..5f22487 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context"; +import { parseEnvFile, renderEnvFile } from "../lib/envFile"; import { GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE, @@ -23,22 +24,17 @@ function valueOrGenerate(value: string | undefined): string { return value; } +/** + * Разбор env-файла делегирован lib/envFile. + * + * Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по + * краям КАЖДОГО значения. Для пароля администратора это прямое нарушение + * контракта — продукт объявляет краевой пробел частью пароля, — а для + * значений с кавычками разбор вдобавок расходился с systemd, который читает + * этот же файл как EnvironmentFile. + */ function parseEnv(content: string): EnvMap { - const result: EnvMap = {}; - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - const separator = line.indexOf("="); - if (separator < 1) { - throw new Error(`invalid env line: ${rawLine}`); - } - const key = line.slice(0, separator).trim(); - const value = line.slice(separator + 1).trim(); - result[key] = value; - } - return result; + return parseEnvFile(content); } function parsePort(name: string, raw: string, fallback: number): number { @@ -396,47 +392,58 @@ export function validateRuntimeConfig(config: RuntimeConfig): void { } } +/** + * Пишет /etc/hy2xs/hy2xs.env. + * + * Каждое значение проходит через formatEnvAssignment, а не подставляется в + * строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом + * только для значений без пробелов по краям, кавычек и обратных слешей: пароль + * администратора, у которого набор символов объявлен неограниченным, не + * пережил бы обратного чтения — ни нашего, ни systemd'ного. + * + * Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и + * остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile. + */ export function renderRuntimeEnv(config: RuntimeConfig): string { - const lines = [ - "# HY2XS runtime config (editable)", - `HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`, - `HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`, - `HY2XS_DOMAIN=${config.domain}`, - `HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`, - `HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`, - `HY2XS_PUBLIC_HOST=${config.publicHost}`, - `HY2XS_PUBLIC_PORT=${config.publicPort}`, - `HY2XS_SSH_PORT=${config.sshPort}`, - `HY2XS_FIREWALL_MODE=${config.firewallMode}`, - `HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`, - `HY2XS_UI_BIND_HOST=${config.uiBindHost}`, - `HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`, - `HY2XS_UI_PORT=${config.uiPort}`, - `HY2XS_ADMIN_USER=${config.adminUser}`, - `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`, - `HY2XS_ADMIN_CON_PASS=${config.adminConPass}`, - `HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`, - `HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`, - `HY2XS_TLS_MODE=${config.tlsMode}`, - `HY2XS_ACME_TYPE=${config.acmeType}`, - `HY2XS_ACME_EMAIL=${config.acmeEmail}`, - `HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`, - `HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`, - `HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`, - `HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`, - `HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`, - `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`, - `HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`, - `HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`, - `HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`, - `HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`, - `HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`, - `HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`, - `HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`, - `HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`, - `HY2XS_INSTALL_DIR=${config.installDir}`, - `HY2XS_DATA_DIR=${config.dataDir}`, - `HY2XS_LOG_DIR=${config.logDir}` + const entries: [string, string][] = [ + ["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)], + ["HY2XS_IPV6_ENABLED", String(config.ipv6Enabled)], + ["HY2XS_DOMAIN", config.domain], + ["HY2XS_DNS_AAAA_POLICY", config.dnsAaaaPolicy], + ["HY2XS_PUBLIC_ENDPOINT_POLICY", config.publicEndpointPolicy], + ["HY2XS_PUBLIC_HOST", config.publicHost], + ["HY2XS_PUBLIC_PORT", String(config.publicPort)], + ["HY2XS_SSH_PORT", String(config.sshPort)], + ["HY2XS_FIREWALL_MODE", config.firewallMode], + ["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)], + ["HY2XS_UI_BIND_HOST", config.uiBindHost], + ["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)], + ["HY2XS_UI_PORT", String(config.uiPort)], + ["HY2XS_ADMIN_USER", config.adminUser], + ["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword], + ["HY2XS_ADMIN_CON_PASS", config.adminConPass], + ["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)], + ["HY2XS_ALLOW_SELF_SIGNED_DEV", String(config.allowSelfSignedDev)], + ["HY2XS_TLS_MODE", config.tlsMode], + ["HY2XS_ACME_TYPE", config.acmeType], + ["HY2XS_ACME_EMAIL", config.acmeEmail], + ["HY2XS_TLS_CERT_PATH", config.tlsCertPath], + ["HY2XS_TLS_KEY_PATH", config.tlsKeyPath], + ["HY2XS_HYSTERIA_BIND_HOST", config.hysteriaBindHost], + ["HY2XS_HYSTERIA_PORT", String(config.hysteriaPort)], + ["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode], + ["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost], + ["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(config.hysteriaTrafficStatsPort)], + ["HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", config.hysteriaTrafficStatsSecret], + ["HY2XS_HYSTERIA_OBFS_TYPE", config.hysteriaObfsType], + ["HY2XS_HYSTERIA_OBFS_PASSWORD", config.hysteriaObfsPassword], + ["HY2XS_HYSTERIA_BANDWIDTH_UP", config.hysteriaBandwidthUp], + ["HY2XS_HYSTERIA_BANDWIDTH_DOWN", config.hysteriaBandwidthDown], + ["HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH", String(config.hysteriaIgnoreClientBandwidth)], + ["HY2XS_HYSTERIA_CONFIG_PATH", config.hysteriaConfigPath], + ["HY2XS_INSTALL_DIR", config.installDir], + ["HY2XS_DATA_DIR", config.dataDir], + ["HY2XS_LOG_DIR", config.logDir] ]; - return `${lines.join("\n")}\n`; + return `# HY2XS runtime config (editable)\n${renderEnvFile(entries)}`; } diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index 2393d29..ba610e5 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -60,14 +60,26 @@ export const ADMIN_LOGIN_PATH = `${ADMIN_API_BASE}/auth/login`; * отказ установки чинится одной строкой в hy2xs.env, а неработающий вход на * готовом сервере — переустановкой. * - * Значения обязаны совпадать с apps/service/admin_credentials.go; сверка - * выполняется тестом admin-credentials.test.ts, который читает Go-исходник. + * Значения обязаны совпадать с apps/credential/admin.go; сверка выполняется + * тестом admin-credentials.test.ts, который читает Go-исходник. */ export const ADMIN_USERNAME_MIN_LENGTH = 6; export const ADMIN_USERNAME_MAX_LENGTH = 32; export const ADMIN_PASSWORD_MIN_LENGTH = 6; export const ADMIN_PASSWORD_MAX_LENGTH = 64; +/** + * Предел bcrypt — в БАЙТАХ. + * + * Он существует одновременно с границей в символах и не выводится из неё: у 64 + * символов длина от 64 до 256 байт в зависимости от алфавита. Пока оркестратор + * знал только границу в символах, он пропускал в hy2xs.env пароль вроде 64 + * кириллических букв (128 байт), установка проходила целиком, а первая учётная + * запись администратора не создавалась вовсе — bcrypt отвечал + * ErrPasswordTooLong уже внутри админки, при старте службы. + */ +export const ADMIN_PASSWORD_MAX_BYTES = 72; + /** * Набор символов логина в записи регекспа. * @@ -105,19 +117,57 @@ export function assertValidAdminUsername(name: string, value: string): string { } /** - * Проверка пароля администратора против контракта панели. + * Единственное правило пароля администратора на стороне оркестратора. * - * Набор символов НЕ проверяется: сервер его не ограничивает ни при установке, - * ни при смене пароля. Проверяется только длина — и в РУНАХ, ровно так её - * считает валидатор админки. `String.length` считает единицы UTF-16, и пароль - * из эмодзи прошёл бы здесь и отказался бы на форме входа. + * Копия Go-контракта, и сверяется с ним по исходнику (admin-credentials.test.ts): + * оркестратор собирается отдельно от Go-бинарника и импортировать его константы + * не может. + * + * Правило состоит из трёх частей, и каждая закрывает свой класс отказа. + * + * Длина в CODE POINTS, а не в `String.length`. `String.length` считает единицы + * UTF-16, и пароль из трёх эмодзи имел бы здесь длину 6 — прошёл бы минимум и + * получил отказ на форме входа, где сервер видит три руны. + * + * Длина в БАЙТАХ — предел bcrypt. Пока его не было, пароль из 64 кириллических + * букв проходил установку целиком, а учётная запись администратора не + * создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор + * получал не отказ установки, а сервер без администратора. + * + * Запрет управляющих символов — требование транспорта. Значение уезжает в + * /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей + * строки там является байт `\n` или `\r`, и пароль с ними не имеет + * представления, переживающего запись и чтение. См. lib/envFile.ts. + * + * Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни + * при установке, ни при смене пароля, и оркестратор не имеет права быть строже. + * Пробелы по краям — часть пароля и не снимаются. */ +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) { + return false; + } + characters += 1; + } + + if (characters < ADMIN_PASSWORD_MIN_LENGTH || characters > ADMIN_PASSWORD_MAX_LENGTH) { + return false; + } + + return Buffer.byteLength(value, "utf8") <= ADMIN_PASSWORD_MAX_BYTES; +} + export function assertValidAdminPassword(name: string, value: string): string { - const length = [...value].length; - if (length < ADMIN_PASSWORD_MIN_LENGTH || length > ADMIN_PASSWORD_MAX_LENGTH) { + if (!isValidAdminPassword(value)) { throw new Error( `invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` + - `до ${ADMIN_PASSWORD_MAX_LENGTH} символов, получено ${length}. ` + + `до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` + + `в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` + + `и ${Buffer.byteLength(value, "utf8")} байт. ` + + `Набор символов не ограничен, пробелы по краям являются частью пароля. ` + `Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.` ); } diff --git a/orchestrator/src/lib/envFile.ts b/orchestrator/src/lib/envFile.ts new file mode 100644 index 0000000..3dc76ea --- /dev/null +++ b/orchestrator/src/lib/envFile.ts @@ -0,0 +1,322 @@ +/** + * Формат файлов `KEY=VALUE`, которые продукт пишет и читает: /etc/hy2xs/hy2xs.env + * и /etc/hy2xs/bootstrap-admin.secret. + * + * Зачем этот модуль существует. У hy2xs.env ДВА читателя, и один из них не наш: + * файл объявлен `EnvironmentFile=` в юните hy2xs-admin, то есть его разбирает + * systemd. Пока оркестратор писал значения интерполяцией + * + * `HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}` + * + * а читал их построчным `split("=")` с `trim()`, форматом это не являлось — + * это было совпадение поведения на значениях, у которых нет ни пробелов по + * краям, ни кавычек, ни обратных слешей. Продукт при этом ОБЕЩАЕТ оператору, + * что набор символов пароля не ограничен, а пробел по краям — часть значения. + * Обещание не выполнялось ни одним из двух читателей: + * + * - systemd у НЕ закавыченного значения срезает пробелы по краям и трактует + * `\` как escape (src/basic/env-file.c, состояние VALUE); + * - собственный парсер срезал пробелы своим `trim()`. + * + * То есть пароль с краевым пробелом терялся ещё до запуска админки, а пароль с + * обратным слешем приезжал изменённым. + * + * Поэтому здесь ровно две функции, и они обратны друг другу: + * + * parseEnvFile — разбор по правилам systemd; + * formatEnvAssignment — запись, которую systemd разберёт обратно побайтово. + * + * Правила разбора не выдуманы и не выведены из документации: они повторяют + * конечный автомат `parse_env_file_internal` из systemd/src/basic/env-file.c. + * Существенны четыре его свойства: + * + * 1. у НЕ закавыченного значения срезаются пробелы в конце, `\` уводит в + * escape, `\<перевод строки>` склеивает строки; + * 2. в одинарных кавычках всё literal до закрывающей кавычки — escape там + * НЕТ (это отличие от sh); + * 3. в двойных кавычках `\` уводит в escape, и escape «разворачивается» + * только для SHELL_NEED_ESCAPE — то есть для `"`, `\`, `` ` `` и `$`; + * для любого другого символа обратный слеш СОХРАНЯЕТСЯ вместе с ним; + * 4. подстановки переменных в env-файле нет вовсе: `$` внутри значения — + * обычный символ. + * + * Из (3) и (4) следует кодирование, которое переживает любое издание systemd: + * двойные кавычки и экранирование ТОЛЬКО `\` и `"`. Оба входят в + * SHELL_NEED_ESCAPE, поэтому разворачиваются одинаково и в действующем + * издании, и в тех, где escape в двойных кавычках снимался безусловно. + */ + +/** Символы, которые systemd считает границей строки. */ +const NEWLINE = "\n\r"; +/** Символы, которые systemd считает пробельными. */ +const WHITESPACE = " \t\n\r"; +/** Начало комментария — только в позиции, где ожидается имя переменной. */ +const COMMENTS = "#;"; +/** + * SHELL_NEED_ESCAPE из systemd: внутри двойных кавычек обратный слеш перед этими + * символами снимается, перед любым другим — сохраняется. + */ +const SHELL_NEED_ESCAPE = '"\\`$'; + +type State = + | "PRE_KEY" + | "KEY" + | "PRE_VALUE" + | "VALUE" + | "VALUE_ESCAPE" + | "SINGLE_QUOTE_VALUE" + | "DOUBLE_QUOTE_VALUE" + | "DOUBLE_QUOTE_VALUE_ESCAPE" + | "COMMENT" + | "COMMENT_ESCAPE"; + +/** + * Разбирает содержимое env-файла ровно так, как это делает systemd. + * + * Строка без `=` — ОШИБКА, а не пропуск. systemd такую строку молча + * отбрасывает, и здесь это единственное намеренное расхождение: молчаливая + * потеря строки из /etc/hy2xs/hy2xs.env означала бы установку с настройкой, + * которую оператор задал, а продукт не увидел. Расхождение в сторону отказа + * безопасно — оно останавливает установку там, где её можно починить. + */ +export function parseEnvFile(content: string): Record { + const result: Record = {}; + + let state: State = "PRE_KEY"; + let key = ""; + let value = ""; + let lastKeyWhitespace = -1; + let lastValueWhitespace = -1; + let line = 1; + + const flush = (stripValueWhitespace: boolean): void => { + const name = lastKeyWhitespace < 0 ? key : key.slice(0, lastKeyWhitespace); + const raw = + stripValueWhitespace && lastValueWhitespace >= 0 + ? value.slice(0, lastValueWhitespace) + : value; + if (name !== "") { + result[name] = raw; + } + key = ""; + value = ""; + lastKeyWhitespace = -1; + lastValueWhitespace = -1; + }; + + for (const c of content) { + switch (state) { + case "PRE_KEY": + if (COMMENTS.includes(c)) { + state = "COMMENT"; + } else if (!WHITESPACE.includes(c)) { + state = "KEY"; + lastKeyWhitespace = -1; + key += c; + } + break; + + case "KEY": + if (NEWLINE.includes(c)) { + // Имя без `=`. systemd молча отбрасывает такую строку; мы называем её. + throw new Error(`invalid env line ${line}: ${key.trim()}`); + } else if (c === "=") { + state = "PRE_VALUE"; + lastValueWhitespace = -1; + } else { + if (!WHITESPACE.includes(c)) { + lastKeyWhitespace = -1; + } else if (lastKeyWhitespace < 0) { + lastKeyWhitespace = key.length; + } + key += c; + } + break; + + case "PRE_VALUE": + if (NEWLINE.includes(c)) { + state = "PRE_KEY"; + line += 1; + flush(false); + } else if (c === "'") { + state = "SINGLE_QUOTE_VALUE"; + } else if (c === '"') { + state = "DOUBLE_QUOTE_VALUE"; + } else if (c === "\\") { + state = "VALUE_ESCAPE"; + } else if (!WHITESPACE.includes(c)) { + state = "VALUE"; + value += c; + } + break; + + case "VALUE": + if (NEWLINE.includes(c)) { + state = "PRE_KEY"; + line += 1; + flush(true); + } else if (c === "\\") { + state = "VALUE_ESCAPE"; + lastValueWhitespace = -1; + } else { + if (!WHITESPACE.includes(c)) { + lastValueWhitespace = -1; + } else if (lastValueWhitespace < 0) { + lastValueWhitespace = value.length; + } + value += c; + } + break; + + case "VALUE_ESCAPE": + state = "VALUE"; + // Экранированный перевод строки — склейка строк, и он съедается целиком. + if (!NEWLINE.includes(c)) { + value += c; + } else { + line += 1; + } + break; + + case "SINGLE_QUOTE_VALUE": + // Escape внутри одинарных кавычек НЕТ: всё до закрывающей кавычки + // приезжает как есть. Это отличие от sh, и именно поэтому кодирование + // ниже использует двойные кавычки — в одинарных нельзя записать сам + // апостроф. + if (c === "'") { + state = "PRE_VALUE"; + } else { + if (NEWLINE.includes(c)) { + line += 1; + } + value += c; + } + break; + + case "DOUBLE_QUOTE_VALUE": + if (c === '"') { + state = "PRE_VALUE"; + } else if (c === "\\") { + state = "DOUBLE_QUOTE_VALUE_ESCAPE"; + } else { + if (NEWLINE.includes(c)) { + line += 1; + } + value += c; + } + break; + + case "DOUBLE_QUOTE_VALUE_ESCAPE": + state = "DOUBLE_QUOTE_VALUE"; + if (SHELL_NEED_ESCAPE.includes(c)) { + value += c; + } else if (!NEWLINE.includes(c)) { + // Обратный слеш СОХРАНЯЕТСЯ вместе с символом — «как делает + // настоящий shell», по формулировке самого systemd. + value += "\\" + c; + } else { + line += 1; + } + break; + + case "COMMENT": + if (c === "\\") { + state = "COMMENT_ESCAPE"; + } else if (NEWLINE.includes(c)) { + state = "PRE_KEY"; + line += 1; + } + break; + + case "COMMENT_ESCAPE": + state = "COMMENT"; + if (NEWLINE.includes(c)) { + line += 1; + } + break; + } + } + + // Хвост без перевода строки на конце файла. + switch (state) { + case "KEY": + throw new Error(`invalid env line ${line}: ${key.trim()}`); + case "PRE_VALUE": + flush(false); + break; + case "VALUE": + flush(true); + break; + case "VALUE_ESCAPE": + case "SINGLE_QUOTE_VALUE": + case "DOUBLE_QUOTE_VALUE": + case "DOUBLE_QUOTE_VALUE_ESCAPE": + // Незакрытая кавычка — испорченный файл, а не значение до конца файла. + // systemd в этом месте отдаёт то, что успел накопить; для конфигурации, + // от которой зависит доступ в панель, «что успели накопить» — не ответ. + throw new Error(`unterminated env value for ${key.trim()}`); + default: + break; + } + + return result; +} + +/** + * Значения, которые можно записать без кавычек. + * + * Набор намеренно узкий и не выведен из правил systemd: цель — чтобы уже + * существующие строки файла (пути, порты, домены, `50 mbps`, base64url-секреты) + * остались побайтово прежними, а всё хоть сколько-нибудь необычное уезжало в + * кавычки. Одиночные пробелы ВНУТРИ значения разрешены, по краям — нет: именно + * краевые systemd и срезает. + */ +const UNQUOTED_SAFE_VALUE = /^[A-Za-z0-9_\-.\/:@,=+%]+(?: [A-Za-z0-9_\-.\/:@,=+%]+)*$/; + +/** + * Проверяет, что значение вообще представимо в этом формате. + * + * Управляющих символов формат не несёт: перевод строки — граница записи, а не + * данные. Отказ здесь громкий намеренно — молчаливая потеря части секрета + * означала бы установку, после которой невозможно войти, и причину, которой + * негде увидеться. + */ +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 и оркестратор, управляющих символов не несёт` + ); + } + } + return value; +} + +/** + * Собирает строку `KEY=VALUE`, которую systemd разберёт обратно побайтово. + * + * Кавычки ставятся только когда они нужны, и это не косметика: пока запись + * остаётся прежней для обычных значений, релизные гейты и инструкции оператора, + * ищущие строку `grep '^HY2XS_UI_PORT=8080$'`, продолжают работать, а изменение + * формата видно ровно там, где оно что-то чинит. + */ +export function formatEnvAssignment(key: string, value: string): string { + assertEnvTransportable(key, value); + + if (value === "" || UNQUOTED_SAFE_VALUE.test(value)) { + return `${key}=${value}`; + } + + // Экранируются ТОЛЬКО `\` и `"`. Оба входят в SHELL_NEED_ESCAPE, поэтому + // разворачиваются обратно одинаково во всех изданиях systemd. Backtick и `$` + // внутри двойных кавычек — обычные символы: подстановки в env-файле нет. + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `${key}="${escaped}"`; +} + +/** Готовый файл из пар, каждая — через formatEnvAssignment. */ +export function renderEnvFile(entries: readonly (readonly [string, string])[]): string { + return `${entries.map(([key, value]) => formatEnvAssignment(key, value)).join("\n")}\n`; +} diff --git a/orchestrator/src/steps/env.ts b/orchestrator/src/steps/env.ts index 7b2b8bb..927afba 100644 --- a/orchestrator/src/steps/env.ts +++ b/orchestrator/src/steps/env.ts @@ -1,5 +1,6 @@ import type { RuntimeContext } from "../types/context"; import { fileExists, readText, renderTemplate, writeTextAtomic } from "../lib/fs"; +import { renderEnvFile } from "../lib/envFile"; import { runMutatingVisible } from "../lib/process"; import { BBR_PROFILE, @@ -56,14 +57,23 @@ export async function writePostInstallEnv(context: RuntimeContext): Promise { await writeTextAtomic( context.config.bootstrapAdminSecretPath, - [ - `ADMIN_USER=${context.config.adminUser}`, - `ADMIN_INITIAL_PASSWORD=${context.config.adminInitialPassword}`, - `ADMIN_CON_PASS=${context.config.adminConPass}` - ].join("\n") + "\n", + renderEnvFile([ + ["ADMIN_USER", context.config.adminUser], + ["ADMIN_INITIAL_PASSWORD", context.config.adminInitialPassword], + ["ADMIN_CON_PASS", context.config.adminConPass] + ]), { mode: 0o600, owner: "root", diff --git a/orchestrator/src/steps/smoke.ts b/orchestrator/src/steps/smoke.ts index cfa90d6..c29e251 100644 --- a/orchestrator/src/steps/smoke.ts +++ b/orchestrator/src/steps/smoke.ts @@ -1,6 +1,8 @@ +import { randomBytes } from "node:crypto"; import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { readText } from "../lib/fs"; +import { parseEnvFile } from "../lib/envFile"; import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; @@ -194,7 +196,7 @@ export async function smoke(context: RuntimeContext): Promise { } if (context.mode === "install") { - const adminConPass = (await runReadOnlySecret`grep '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-`).trim(); + const adminConPass = (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? ""; if (!adminConPass) { throw new Error("admin connection password is empty in bootstrap secret file"); } @@ -279,36 +281,55 @@ export async function smoke(context: RuntimeContext): Promise { async function assertAdminLoginWorks(context: RuntimeContext): Promise { const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`; + // Отрицательная проба доказывает ОТКАЗ, а не отсутствие HTTP 500. + // + // Здесь стояли жёстко записанный пароль `definitely-not-the-admin-password` и + // критерий `code.trim() === "200"`. У этой пары было два изъяна. Пароль был + // литералом, то есть теоретически мог оказаться настоящим — и тогда проверка + // «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ. А критерий + // «HTTP 200» не отличал отказ от успеха вовсе: админка отвечает 200 и на то, + // и на другое, причина живёт в конверте. Проверка доказывала ровно одно — + // что запрос не закончился пятисоткой. + // + // Теперь пароль генерируется в момент пробы, а результат сверяется с + // конвертом: код 50000, причина `invalid_credentials` и ОТСУТСТВИЕ токена + // доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы + // запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля. + const rejectedPassword = randomBytes(24).toString("base64url"); const rejectedPayload = JSON.stringify({ - username: "hy2xsadmin", - pass: "definitely-not-the-admin-password" + username: context.config.adminUser, + pass: rejectedPassword }); - const rejectedStatus = await retry( + const rejectedBody = await retry( "admin login rejects wrong credentials", 10, 1000, async () => - runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`, - (code) => code.trim() === "200", - (code, error) => + runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`, + (body) => isRejectedLogin(body), + (body, error) => new Error( - `admin login answered HTTP ${code ?? String(error)} for invalid credentials: ` + - `панель обязана отвечать конвертом отказа, а HTTP 500 здесь означает, что запрос ` + - `не доживает до проверки учётных данных` + `admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` + + `Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` + + `Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.` ) ); - info(`admin login rejects wrong credentials with HTTP ${rejectedStatus.trim()}`); + info(`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`); if (context.mode !== "install") { return; } - const adminUser = ( - await runReadOnlySecret`grep '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-` - ).trim(); - const adminPassword = ( - await runReadOnlySecret`grep '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath} | head -n1 | cut -d= -f2-` - ).trim(); + // Файл читается ЦЕЛИКОМ и разбирается тем же парсером, которым он написан. + // + // Здесь стояло `grep … | cut -d= -f2-` с последующим `.trim()`, и это был + // третий по счёту слой, срезающий пробелы у пароля, набор символов которого + // объявлен неограниченным. Даже с починенными env-транспортом и админкой + // проба брала бы не тот пароль и объявляла бы рабочую установку сломанной. + // Побочно исчез и прогон секрета через shell-конвейер. + const bootstrap = await readBootstrapAdminSecret(context); + const adminUser = bootstrap.ADMIN_USER ?? ""; + const adminPassword = bootstrap.ADMIN_INITIAL_PASSWORD ?? ""; if (!adminUser) { throw new Error("admin username is empty in bootstrap secret file"); } @@ -316,6 +337,13 @@ async function assertAdminLoginWorks(context: RuntimeContext): Promise { throw new Error("admin initial password is empty in bootstrap secret file"); } + // Отрицательная проба обязана быть отрицательной. Совпадение случайного + // пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда + // «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало». + if (rejectedPassword === adminPassword) { + throw new Error("negative login probe accidentally used the real admin password"); + } + const payload = JSON.stringify({ username: adminUser, pass: adminPassword }); const response = await retry( "admin login with bootstrap credentials", @@ -348,6 +376,75 @@ function isSuccessfulLogin(body: string): boolean { return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body); } +/** + * Код причины отказа по учётным данным — тот же литерал, что и + * constant.ErrCodeInvalidCredentials в админке. + * + * Он часть публичного контракта API: панель выбирает по нему локализованную + * фразу, и его значение не меняется вместе с формулировками сообщений. + */ +const INVALID_CREDENTIALS_CODE = "invalid_credentials"; + +/** + * Отказ определяется по КОНВЕРТУ целиком, а не по отсутствию HTTP 500. + * + * Требуются три признака сразу, и каждый закрывает свой способ ошибиться: + * + * код ответа 50000 — это отказ уровня операции, а не успех (20000) и + * не отказ валидации (50001), который означал бы, + * что проба отправила негодный запрос и до проверки + * пароля он не дошёл; + * причина invalid_credentials — отказ именно по учётным данным, а не, + * скажем, по недоступной базе; + * отсутствие accessToken — панель не выдала сессию. + */ +function isRejectedLogin(body: string): boolean { + return ( + /"code"\s*:\s*50000/.test(body) && + new RegExp(`"code"\\s*:\\s*"${INVALID_CREDENTIALS_CODE}"`).test(body) && + !/"accessToken"\s*:\s*"[^"]+"/.test(body) + ); +} + +/** + * Причина, по которой отрицательная проба не признана отказом, — БЕЗ тела + * ответа. + * + * Тело сюда попасть не может по той же причине, что и в describeLoginFailure: + * если панель по недоразумению впустила, в ответе лежит токен доступа, а этот + * текст уезжает в журнал установки и в diagnostics-бандл. + */ +function describeRejectionFailure(body: string | undefined, error: unknown): string { + if (body === undefined) { + return `запрос не выполнен: ${String(error)}`; + } + if (/"accessToken"\s*:\s*"[^"]+"/.test(body)) { + return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные"; + } + return describeRejection(body); +} + +/** Как выглядит отказ: код конверта и код причины, без тела. */ +function describeRejection(body: string): string { + const envelope = body.match(/"code"\s*:\s*(\d+)/); + const reason = body.match(/"code"\s*:\s*"([a-z_]+)"/); + return `code=${envelope ? envelope[1] : "нет"}, причина=${reason ? reason[1] : "нет"}, токен не выдан`; +} + +// Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан. +// +// Файл несёт пароль администратора, у которого набор символов не ограничен, а +// пробелы по краям являются частью значения. Прежнее чтение — +// `grep … | cut -d= -f2-` с `.trim()` — не умело ни того, ни другого, и вдобавок +// прогоняло секрет через shell-конвейер. +// +// Комментарий записан строчными `//`, а не блоком: скан релизных гейтов +// отбрасывает только их, и объяснение, называющее убранную конструкцию по +// имени, иначе роняет проверку «этой конструкции здесь больше нет». +async function readBootstrapAdminSecret(context: RuntimeContext): Promise> { + return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath)); +} + /** * Причина отказа БЕЗ тела ответа. * diff --git a/orchestrator/test/admin-credentials.test.ts b/orchestrator/test/admin-credentials.test.ts index 6fad386..f364998 100644 --- a/orchestrator/test/admin-credentials.test.ts +++ b/orchestrator/test/admin-credentials.test.ts @@ -5,12 +5,14 @@ import path from "node:path"; import { parseRuntimeEnv } from "../src/config/env"; import { ADMIN_LOGIN_PATH, + ADMIN_PASSWORD_MAX_BYTES, ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MIN_LENGTH, ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MIN_LENGTH, - ADMIN_USERNAME_PATTERN + ADMIN_USERNAME_PATTERN, + isValidAdminPassword } from "../src/config/profile"; import { baselineConfig, envText } from "./fixtures"; @@ -28,7 +30,7 @@ const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); function goContract(): string { return fs.readFileSync( - path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"), + path.join(REPO_ROOT, "apps", "credential", "admin.go"), "utf8" ); } @@ -43,18 +45,37 @@ function goConst(name: string): string { if (backticked) { return backticked[1]; } - throw new Error(`константа ${name} не найдена в apps/service/admin_credentials.go`); + throw new Error(`константа ${name} не найдена в apps/credential/admin.go`); +} + +/** + * Содержимое файла без комментариев. + * + * Проверять положено КОД, а не упоминания: комментарий, объясняющий, почему + * убранной конструкции здесь больше нет, обязан называть её по имени, иначе он + * бесполезен, — и проверка «такой конструкции в файле нет» начинает падать + * ровно на той документации, которая описывает выполненную ею же работу. + */ +function codeOf(source: string): string { + return source + .split("\n") + .filter((line) => !/^\s*(\/\/|\/?\*)/.test(line)) + .join("\n"); } describe("контракт учётных данных совпадает с админкой", () => { // Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от // Go-бинарника и импортировать его константы не может. Проверяется поэтому // не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику. - test("границы длины совпадают с service.Admin*Length", () => { + test("границы совпадают с credential.Admin*", () => { expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength")); expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength")); expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength")); expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength")); + // Предел bcrypt — ОТДЕЛЬНАЯ константа и отдельная сверка: пока его тут не + // было, оркестратор пропускал в hy2xs.env пароль, который админка не могла + // захешировать, и установка заканчивалась сервером без администратора. + expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes")); }); test("набор символов логина совпадает с классом из Go", () => { @@ -191,13 +212,79 @@ describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разбо } }); - // Длина считается в РУНАХ: 32 кириллические буквы — это 64 байта, и проверка - // по байтам отвергла бы пароль, который панель принимает. - test("длина считается в символах, а не в байтах", () => { - const cyrillic = "я".repeat(ADMIN_PASSWORD_MAX_LENGTH); - expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: cyrillic }).adminInitialPassword).toBe( - cyrillic + // Границ ДВЕ, и они в разных единицах. + // + // Здесь стоял тест «длина считается в символах, а не в байтах», требовавший + // приёма 64 кириллических букв. Он закреплял как ожидаемое ровно то значение, + // на котором продукт ломался: 64 символа — это 128 байт, а + // bcrypt.GenerateFromPassword отвечает ErrPasswordTooLong строго при длине + // больше 72 байт. Установка проходила целиком, а первая учётная запись + // администратора не создавалась вовсе. + // + // Длина по-прежнему считается в СИМВОЛАХ — иначе оркестратор был бы строже + // панели, — но байтовая граница существует одновременно с ней. + test("границы пароля проверяются и в символах, и в байтах", () => { + // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи + // ниже проверяли бы не то, что написано. + expect(Buffer.byteLength("я".repeat(36), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES); + expect(Buffer.byteLength("😀".repeat(18), "utf8")).toBe(ADMIN_PASSWORD_MAX_BYTES); + + const accepted = [ + "a".repeat(ADMIN_PASSWORD_MIN_LENGTH), + "a".repeat(ADMIN_PASSWORD_MAX_LENGTH), + "я".repeat(36), + "😀".repeat(18), + "abcde ", + " ".repeat(ADMIN_PASSWORD_MIN_LENGTH) + ]; + for (const password of accepted) { + expect(isValidAdminPassword(password)).toBe(true); + expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` }).adminInitialPassword).toBe( + password + ); + } + + const rejected = [ + "a".repeat(ADMIN_PASSWORD_MIN_LENGTH - 1), + "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1), + "я".repeat(37), + "😀".repeat(19), + "я".repeat(ADMIN_PASSWORD_MAX_LENGTH) + ]; + for (const password of rejected) { + expect(isValidAdminPassword(password)).toBe(false); + expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: `"${password}"` })).toThrow( + /invalid HY2XS_ADMIN_INITIAL_PASSWORD/ + ); + } + }); + + // Отказ обязан НАЗЫВАТЬ обе границы: пароль из 40 эмодзи укладывается в 64 + // символа и не укладывается в 72 байта, и сообщение «не длиннее 64 символов» + // отправило бы оператора сокращать пароль, отвергнутый не за это. + test("отказ называет и символы, и байты", () => { + const tooManyBytes = `"${"я".repeat(ADMIN_PASSWORD_MAX_LENGTH)}"`; + expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow( + new RegExp(`${ADMIN_PASSWORD_MAX_BYTES} байт`) ); + expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: tooManyBytes })).toThrow( + new RegExp(`${ADMIN_PASSWORD_MIN_LENGTH}.*${ADMIN_PASSWORD_MAX_LENGTH}`) + ); + }); + + // Управляющий символ отвергается контрактом, а не теряется транспортом. + test("управляющий символ в пароле роняет установку", () => { + expect(isValidAdminPassword("abcde\n")).toBe(false); + expect(isValidAdminPassword("abcde\t")).toBe(false); + }); + + // Сгенерированный пароль обязан проходить контракт ЦЕЛИКОМ, включая байты: + // генератор — такой же источник значения, как и оператор. + test("сгенерированный пароль проходит контракт целиком", () => { + for (let i = 0; i < 50; i += 1) { + const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "__GENERATE__" })); + expect(isValidAdminPassword(config.adminInitialPassword)).toBe(true); + } }); }); @@ -277,4 +364,66 @@ describe("smoke выполняет настоящий вход в панель", expect(rejected).toBeGreaterThan(-1); expect(modeGate).toBeGreaterThan(rejected); }); + + // Отрицательная проба обязана доказывать ОТКАЗ. + // + // Она проверяла `code.trim() === "200"`, то есть ровно одно: что запрос не + // закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже, + // поэтому «неверные учётные данные отвергнуты» этой проверкой не следовало. + test("отрицательная проба сверяет конверт отказа, а не код HTTP", () => { + const source = smoke(); + + expect(source).toContain("isRejectedLogin"); + // Код конверта отказа, доменная причина и ОТСУТСТВИЕ токена — три признака, + // и каждый закрывает свой способ ошибиться. + expect(source).toMatch(/"code"\\s\*:\\s\*50000/); + expect(source).toContain('INVALID_CREDENTIALS_CODE = "invalid_credentials"'); + + const start = source.indexOf("function isRejectedLogin"); + const body = source.slice(start, source.indexOf("\n/**", start)); + expect(body).toContain("!/\"accessToken\""); + }); + + // Доменный код обязан совпадать с тем, который объявляет админка. + test("код причины отказа совпадает с constant.ErrCodeInvalidCredentials", () => { + const goErrors = fs.readFileSync( + path.join(REPO_ROOT, "apps", "model", "constant", "error.go"), + "utf8" + ); + const declared = goErrors.match(/ErrCodeInvalidCredentials\s+string\s*=\s*"([^"]+)"/); + expect(declared).not.toBeNull(); + expect(smoke()).toContain(`INVALID_CREDENTIALS_CODE = "${declared![1]}"`); + }); + + // Пароль отрицательной пробы не имеет права быть литералом: записанное в + // исходнике значение теоретически может оказаться настоящим паролем, и тогда + // проверка «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ. + test("пароль отрицательной пробы генерируется, а не записан в исходнике", () => { + const source = smoke(); + // Считается КОД, а не упоминания: комментарий, объясняющий, почему прежнего + // литерала здесь больше нет, обязан называть его по имени — иначе он + // бесполезен. Тот же урок уже записан в code_without_comments в + // acceptance.sh и в codeOf во frontend-contract.test.ts. + expect(codeOf(source)).not.toContain("definitely-not-the-admin-password"); + expect(source).toContain('randomBytes(24).toString("base64url")'); + expect(source).toContain("negative login probe accidentally used the real admin password"); + }); + + // Bootstrap-секрет читается ПАРСЕРОМ того же формата, которым он написан. + // + // Прежнее `grep … | cut -d= -f2-` с `.trim()` было третьим по счёту слоем, + // срезающим краевые пробелы у пароля, набор символов которого объявлен + // неограниченным: положительная проба брала не тот пароль и объявляла бы + // рабочую установку сломанной. + test("bootstrap-секрет читается парсером формата, а не шелл-конвейером", () => { + const source = smoke(); + + expect(source).toContain("readBootstrapAdminSecret"); + expect(source).toContain("parseEnvFile(await readText(context.config.bootstrapAdminSecretPath))"); + + // Извлечения значения шеллом не остаётся ни одного. Проверки НАЛИЧИЯ + // ключей (`grep -q '^ADMIN_USER='`) при этом законны и остаются: они + // отвечают на другой вопрос — цел ли файл, — и значения не читают. + expect(codeOf(source)).not.toContain("cut -d= -f2-"); + }); }); diff --git a/orchestrator/test/env-file.test.ts b/orchestrator/test/env-file.test.ts new file mode 100644 index 0000000..36b8380 --- /dev/null +++ b/orchestrator/test/env-file.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; + +import { + assertEnvTransportable, + formatEnvAssignment, + parseEnvFile, + renderEnvFile +} from "../src/lib/envFile"; +import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env"; +import { baselineConfig, envText } from "./fixtures"; + +/** + * Формат /etc/hy2xs/hy2xs.env. + * + * У файла ДВА читателя, и один из них не наш: он объявлен `EnvironmentFile=` в + * юните hy2xs-admin, то есть его разбирает systemd. Пока значения писались + * интерполяцией, а читались `split("=")` с `trim()`, форматом это не являлось — + * совпадение поведения держалось на том, что в значениях не встречалось ни + * пробелов по краям, ни кавычек, ни обратных слешей. Продукт при этом обещает + * оператору, что набор символов пароля не ограничен, а краевой пробел — часть + * значения. + * + * Проверяется поэтому не «работает на наших данных», а совпадение с правилами + * systemd (src/basic/env-file.c) на тех самых значениях, где прежний разбор + * расходился. + */ + +describe("разбор повторяет правила systemd", () => { + test("у незакавыченного значения срезаются краевые пробелы", () => { + // Ровно это делает состояние VALUE: `value[last_value_whitespace] = 0`. + // Отсюда и требование закавычивать пароль: иначе его хвост теряет systemd, + // а не наш парсер. + expect(parseEnvFile("KEY= value \n")).toEqual({ KEY: "value" }); + }); + + test("в двойных кавычках краевые пробелы сохраняются", () => { + expect(parseEnvFile('KEY=" value "\n')).toEqual({ KEY: " value " }); + }); + + test("в двойных кавычках снимается escape только у SHELL_NEED_ESCAPE", () => { + // systemd: `если символ из тех, что нуждаются в экранировании, — снять + // экранирование; иначе оставить обратный слеш на месте, как делает shell`. + expect(parseEnvFile('KEY="a\\"b"\n')).toEqual({ KEY: 'a"b' }); + expect(parseEnvFile('KEY="a\\\\b"\n')).toEqual({ KEY: "a\\b" }); + expect(parseEnvFile('KEY="a\\$b"\n')).toEqual({ KEY: "a$b" }); + expect(parseEnvFile('KEY="a\\`b"\n')).toEqual({ KEY: "a`b" }); + // `n` в SHELL_NEED_ESCAPE не входит: обратный слеш сохраняется. + expect(parseEnvFile('KEY="a\\nb"\n')).toEqual({ KEY: "a\\nb" }); + }); + + test("подстановки переменных в значении нет", () => { + // env-файл не является shell-скриптом: `$` — обычный символ. + expect(parseEnvFile('KEY="$HOME ${OTHER}"\n')).toEqual({ KEY: "$HOME ${OTHER}" }); + }); + + test("в одинарных кавычках всё literal, escape там нет", () => { + expect(parseEnvFile("KEY='a\\b\"c $d'\n")).toEqual({ KEY: 'a\\b"c $d' }); + }); + + test("незакавыченное значение обрабатывает обратный слеш как escape", () => { + expect(parseEnvFile("KEY=a\\ b\n")).toEqual({ KEY: "a b" }); + // Экранированный перевод строки склеивает строки. + expect(parseEnvFile("KEY=a\\\nb\n")).toEqual({ KEY: "ab" }); + }); + + test("комментарии и пустые строки пропускаются", () => { + expect(parseEnvFile("# comment\n\n; other\n KEY=value\n")).toEqual({ KEY: "value" }); + }); + + test("пустое значение остаётся пустой строкой", () => { + expect(parseEnvFile("KEY=\n")).toEqual({ KEY: "" }); + }); + + test("значение без перевода строки в конце файла не теряется", () => { + expect(parseEnvFile("KEY=value")).toEqual({ KEY: "value" }); + }); + + // Единственное намеренное расхождение с systemd: он такую строку молча + // отбрасывает. Молчаливая потеря строки из hy2xs.env означала бы установку с + // настройкой, которую оператор задал, а продукт не увидел. + test("строка без знака равенства — отказ, а не пропуск", () => { + expect(() => parseEnvFile("HY2XS_UI_PORT\n")).toThrow(/invalid env line/); + }); + + test("незакрытая кавычка — отказ, а не значение до конца файла", () => { + expect(() => parseEnvFile('KEY="value\n')).toThrow(/unterminated env value/); + }); +}); + +describe("запись обратима разбором", () => { + // Значения, на которых прежняя запись теряла или искажала содержимое. Каждое + // из них — законный пароль администратора: набор символов не ограничен. + const values = [ + "simple-value", + " краевые пробелы ", + "trailing space ", + " leading space", + 'кавычка " внутри', + "обратный слеш \\ внутри", + "и то и другое \\\" вместе", + "доллар $HOME и backtick `id`", + "апостроф ' внутри", + "решётка # и точка с запятой ;", + "50 mbps", + "равно = внутри значения", + "пароль-администратора", + "😀😀😀😀😀😀" + ]; + + test("любое значение переживает render -> parse побайтово", () => { + for (const value of values) { + const rendered = renderEnvFile([["HY2XS_ADMIN_INITIAL_PASSWORD", value]]); + expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value); + } + }); + + test("кавычки ставятся только там, где они нужны", () => { + // Обычные значения обязаны остаться побайтово прежними: релизные гейты и + // инструкции оператора ищут строки вида `^HY2XS_UI_PORT=8080$`. + expect(formatEnvAssignment("HY2XS_UI_PORT", "8080")).toBe("HY2XS_UI_PORT=8080"); + expect(formatEnvAssignment("HY2XS_ADMIN_USER", "hy2xsadmin")).toBe("HY2XS_ADMIN_USER=hy2xsadmin"); + expect(formatEnvAssignment("HY2XS_HYSTERIA_BANDWIDTH_UP", "50 mbps")).toBe( + "HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps" + ); + expect(formatEnvAssignment("HY2XS_TLS_CERT_PATH", "/etc/hysteria/server.crt")).toBe( + "HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt" + ); + expect(formatEnvAssignment("HY2XS_DOMAIN", "")).toBe("HY2XS_DOMAIN="); + + // А необычные — получают кавычки и экранирование. + expect(formatEnvAssignment("K", "trailing ")).toBe('K="trailing "'); + expect(formatEnvAssignment("K", 'a"b')).toBe('K="a\\"b"'); + expect(formatEnvAssignment("K", "a\\b")).toBe('K="a\\\\b"'); + }); + + test("экранируются ТОЛЬКО обратный слеш и двойная кавычка", () => { + // Оба входят в SHELL_NEED_ESCAPE, поэтому разворачиваются обратно + // одинаково во всех изданиях systemd — в том числе в тех, где escape в + // двойных кавычках снимался безусловно. `$` и backtick трогать нельзя: + // подстановки в env-файле нет, и лишний слеш стал бы частью значения. + 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/ + ); + }); +}); + +describe("пароль администратора доезжает до админки неизменным", () => { + // Главный барьер файла. Продукт объявляет краевой пробел частью пароля; + // прежняя цепочка теряла его дважды — при записи (systemd срезал бы хвост + // незакавыченного значения) и при чтении (собственный `trim()`). + const passwords = [ + "abcde ", + " abcde", + 'пароль "в кавычках"', + "back\\slash", + "d$llar and `tick`", + "апостроф ' и решётка #", + " " + ]; + + test("render -> parse сохраняет значение целиком", () => { + for (const password of passwords) { + const config = baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: null }); + const rendered = renderRuntimeEnv({ ...config, adminInitialPassword: password }); + expect(parseRuntimeEnv(rendered).adminInitialPassword).toBe(password); + } + }); + + test("значение, записанное оператором в кавычках, читается как есть", () => { + // Оператор правит hy2xs.env руками: закавыченная запись обязана работать и + // у нас, и у systemd одинаково. + const config = parseRuntimeEnv( + envText({ HY2XS_ADMIN_INITIAL_PASSWORD: '"пароль с пробелом на конце "' }) + ); + expect(config.adminInitialPassword).toBe("пароль с пробелом на конце "); + }); + + test("незакавыченное значение теряет краевые пробелы — как и у systemd", () => { + // Это НЕ дефект, а точное повторение чужих правил: оператор, записавший + // пароль без кавычек, получит ровно тот пароль, который увидит админка. + const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: "abcdef " })); + expect(config.adminInitialPassword).toBe("abcdef"); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 33a10bd..3daf26d 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -799,16 +799,79 @@ run_admin_login_acceptance() { || fail "acceptance: барьер перестал обходить исходники модели" log_step "Acceptance: the admin credential contract has a single owner" - [ -f apps/service/admin_credentials.go ] \ + # Контракт живёт в LEAF-пакете, а не в service. Это не вкусовщина: его зовут + # util.HashPassword и dao (bootstrap), а `service` импортирует `util` — + # обратный импорт был бы циклическим. Пока контракт лежал в service, + # HashPassword завёл собственную проверку `len(TrimSpace(p)) < 6`, и она + # разошлась с остальным продуктом. + [ -f apps/credential/admin.go ] \ || fail "acceptance: контракт учётных данных администратора отсутствует" - code_has apps/controller/validator.go -F -- 'service.IsValidAdminUsername' \ + [ ! -f apps/service/admin_credentials.go ] \ + || fail "acceptance: контракт вернулся в service, откуда его не может позвать util.HashPassword" + code_has apps/controller/validator.go -F -- 'credential.IsValidAdminUsername' \ || fail "acceptance: правило логина снова несёт собственную копию набора символов" + code_has apps/controller/validator.go -F -- 'credential.IsValidAdminPassword' \ + || fail "acceptance: правило пароля снова несёт собственную копию границ" # Длина логина живёт ВНУТРИ правила. Два правила длины на одном поле уже # приводили к отказу, который невозможно объяснить оператору. ! code_has apps/model/dto/auth.go -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*credentialStr' \ || fail "acceptance: на логине снова стоит отдельная граница длины рядом с правилом" + log_step "Acceptance: the password contract knows the bcrypt byte limit" + # Границ у пароля ДВЕ и они в разных единицах. bcrypt читает 72 БАЙТА и + # отвечает ErrPasswordTooLong; 64 символа — это от 64 до 256 байт. Пока + # байтовой границы не было, пароль из 64 кириллических букв проходил + # оркестратор, DTO и панель, а падал в хешировании — системной ошибкой на + # штатной смене пароля и отсутствием администратора после установки. + code_has apps/credential/admin.go -F -- 'AdminPasswordMaxBytes = 72' \ + || fail "acceptance: предел bcrypt в байтах не объявлен в контракте" + code_has orchestrator/src/config/profile.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \ + || fail "acceptance: оркестратор снова не знает про предел bcrypt в байтах" + code_has apps/frontend/src/constants/credentials.ts -F -- 'ADMIN_PASSWORD_MAX_BYTES = 72' \ + || fail "acceptance: панель снова не знает про предел bcrypt в байтах" + + # Границы пароля живут ВНУТРИ правила: тег валидатора умеет считать только + # символы, а границу в байтах им не выразить вовсе. + local password_dto + for password_dto in apps/model/dto/auth.go apps/model/dto/admin_user.go; do + code_has "$password_dto" -F -- 'adminPassword' \ + || fail "acceptance: $password_dto больше не проверяет пароль общим правилом" + ! code_has "$password_dto" -E -- 'validate:"[^"]*(min|max)=[0-9]+[^"]*"' \ + || fail "acceptance: в $password_dto вернулась граница длины рядом с правилом пароля" + done + + log_step "Acceptance: the password is never trimmed on its way to bcrypt" + # Пробел по краям объявлен ЧАСТЬЮ пароля. Пока HashPassword триммил значение, + # пароль `abcde ` принимали все двери продукта и не мог захешировать никто; а + # пока bootstrap триммил HY2XS_ADMIN_INITIAL_PASSWORD, учётная запись + # создавалась не с тем паролем, который оператор записал в hy2xs.env. + code_has apps/util/encrypt.go -F -- 'credential.IsValidAdminPassword(password)' \ + || fail "acceptance: HashPassword снова проверяет пароль собственным правилом" + ! code_has apps/util/encrypt.go -F -- 'strings.TrimSpace(password)' \ + || fail "acceptance: в HashPassword вернулся TrimSpace — пароль с краевым пробелом снова нельзя захешировать" + ! code_has apps/dao/sqlite.go -F -- 'strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD"))' \ + || fail "acceptance: bootstrap снова обрезает пароль первого администратора" + code_has apps/dao/sqlite.go -F -- 'credential.IsValidAdminPassword(adminPassword)' \ + || fail "acceptance: bootstrap снова не проверяет пароль против контракта" + + log_step "Acceptance: hy2xs.env is a format, not a lucky interpolation" + # У файла ДВА читателя, и один из них — systemd (EnvironmentFile=). Пока + # значения писались интерполяцией, а читались split('=') с trim(), пароль с + # краевым пробелом, кавычкой или обратным слешем терялся ещё до запуска + # админки — при том, что продукт обещает неограниченный набор символов. + [ -f orchestrator/src/lib/envFile.ts ] \ + || fail "acceptance: модуль формата env-файлов отсутствует" + code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \ + || fail "acceptance: разбор hy2xs.env снова расходится с systemd" + code_has orchestrator/src/config/env.ts -F -- 'renderEnvFile(entries)' \ + || fail "acceptance: запись hy2xs.env снова идёт интерполяцией" + code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \ + || fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией" + # Экранируются ТОЛЬКО те символы, которые systemd разворачивает обратно. + code_has orchestrator/src/lib/envFile.ts -F -- 'SHELL_NEED_ESCAPE' \ + || fail "acceptance: кодировщик env потерял ссылку на правила systemd" + log_step "Acceptance: the panel is never stricter than the server" [ -f apps/frontend/src/constants/credentials.ts ] \ || fail "acceptance: контракт учётных данных панели отсутствует" @@ -821,6 +884,12 @@ run_admin_login_acceptance() { # серверным только потому, что обе стороны несли одну опечатку. ! code_has "$form" -F -- '_+-=' \ || fail "acceptance: $form вернул класс символов, записанный диапазоном по опечатке" + # Встроенные min/max Element Plus делегируются async-validator, а он считает + # длину строки в единицах UTF-16: пароль из трёх эмодзи имел length 6, + # проходил минимум формы и получал отказ сервера, который панель не могла + # объяснить. Правило пароля обязано быть общим и считать code points. + code_has "$form" -F -- 'adminPasswordFormRule(' \ + || fail "acceptance: $form снова проверяет пароль встроенными min/max async-validator" done log_step "Acceptance: install proves that the panel lets an operator in" @@ -849,6 +918,25 @@ run_admin_login_acceptance() { ! grep -q 'runReadOnly`curl' <<<"$login_body" \ || fail "acceptance: запрос входа выполняется раннером, кладущим команду в текст ошибки" + log_step "Acceptance: the negative login probe proves a rejection" + # Проверка `code.trim() === "200"` доказывала ровно одно: что запрос не + # закончился пятисоткой. Админка отвечает HTTP 200 и на успешный вход тоже, + # поэтому «неверные учётные данные отвергнуты» из неё не следовало. + code_has orchestrator/src/steps/smoke.ts -F -- 'isRejectedLogin' \ + || fail "acceptance: отрицательная проба снова определяет отказ по коду HTTP" + code_has orchestrator/src/steps/smoke.ts -F -- 'INVALID_CREDENTIALS_CODE = "invalid_credentials"' \ + || fail "acceptance: отрицательная проба не сверяет доменный код отказа" + # Литерал в исходнике теоретически может оказаться настоящим паролем — тогда + # проверка «данные отвергнуты» отчиталась бы об успешном ВХОДЕ. + ! code_has orchestrator/src/steps/smoke.ts -F -- 'definitely-not-the-admin-password' \ + || fail "acceptance: пароль отрицательной пробы снова записан литералом" + # Bootstrap-секрет читается парсером формата: `cut -d= -f2-` с `trim()` был + # третьим слоем, срезающим краевые пробелы у пароля. + ! code_has orchestrator/src/steps/smoke.ts -F -- 'cut -d= -f2-' \ + || fail "acceptance: bootstrap-секрет снова извлекается шелл-конвейером" + code_has orchestrator/src/steps/smoke.ts -F -- 'readBootstrapAdminSecret' \ + || fail "acceptance: bootstrap-секрет читается не парсером собственного формата" + log_step "Acceptance: the orchestrator validates what it generates" code_has orchestrator/src/config/env.ts -F -- 'assertValidAdminUsername(' \ || fail "acceptance: HY2XS_ADMIN_USER снова не проверяется против контракта панели" diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 36a8e8c..10def8a 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -8,12 +8,14 @@ import { FLAMY_URL, } from "../../apps/frontend/src/constants/branding"; import { + ADMIN_PASSWORD_MAX_BYTES, ADMIN_PASSWORD_MAX_LENGTH, ADMIN_PASSWORD_MIN_LENGTH, ADMIN_USERNAME_CHARSET, ADMIN_USERNAME_MAX_LENGTH, ADMIN_USERNAME_MIN_LENGTH, ADMIN_USERNAME_PATTERN, + isValidAdminPassword, } from "../../apps/frontend/src/constants/credentials"; import ru from "../../apps/frontend/src/lang/package/ru"; import en from "../../apps/frontend/src/lang/package/en"; @@ -689,7 +691,7 @@ describe("правдивая диагностика", () => { describe("контракт учётных данных администратора", () => { const goContract = () => fs.readFileSync( - path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"), + path.join(REPO_ROOT, "apps", "credential", "admin.go"), "utf8" ); @@ -703,7 +705,7 @@ describe("контракт учётных данных администрато if (backticked) { return backticked[1]; } - throw new Error(`константа ${name} не найдена в admin_credentials.go`); + throw new Error(`константа ${name} не найдена в apps/credential/admin.go`); } const loginForm = () => readSource("views", "login", "index.vue"); @@ -718,6 +720,12 @@ describe("контракт учётных данных администрато expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength")); expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength")); expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength")); + // Предел bcrypt — отдельная константа и отдельная сверка. Пока панель знала + // только границу в символах, она предлагала оператору назначить пароль из + // 64 кириллических букв (128 байт), сервер его принимал всеми проверками, а + // хеширование отказывало — и оператор получал системную ошибку на штатной + // смене пароля. + expect(String(ADMIN_PASSWORD_MAX_BYTES)).toBe(goConst("AdminPasswordMaxBytes")); expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset")); const declared = goConst("adminUsernameCharacterClass"); @@ -756,20 +764,108 @@ describe("контракт учётных данных администрато // серверном пределе в 64, а форма смены пароля назначала до 64. Пароль, // назначенный штатной операцией, становился паролем, с которым невозможно // войти. - test("границы пароля на обеих формах — одни и те же", () => { + test("правило пароля на обеих формах — одно и то же", () => { for (const form of [loginForm(), changePasswordForm()]) { const code = vueCodeOf(form); - expect(code).toContain("min: ADMIN_PASSWORD_MIN_LENGTH"); - expect(code).toContain("max: ADMIN_PASSWORD_MAX_LENGTH"); + expect(code).toContain("adminPasswordFormRule("); + expect(code).toContain("credentials.passwordFormat"); } }); + // Регрессия RC2-UI-04. Element Plus делегирует правила формы библиотеке + // `async-validator`, а она сравнивает `min`/`max` строки с + // `String.prototype.length`, то есть считает единицы UTF-16. Сервер считает + // code points. Пароль из трёх эмодзи имел `value.length === 6`, проходил + // минимум формы и получал отказ сервера, который панель не могла объяснить. + // + // Встроенных границ на пароле поэтому быть не должно вовсе: длина + // принадлежит правилу, а не соседям по объекту. + test("на пароле нет встроенных min/max от async-validator", async () => { + const { adminPasswordFormRule } = await import( + "../../apps/frontend/src/constants/credentials" + ); + + // Правило одно на обе формы, и границ как СВОЙСТВ правила у него нет: длина + // живёт внутри валидатора, где её можно посчитать правильно. + const rule = adminPasswordFormRule("format") as Record; + expect(typeof rule.validator).toBe("function"); + expect("min" in rule).toBe(false); + expect("max" in rule).toBe(false); + + // И ни одна форма не заводит собственных границ рядом с ним. Числа в + // сообщении не считаются: фраза обязана НАЗЫВАТЬ требование, поэтому они + // подставляются в t() и вырезаются здесь перед проверкой. + for (const form of [loginForm(), changePasswordForm()]) { + const withoutMessages = vueCodeOf(form).replace( + /t\("credentials\.passwordFormat",[\s\S]*?\n\s*\}\)/g, + 't("credentials.passwordFormat")' + ); + expect(withoutMessages).not.toContain("min: ADMIN_PASSWORD_MIN_LENGTH"); + expect(withoutMessages).not.toContain("max: ADMIN_PASSWORD_MAX_LENGTH"); + } + }); + + // Правило панели обязано отвечать так же, как Go-контракт, на тех значениях, + // где расходятся способы счёта длины. + test("правило пароля считает code points и байты, а не UTF-16", () => { + // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, случаи + // ниже проверяли бы не то, что написано. + expect(new TextEncoder().encode("я".repeat(36)).length).toBe(ADMIN_PASSWORD_MAX_BYTES); + expect(new TextEncoder().encode("😀".repeat(18)).length).toBe(ADMIN_PASSWORD_MAX_BYTES); + + for (const accepted of [ + "a".repeat(ADMIN_PASSWORD_MIN_LENGTH), + "a".repeat(ADMIN_PASSWORD_MAX_LENGTH), + "я".repeat(36), + "😀".repeat(18), + "abcde ", + " ".repeat(ADMIN_PASSWORD_MIN_LENGTH), + ]) { + expect(isValidAdminPassword(accepted)).toBe(true); + } + + for (const rejected of [ + "a".repeat(ADMIN_PASSWORD_MIN_LENGTH - 1), + "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1), + "я".repeat(37), + "😀".repeat(19), + "я".repeat(ADMIN_PASSWORD_MAX_LENGTH), + "abcde\n", + "abcde\t", + ]) { + expect(isValidAdminPassword(rejected)).toBe(false); + } + + // Именно тот случай, ради которого встроенные min/max убраны: три эмодзи — + // это 3 code points и 6 единиц UTF-16. + expect("😀😀😀".length).toBe(6); + expect(isValidAdminPassword("😀😀😀")).toBe(false); + }); + + // Пустое значение — забота правила `required`, а не правила формата: иначе + // оператор, отправивший пустую форму, читает про границы длины вместо «поле + // обязательно». Сервер ведёт себя так же (DTO приводит пустой пароль к «не + // задано» до проверки правил). + test("пустой пароль остаётся на правиле required", async () => { + const { adminPasswordFormRule } = await import( + "../../apps/frontend/src/constants/credentials" + ); + const rule = adminPasswordFormRule("format"); + + const errors: (Error | undefined)[] = []; + rule.validator(null, "", (error?: Error) => errors.push(error)); + rule.validator(null, "abc", (error?: Error) => errors.push(error)); + + expect(errors[0]).toBeUndefined(); + expect(errors[1]).toBeInstanceOf(Error); + }); + // Набор символов пароля сервером не проверяется НИГДЕ, поэтому его проверка в // панели умеет только отвергнуть пароль, который сервер принял бы. test("на пароль не навешен набор символов", () => { for (const form of [loginForm(), changePasswordForm()]) { const code = vueCodeOf(form); - expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordLength\""); + expect(code).not.toContain("ADMIN_USERNAME_PATTERN,\n message: t(\"credentials.passwordFormat\""); expect(code).not.toMatch(/passwordPattern/); } // Правило логина — единственное место, где вообще упоминается набор. @@ -782,13 +878,17 @@ describe("контракт учётных данных администрато for (const dictionary of [ru, en] as const) { const credentials = (dictionary as any).credentials ?? {}; expect(typeof credentials.usernameFormat).toBe("string"); - expect(typeof credentials.passwordLength).toBe("string"); + expect(typeof credentials.passwordFormat).toBe("string"); for (const placeholder of ["{min}", "{max}", "{charset}"]) { expect(credentials.usernameFormat).toContain(placeholder); } - for (const placeholder of ["{min}", "{max}"]) { - expect(credentials.passwordLength).toContain(placeholder); + // Границ у пароля ДВЕ, и они в разных единицах: пароль из 40 эмодзи + // укладывается в 64 символа и не укладывается в 72 байта. Фраза «не + // длиннее {max} символов» отправила бы оператора сокращать пароль, + // отвергнутый не за это. + for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) { + expect(credentials.passwordFormat).toContain(placeholder); } // Причина отказа от сервера несёт те же params и обязана их использовать. @@ -796,6 +896,10 @@ describe("контракт учётных данных администрато for (const placeholder of ["{min}", "{max}", "{charset}"]) { expect(reason).toContain(placeholder); } + const passwordReason = (dictionary as any).error?.code?.admin_password_format ?? ""; + for (const placeholder of ["{min}", "{max}", "{maxBytes}"]) { + expect(passwordReason).toContain(placeholder); + } } }); });