Files
HY2XS_flamy/apps/dao/bootstrap_admin_test.go
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 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 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

105 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 байта не проходит проверку собственным хешем")
}
}