diff --git a/CHANGELOG.md b/CHANGELOG.md index 853e99a..34de030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ ## [Unreleased] +Очередной hardening-проход закрывает границу кодировок. Оркестратор теперь +читает конфигурацию и секреты как байты с fatal UTF-8 decoding, сохраняет BOM +как U+FEFF до явного транспортного отказа и проверяет исходный EnvironmentFile +до разбора ключей. HTTP API до `encoding/json` отвергает повреждённый UTF-8 и +непарные UTF-16 surrogate escape, поэтому вход или импорт больше не могут +молча превратиться в другое значение через U+FFFD. Diagnostics сначала +санитизирует данные в памяти и никогда не кладёт сырые env/YAML/journal во +временный каталог. U+FEFF во всех слоях отнесён к документированному домену +systemd, а управляющие `Cc` — к политике HY2XS. + Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику «только чистая установка» настоящим системным инвариантом, а не строчкой в документации. diff --git a/README.md b/README.md index aefd123..a029b6a 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 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); набор символов не ограничен, кроме управляющих; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__GENERATE__` генерируется при install | `__GENERATE__` | +| `HY2XS_ADMIN_INITIAL_PASSWORD` | Bootstrap‑пароль администратора: 6-64 символа Unicode **и** не более 72 байт в UTF‑8 (предел bcrypt); валидный UTF‑8 в документированном домене `EnvironmentFile=` (в частности, без U+FEFF), без управляющих символов; пробелы по краям — часть пароля, поэтому такое значение записывается в двойных кавычках; `__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` | @@ -766,6 +766,11 @@ hy2xs-orchestrator diagnostics collect \ --config /etc/hy2xs/hy2xs.env ``` +Бандл не содержит сырых промежуточных копий конфигурации или журналов: +редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не +декодируется с заменой и не попадает в архив; вместо него записывается +безопасная причина пропуска. + ## Проверка безопасности после установки Минимальный набор проверок: diff --git a/apps/controller/auth_test.go b/apps/controller/auth_test.go index de7a788..23b3847 100644 --- a/apps/controller/auth_test.go +++ b/apps/controller/auth_test.go @@ -491,6 +491,46 @@ func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) { } } +// Повреждённые байты и непарные surrogate escape не имеют права превратиться +// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом. +func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) { + password := "abcde" + string(rune(0xFFFD)) + id := newAuthTestDB(t, "hy2xsadmin", password) + + for _, body := range [][]byte{ + []byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'}, + []byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`), + []byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`), + } { + status, result := postLoginRaw(t, body, false) + if status != http.StatusOK { + t.Fatalf("повреждённое тело дало HTTP %d", status) + } + if result.Code != constant.CodeInvalidError { + t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result) + } + var found bool + for _, reason := range result.Errors { + found = found || reason.Code == constant.ErrCodeBodyInvalid + } + if !found { + t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result) + } + } + admin, err := dao.GetAdminUser("id = ?", id) + if err != nil { + t.Fatalf("не удалось проверить состояние администратора: %v", err) + } + if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 { + t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt) + } + + _, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password}) + if valid.Code != constant.CodeSuccess { + t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid) + } +} + // Тот же запрос ЗА gin.Recovery: именно так он шёл в бою. // // В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index a47184a..94e807e 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -11,7 +11,7 @@ import ( func Hysteria2Auth(c *gin.Context) { var req dto.Hysteria2AuthDto - if err := c.ShouldBindJSON(&req); err != nil { + if err := strictBindJSON(c, &req); err != nil { vo.Hysteria2AuthBadRequest(c) return } diff --git a/apps/controller/json_body.go b/apps/controller/json_body.go new file mode 100644 index 0000000..a937996 --- /dev/null +++ b/apps/controller/json_body.go @@ -0,0 +1,113 @@ +package controller + +import ( + "bytes" + "errors" + "fmt" + "io" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +const jsonBodyMaxBytes = 1024 * 1024 + +var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ") + +// strictBindJSON не позволяет стандартному encoding/json молча заменить +// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD. +// +// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой +// границы другое байтовое значение могло аутентифицироваться как пароль, +// который фактически хранится в базе. После проверки тело восстанавливается и +// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт. +func strictBindJSON(c *gin.Context, dst any) error { + body := c.Request.Body + if body == nil { + return errors.New("пустое тело JSON") + } + raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1)) + _ = body.Close() + c.Request.Body = io.NopCloser(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("не удалось прочитать тело JSON: %w", err) + } + if len(raw) > jsonBodyMaxBytes { + return errJSONBodyTooLarge + } + if err := validateStrictJSONEncoding(raw); err != nil { + return err + } + return c.ShouldBindJSON(dst) +} + +// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис, +// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается +// его документированное replacement-поведение. +func validateStrictJSONEncoding(raw []byte) error { + if !utf8.Valid(raw) { + return errors.New("тело JSON содержит некорректный UTF-8") + } + + inString := false + for i := 0; i < len(raw); i++ { + switch raw[i] { + case '"': + inString = !inString + case '\\': + if !inString { + continue + } + i++ + if i >= len(raw) { + return errors.New("незавершённая escape-последовательность JSON") + } + if raw[i] != 'u' { + continue + } + + code, ok := parseJSONHex4(raw, i+1) + if !ok { + return errors.New("некорректная Unicode escape-последовательность JSON") + } + i += 4 + switch { + case code >= 0xD800 && code <= 0xDBFF: + // Старший суррогат допустим только как первая половина пары и + // только в соседней escape-последовательности \uXXXX. + if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' { + return errors.New("непарный старший UTF-16 суррогат в JSON") + } + low, lowOK := parseJSONHex4(raw, i+3) + if !lowOK || low < 0xDC00 || low > 0xDFFF { + return errors.New("непарный старший UTF-16 суррогат в JSON") + } + i += 6 + case code >= 0xDC00 && code <= 0xDFFF: + return errors.New("непарный младший UTF-16 суррогат в JSON") + } + } + } + return nil +} + +func parseJSONHex4(raw []byte, start int) (uint16, bool) { + if start < 0 || start+4 > len(raw) { + return 0, false + } + var value uint16 + for _, character := range raw[start : start+4] { + value <<= 4 + switch { + case character >= '0' && character <= '9': + value += uint16(character - '0') + case character >= 'a' && character <= 'f': + value += uint16(character-'a') + 10 + case character >= 'A' && character <= 'F': + value += uint16(character-'A') + 10 + default: + return 0, false + } + } + return value, true +} diff --git a/apps/controller/json_body_test.go b/apps/controller/json_body_test.go new file mode 100644 index 0000000..6b256f2 --- /dev/null +++ b/apps/controller/json_body_test.go @@ -0,0 +1,31 @@ +package controller + +import "testing" + +func TestValidateStrictJSONEncoding(t *testing.T) { + tests := []struct { + name string + body []byte + wantErr bool + }{ + {"обычный UTF-8", []byte(`{"password":"пароль"}`), false}, + {"настоящий U+FFFD", []byte(`{"password":"abcde�"}`), false}, + {"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false}, + {"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false}, + {"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true}, + {"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true}, + {"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true}, + {"непарный старший", []byte(`{"value":"\uD800"}`), true}, + {"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true}, + {"непарный младший", []byte(`{"value":"\uDC00"}`), true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotErr := validateStrictJSONEncoding(test.body) != nil + if gotErr != test.wantErr { + t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr) + } + }) + } +} diff --git a/apps/controller/peer.go b/apps/controller/peer.go index a8d06fa..3c8fe99 100644 --- a/apps/controller/peer.go +++ b/apps/controller/peer.go @@ -189,6 +189,10 @@ func ImportPeer(c *gin.Context) { vo.Fail("the file is too big", c) return } + if err = validateStrictJSONEncoding(content); err != nil { + vo.Fail("content contains invalid UTF-8 or UTF-16", c) + return + } var peerExports []bo.PeerExport decoder := json.NewDecoder(bytes.NewReader(content)) diff --git a/apps/controller/peer_test.go b/apps/controller/peer_test.go index 445ee0d..cf28646 100644 --- a/apps/controller/peer_test.go +++ b/apps/controller/peer_test.go @@ -17,7 +17,7 @@ import ( // Разбор загруженного файла проверяется без базы: все три отказа наступают до // первого обращения к слою данных, и это часть контракта — испорченный файл не // должен доходить до транзакции. -func postImportFile(t *testing.T, fileName string, content string) apiResult { +func postImportBytes(t *testing.T, fileName string, content []byte) apiResult { t.Helper() gin.SetMode(gin.TestMode) @@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult { if err != nil { t.Fatalf("не удалось собрать форму: %v", err) } - if _, err := part.Write([]byte(content)); err != nil { + if _, err := part.Write(content); err != nil { t.Fatalf("не удалось записать файл в форму: %v", err) } if err := writer.Close(); err != nil { @@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult { return result } +func postImportFile(t *testing.T, fileName string, content string) apiResult { + t.Helper() + return postImportBytes(t, fileName, []byte(content)) +} + +func TestImportPeerRejectsInvalidUTF8(t *testing.T) { + for _, content := range [][]byte{ + {'[', '"', 0xFF, '"', ']'}, + {'[', '"', 0xC3, '"', ']'}, + {'[', '"', 0xED, 0xA0, 0x80, '"', ']'}, + } { + result := postImportBytes(t, "peers.json", content) + if result.Type != "no" { + t.Fatalf("повреждённый UTF-8 принят: %+v", result) + } + } +} + // Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с // хвостом принимался целиком, оператор видел «импорт выполнен», а вторая // половина файла молча не применялась. diff --git a/apps/controller/validator.go b/apps/controller/validator.go index acde6b6..0ee0134 100644 --- a/apps/controller/validator.go +++ b/apps/controller/validator.go @@ -109,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) { case http.MethodGet: bindErr = c.ShouldBindQuery(&field) case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: - bindErr = c.ShouldBindJSON(&field) + bindErr = strictBindJSON(c, &field) } if bindErr != nil { vo.FailValidation( @@ -246,7 +246,7 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError { "maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes), } described.Message = fmt.Sprintf( - "поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов", + "поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF", field, credential.AdminPasswordMinLength, credential.AdminPasswordMaxLength, diff --git a/apps/credential/admin.go b/apps/credential/admin.go index 62822b2..497821b 100644 --- a/apps/credential/admin.go +++ b/apps/credential/admin.go @@ -131,10 +131,9 @@ func IsValidAdminUsername(username string) bool { // же класс дефекта, ради уничтожения которого контракт и существует, только на // один слой ниже. // -// U+FEFF в этот список НЕ входит: `0xFEFF & 0xFFFE` — это `0xFEFE`, и systemd -// такое значение принимает (комментарий `/* BOM */` в его исходнике относится к -// U+xFFFE и является неточностью самого systemd). Запрет U+FEFF — отдельное -// продуктовое решение, см. hasForbiddenRune. +// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13 +// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует +// документированному контракту, а не этому расхождению реализации. func isUnicodeNoncharacter(r rune) bool { if r >= 0xFDD0 && r <= 0xFDEF { return true @@ -144,8 +143,7 @@ func isUnicodeNoncharacter(r rune) bool { // IsEnvTransportableText сообщает, переживёт ли значение файл окружения. // -// Это ДОМЕН systemd, а не политика HY2XS: здесь перечислено ровно то, что -// `utf8_is_valid` отвергает, и ничего сверх того. Продуктовые ограничения +// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения // (границы длины, запрет управляющих символов) живут отдельно — иначе // невозможно объяснить, какое из двух правил нарушено и чем это грозит. func IsEnvTransportableText(value string) bool { @@ -159,7 +157,7 @@ func IsEnvTransportableText(value string) bool { for _, r := range value { // NUL — валидный UTF-8, но границей строки в C; systemd отвергает его // отдельной веткой (`embedded NUL`). - if r == 0 { + if r == 0 || r == 0xFEFF { return false } if isUnicodeNoncharacter(r) { @@ -178,7 +176,7 @@ func IsEnvTransportableText(value string) bool { // (IsEnvTransportableText); // 6-64 code points — граница, которую видит оператор; // <= 72 UTF-8 bytes — граница, которую ставит bcrypt; -// Cc и U+FEFF — продуктовая политика, см. hasForbiddenRune. +// Cc — продуктовая политика, см. hasForbiddenRune. // // Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает // go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её @@ -226,22 +224,13 @@ func IsValidAdminPassword(password string) bool { // символов» — то есть документация была шире кода ровно на C1. Проверяется // теперь то, что обещано. // -// И отдельно U+FEFF. Формат его несёт: `0xFEFF & 0xFFFE` — это `0xFEFE`, и -// `unichar_is_valid` такое значение ПРИНИМАЕТ (комментарий `/* BOM */` в -// исходнике systemd относится к U+xFFFE и является его собственной -// неточностью). Запрет здесь — решение продукта: невидимый символ нулевой -// ширины в пароле нельзя ни увидеть в поле ввода, ни повторить при следующем -// входе, то есть он умеет ровно одно — запереть оператора снаружи. Ровно тем же -// обоснованием запрещены и управляющие символы: перевод строки невозможно -// ввести в однострочное поле формы входа. +// U+FEFF здесь уже не проверяется: он принадлежит документированному домену +// EnvironmentFile и отвергается IsEnvTransportableText. func hasForbiddenRune(value string) bool { for _, r := range value { if r < 0x20 || (r >= 0x7F && r <= 0x9F) { return true } - if r == 0xFEFF { - return true - } } return false } diff --git a/apps/credential/admin_test.go b/apps/credential/admin_test.go index f6f5755..5e62dee 100644 --- a/apps/credential/admin_test.go +++ b/apps/credential/admin_test.go @@ -146,8 +146,7 @@ func TestAdminPasswordBoundaries(t *testing.T) { {"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true}, {"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true}, - // U+FEFF формат несёт (0xFEFF & 0xFFFE == 0xFEFE), и отвергается он - // продуктовой политикой, а не systemd. + // U+FEFF отвергается документированным транспортным доменом systemd. {"U+FEFF", "abcde" + string(rune(0xFEFF)), false}, } @@ -187,18 +186,18 @@ func TestAdminPasswordLimitsAreIndependent(t *testing.T) { } } -// Домен транспорта повторяет множество systemd ТОЧНО — не шире и не уже. +// Домен транспорта повторяет публично документированное множество systemd. // -// Шире означало бы, что HY2XS отвергает значение, которое systemd загрузил бы; -// уже — что значение проходит все двери продукта и делает файл окружения -// незагружаемым, а юнит — не стартующим. Второе и было дефектом: контракт знал -// про управляющие символы и не знал про noncharacters. +// Шире означало бы собственный запрет сверх публичного контракта; уже — что +// значение проходит все двери продукта вопреки документации EnvironmentFile. +// Второе и было дефектом: контракт знал про управляющие символы и не знал про +// noncharacters. // // Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются // вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность. -func TestEnvTransportDomainMatchesSystemd(t *testing.T) { +func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) { rejected := []rune{ - 0x0000, // embedded NUL — отдельная ветка utf8_is_valid + 0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом 0xFDD0, 0xFDEF, // U+FDD0..U+FDEF 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, @@ -206,21 +205,20 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) { } for _, r := range rejected { if IsEnvTransportableText(string(r)) { - t.Errorf("U+%04X принят: systemd откажется загружать такой EnvironmentFile", r) + t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r) } } accepted := []rune{ 0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта 0xFDCF, 0xFDF0, // соседи зарезервированного диапазона - 0xFEFF, // 0xFEFF & 0xFFFE == 0xFEFE — systemd принимает 0xFFFD, // replacement character — обычный символ 0x10FFFD, // последний scalar value, не noncharacter 0x1F600, // 😀 } for _, r := range accepted { if !IsEnvTransportableText(string(r)) { - t.Errorf("U+%04X отвергнут: правило шире множества systemd", r) + t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r) } } @@ -239,7 +237,7 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) { // одно из двух правил отсутствует. func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) { // Управляющие символы формат несёт — их запрещает HY2XS. - for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F, 0xFEFF} { + for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} { password := "abcde" + string(r) if !IsEnvTransportableText(password) && r != 0x00 { t.Errorf("U+%04X: ожидалось, что формат его несёт", r) @@ -248,6 +246,9 @@ func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) { t.Errorf("U+%04X принят паролем вопреки политике продукта", r) } } + if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) { + t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile") + } } // Пароль НЕ триммится нигде, включая сам контракт. diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index 5b4817b..5ad9ddd 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -163,7 +163,7 @@ func ensureSecureBootstrapAdmin() error { if !credential.IsValidAdminPassword(adminPassword) { return fmt.Errorf( "HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+ - "от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+ + "от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+ "Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+ "Учётная запись администратора с таким значением создана НЕ будет: "+ "установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+ diff --git a/apps/frontend/src/constants/credentials.ts b/apps/frontend/src/constants/credentials.ts index 65e286a..5d0f5b9 100644 --- a/apps/frontend/src/constants/credentials.ts +++ b/apps/frontend/src/constants/credentials.ts @@ -82,8 +82,9 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72; * `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и * перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там — * это не предупреждение, а незагруженный файл окружения и, следовательно, - * юнит, который не стартует. Отвергаются NUL, суррогаты, `U+FDD0..U+FDEF` и все - * code points вида `*FFFE`/`*FFFF`. Панель обязана знать это множество: иначе + * юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF, + * суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация + * v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе * она предложила бы оператору назначить пароль, после которого сервер * перестанет подниматься. * @@ -93,7 +94,7 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72; * не отказ. * * Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0, - * DEL, C1) и U+FEFF. Формат их несёт, запрещает их HY2XS: ни один из них + * DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них * невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть * они умеют ровно одно — запереть оператора снаружи. * @@ -109,6 +110,7 @@ export function isValidAdminPassword(value: string): boolean { // Домен systemd: значение вне него не доедет до сервера вовсе. if ( code === 0 || + code === 0xfeff || (code >= 0xd800 && code <= 0xdfff) || (code >= 0xfdd0 && code <= 0xfdef) || (code & 0xfffe) === 0xfffe @@ -116,8 +118,8 @@ export function isValidAdminPassword(value: string): boolean { return false; } - // Политика HY2XS: Cc и U+FEFF. - if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) { + // Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой. + if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) { return false; } diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 4bea818..0e87f58 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -27,7 +27,7 @@ export default { credentials: { usernameFormat: "Username: {min} to {max} characters from {charset}", passwordFormat: - "Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters", + "Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF", }, dashboard: { stale: "Dashboard data is stale. Retrying automatically...", @@ -168,7 +168,7 @@ export default { "“{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", + "“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF", 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 66349b4..d3883c2 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -36,7 +36,7 @@ export default { // байта; сообщение «не длиннее {max} символов» отправило бы оператора // сокращать пароль, отвергнутый не за это. passwordFormat: - "Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", + "Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF", }, dashboard: { stale: @@ -189,7 +189,7 @@ export default { credential_format: "«{field}»: от {min} до {max} символов из набора {charset}", admin_password_format: - "«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", + "«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF", rule_violated: "«{field}»: значение не подходит", validation_failed: "Проверка данных не пройдена", body_invalid: "Запрос не разобран: проверьте формат и типы полей", diff --git a/apps/util/encrypt.go b/apps/util/encrypt.go index ff92260..351f944 100644 --- a/apps/util/encrypt.go +++ b/apps/util/encrypt.go @@ -59,7 +59,7 @@ func HashPassword(password string) (string, error) { if !credential.IsValidAdminPassword(password) { return "", fmt.Errorf( "пароль администратора не удовлетворяет контракту: от %d до %d символов, "+ - "не более %d байт в UTF-8, без управляющих символов", + "не более %d байт в UTF-8, без управляющих символов и U+FEFF", credential.AdminPasswordMinLength, credential.AdminPasswordMaxLength, credential.AdminPasswordMaxBytes, diff --git a/docs/admin/15-ui-contracts.md b/docs/admin/15-ui-contracts.md index 8ee51df..0000bf9 100644 --- a/docs/admin/15-ui-contracts.md +++ b/docs/admin/15-ui-contracts.md @@ -278,8 +278,8 @@ control plane показывал всех пиров офлайн. Теперь | Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` | | Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | | Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` | -| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` | -| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` | +| Домен пароля | документированный домен systemd `EnvironmentFile=`: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | `IsEnvTransportableText` | +| Набор символов пароля | не ограничен, кроме `Cc` | `hasForbiddenRune` | | Пробелы по краям пароля | часть значения, не снимаются | — | Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не @@ -323,7 +323,11 @@ U+FDD0..U+FDEF noncharacters (cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF ``` -плюс встроенный NUL и любую невалидную последовательность UTF-8. +плюс встроенный NUL, U+FEFF и любую невалидную последовательность UTF-8. + +U+FEFF запрещён публичной документацией EnvironmentFile. Реализация systemd +v257.13 случайно пропускает его (`0xFEFF & 0xFFFE == 0xFEFE`); HY2XS следует +документированному контракту, а не закрепляет ошибку конкретной версии. Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, @@ -335,13 +339,17 @@ U+FDD0..U+FDEF noncharacters суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое изменение пароля по дороге в файл. -**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие +**Политика HY2XS — наше решение.** Сверх транспортного домена запрещены управляющие символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`, -`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`, -и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в +`U+0080..U+009F`). Их невозможно ни увидеть в поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть оператора снаружи. +На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON +байты должны быть валидным UTF-8, а `\uXXXX` — не содержать непарных UTF-16 +суррогатов. Это делается до `encoding/json`, который иначе молча заменил бы оба +дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение. + ### Почему границ у пароля две Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя. diff --git a/docs/operations/12-operations-and-troubleshooting.md b/docs/operations/12-operations-and-troubleshooting.md index 9bea398..e05c1e4 100644 --- a/docs/operations/12-operations-and-troubleshooting.md +++ b/docs/operations/12-operations-and-troubleshooting.md @@ -553,7 +553,8 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret | --- | --- | | 6-64 символа Unicode | форма входа и форма смены пароля | | не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) | -| без управляющих символов | формат `KEY=VALUE`, который читает systemd | +| валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | документированный домен systemd `EnvironmentFile=` | +| без управляющих символов Unicode (`Cc`) | политика HY2XS: значение должно воспроизводиться в однострочной форме входа | Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64 кириллических букв — это 128 байт, и он отвергается, хотя в границу символов diff --git a/docs/runtime/08-orchestrator-spec.md b/docs/runtime/08-orchestrator-spec.md index 00fcac2..395bc45 100644 --- a/docs/runtime/08-orchestrator-spec.md +++ b/docs/runtime/08-orchestrator-spec.md @@ -597,10 +597,12 @@ production-профилем, а не ищет подстроки. Проверя (`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам -`utf8_is_valid` — встроенный NUL и невалидный UTF-8. +`utf8_is_valid` — встроенный NUL и невалидный UTF-8. Публичная документация +EnvironmentFile дополнительно запрещает U+FEFF. Реализация v257.13 случайно +пропускает его из-за маски; HY2XS следует документированному контракту. -`isEnvTransportable` в `lib/envFile.ts` повторяет ровно это множество и ничего -сверх него. Управляющие символы формат несёт — внутри двойных кавычек перевод +`isEnvTransportable` в `lib/envFile.ts` повторяет документированное множество. +Управляющие символы формат несёт — внутри двойных кавычек перевод строки накапливается как обычный байт и переживает round-trip, — и запрещает их контракт учётных данных, а не транспорт. Приписывать формату чужие запреты нельзя: именно так проверка и пропустила noncharacters, о которых ничего не @@ -610,6 +612,13 @@ production-профилем, а не ищет подстроки. Проверя вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на `U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ. +Сам файл читается только как байты и декодируется через +`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный +`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты +на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до +транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей: +запрещённый символ не может спрятаться в комментарии или неизвестной переменной. + ### Непригодная конфигурация отвергается до первой мутации `validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи @@ -681,6 +690,13 @@ production-профилем, а не ищет подстроки. Проверя `redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML разбирается и обходится как дерево. +Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl +сразу в staging. Сначала данные читаются или захватываются в память, проходят +редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в +конфигурационном файле даёт безопасный маркер пропуска без исходных байтов. +Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно +помечается; архив создаётся сразу под `umask 077`. + Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в заголовок mapping'а и оставляло нетронутым вложенный `auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token diff --git a/docs/runtime/09-post-install-env.md b/docs/runtime/09-post-install-env.md index 1257f0d..23de2d8 100644 --- a/docs/runtime/09-post-install-env.md +++ b/docs/runtime/09-post-install-env.md @@ -153,7 +153,7 @@ anycast. Отсутствие A-записи фатально при любом | Переменная | Требование | Значение по умолчанию | | --- | --- | --- | | `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` | -| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; значение, загружаемое systemd из `EnvironmentFile=`; набор не ограничен, кроме `Cc` и `U+FEFF` | генерируется | +| `HY2XS_ADMIN_INITIAL_PASSWORD` | 6-64 символа Unicode **и** не более 72 байт в UTF-8; документированный транспортный домен systemd, включая запрет U+FEFF; набор не ограничен, кроме `Cc` | генерируется | Значение вне контракта **роняет установку** с явным текстом, называющим границы и набор. Так и должно быть: отказ, пришедший установщику, чинится одной строкой @@ -193,7 +193,7 @@ HY2XS_ADMIN_INITIAL_PASSWORD="пароль с пробелом на конце " Есть два ограничения набора символов, и они разного происхождения. **Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`: -валидный UTF-8 из Unicode scalar values, без NUL, без суррогатов и без +валидный UTF-8 из Unicode scalar values, без NUL, U+FEFF, суррогатов и noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило — systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет @@ -201,9 +201,13 @@ systemd прогоняет значение через `utf8_is_valid` и отв или obfs-пароль сломали бы загрузку юнита ровно так же. **Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode -(категория `Cc`) и `U+FEFF`. Формат их несёт, но ввести такой пароль в +(категория `Cc`). Формат их несёт, но ввести такой пароль в однострочное поле формы входа всё равно нельзя. +Ограничение U+FEFF берётся из публичной документации systemd. В реализации +v257.13 он случайно проходит через `unichar_is_valid` из-за маски; HY2XS не +делает эту ошибку версии частью собственного контракта. + Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до первой необратимой операции над хостом: `preflight-install` и `install` видят его одинаково. diff --git a/docs/testing/11-2-builder-layer.md b/docs/testing/11-2-builder-layer.md index 02047ed..56b733c 100644 --- a/docs/testing/11-2-builder-layer.md +++ b/docs/testing/11-2-builder-layer.md @@ -117,10 +117,10 @@ | Вход | Ожидание | | --- | --- | -| NUL, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: `utf8_is_valid` отвергает такое значение, и EnvironmentFile не загрузится | +| NUL, `U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF` | отказ ЗАПИСИ: публичный контракт EnvironmentFile запрещает такое значение | | одиночный суррогат `U+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет | | `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность | -| `\n`, `\r`, `\t`, `U+007F`, `U+0085`, `U+FEFF` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт | +| `\n`, `\r`, `\t`, `U+007F`, `U+0085` | формат их НЕСЁТ и round-trip сохраняет; запрещает их контракт учётных данных, а не транспорт | ### A4b. Непригодная конфигурация отвергается до первой мутации @@ -591,18 +591,27 @@ wildcard-маршрутом фронтенда или дублирующая р - управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`, `DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а документация обещала «без управляющих символов» — то есть была шире кода; -- значения вне домена systemd (`U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, +- значения вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FDEF`, `U+FFFE`, `U+FFFF`, `U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними `/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует; - соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются: правило описывает множество systemd, а не окрестность подозрительных значений; -- `U+FEFF` отвергается **политикой продукта**, а не форматом: - `0xFEFF & 0xFFFE == 0xFEFE`, и systemd такое значение принимает. Отдельный - тест `TestEnvTransportDomainMatchesSystemd` закрепляет, что домен транспорта - не шире и не уже множества systemd, а +- `U+FEFF` отвергается **транспортным доменом**: публичная документация systemd + запрещает его, хотя реализация v257.13 случайно пропускает из-за маски + `0xFEFF & 0xFFFE == 0xFEFE`. Тест + `TestEnvTransportDomainMatchesDocumentedSystemdContract` закрепляет публичный контракт, а `TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются различимы. +`orchestrator/test/strict-text-read.test.ts` подаёт reader'у реальные байтовые +последовательности `0xFF`, оборванную `0xC3` и `ED A0 80`. Ни одна из них не +превращается в U+FFFD; начальный BOM сохраняется как U+FEFF и доходит до +транспортного отказа. + +`apps/controller/json_body_test.go` и HTTP-тест входа доказывают то же на API: +повреждённый UTF-8 и непарные `\uD800`/`\uDC00` отвергаются до стандартного +Go-декодера, а настоящий U+FFFD остаётся допустимым значением. + `apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги `credentialStr` и `adminPassword` прогоняются через production-валидатор и обязаны отвечать так же, как функции контракта, на тех же граничных значениях. @@ -932,4 +941,3 @@ Hysteria. Вторая не была замечена только потому, Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а `TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера — и существование этого теста само проверяется контрактом. - diff --git a/docs/testing/11-3-target-and-runtime.md b/docs/testing/11-3-target-and-runtime.md index 762169b..37727c9 100644 --- a/docs/testing/11-3-target-and-runtime.md +++ b/docs/testing/11-3-target-and-runtime.md @@ -55,7 +55,8 @@ 35. **оператор входит в панель**: `POST /api/auth/login` с bootstrap-учётными данными из `/etc/hy2xs/bootstrap-admin.secret` отвечает `code: 20000` и непустым `accessToken`. Заведомо неверные учётные данные (со СЛУЧАЙНЫМ паролем, а не с литералом) дают HTTP 200 с конвертом отказа: `code: 50000`, причина `invalid_credentials` и отсутствие `accessToken` 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой 37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным -37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне домена systemd (`U+FDD0`, `U+FFFF`, невалидный UTF-8) **роняет `preflight-install`** — то есть отказ приходит до первой мутации хоста, а не после установки пакетов; сервер остаётся нетронутым +37a. `HY2XS_ADMIN_INITIAL_PASSWORD` со значением вне документированного домена systemd (`U+FEFF`, `U+FDD0`, `U+FFFF`) или файл с байтами `0xFF`, оборванной `0xC3`, `ED A0 80` **роняет `preflight-install`** — отказ приходит до первой мутации хоста; сервер остаётся нетронутым +37b. API входа и импорта отвергает повреждённый UTF-8 и непарные UTF-16 surrogate escape до `encoding/json`; настоящий U+FFFD работает как обычный символ 38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки ## C0. Панель обязана впускать, а не слушать порт @@ -237,4 +238,3 @@ idle timeout проходил семантическую проверку. То Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами: граница определяется значением, а не именем ключа. - diff --git a/orchestrator/src/commands/diagnostics.ts b/orchestrator/src/commands/diagnostics.ts index ba97d46..f1dcec7 100644 --- a/orchestrator/src/commands/diagnostics.ts +++ b/orchestrator/src/commands/diagnostics.ts @@ -1,74 +1,96 @@ +import { join } from "node:path"; import type { CommonOptions } from "../types/context"; +import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs"; import { info, setOperationContext } from "../lib/log"; -import { runMutating } from "../lib/process"; +import { runMutating, runReadOnlyArgvStatus } from "../lib/process"; import { redactEnv, redactLogText, redactYaml } from "../lib/redaction"; function shellEscapeSingleQuotes(value: string): string { return value.replaceAll("'", "'\\''"); } +const unavailable = (reason: string): string => + `[HY2XS: источник не включён в диагностику: ${reason}]\n`; + +async function writeDiagnostic(outDir: string, name: string, content: string): Promise { + await writeTextAtomic(join(outDir, name), content, { mode: 0o600, owner: "root", group: "root" }); +} + +/** + * Вывод сначала попадает в память, редактируется и лишь затем записывается. + * В staging-каталоге ни на мгновение нет сырого journal/systemctl-вывода с + * machine token. + */ +async function collectCommand(outDir: string, name: string, argv: string[]): Promise { + try { + const result = await runReadOnlyArgvStatus(argv); + const combined = [result.stdout, result.stderr] + .filter(Boolean) + .join(result.stdout && result.stderr ? "\n" : ""); + const status = result.exitCode === 0 ? "" : `[exit code: ${result.exitCode}]\n`; + const truncated = result.stdoutTruncated || result.stderrTruncated + ? "[HY2XS: вывод обрезан по безопасному пределу 8 МиБ на поток]\n" + : ""; + await writeDiagnostic(outDir, name, redactLogText(`${status}${truncated}${combined}`)); + } catch (error) { + info(`diagnostics: ${name} пропущен: ${error instanceof Error ? error.message : String(error)}`); + await writeDiagnostic(outDir, name, unavailable("команда недоступна")); + } +} + +/** + * Конфигурация никогда не копируется в staging как есть. Повреждённый UTF-8 + * не декодируется с заменой: в бандл попадает только безопасное объяснение без + * исходных байтов. + */ +async function collectFile( + outDir: string, + name: string, + source: string, + sanitize: (content: string) => string +): Promise { + await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize)); +} + +/** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */ +export async function prepareDiagnosticFile( + source: string, + sanitize: (content: string) => string +): Promise { + try { + const raw = await readText(source); + return sanitize(raw); + } catch (error) { + const reason = error instanceof InvalidUtf8Error ? "некорректный UTF-8" : "файл недоступен"; + info(`diagnostics: ${source} пропущен: ${error instanceof Error ? error.message : String(error)}`); + return unavailable(reason); + } +} + export async function diagnosticsCollect(_options: CommonOptions): Promise { const opId = `diag-${Date.now().toString(36)}`; setOperationContext(opId); const outDir = `/var/log/hy2xs/diagnostics/${opId}`; const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`; - await runMutating`mkdir -p ${outDir}`; + await runMutating`install -d -m 0700 ${outDir}`; - await runMutating`sh -c ${`systemctl status hysteria-server > '${shellEscapeSingleQuotes(`${outDir}/systemd-hysteria.txt`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`; - await runMutating`sh -c ${`cp -a /etc/hysteria/post-install.env '${shellEscapeSingleQuotes(`${outDir}/post-install.env`)}' 2>/dev/null || true`}`; - await runMutating`sh -c ${`cp -a /etc/hy2xs/hy2xs.env '${shellEscapeSingleQuotes(`${outDir}/hy2xs.env`)}' 2>/dev/null || true`}`; - await runMutating`sh -c ${`cp -a /etc/hysteria/config.yaml '${shellEscapeSingleQuotes(`${outDir}/hysteria-config.yaml`)}' 2>/dev/null || true`}`; - await runMutating`sh -c ${`cp -a /var/lib/hy2xs/install-state.json '${shellEscapeSingleQuotes(`${outDir}/install-state.json`)}' 2>/dev/null || true`}`; - await runMutating`sh -c ${`ss -ltnup > '${shellEscapeSingleQuotes(`${outDir}/ss-ltnup.txt`)}' 2>&1 || true`}`; + await collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]); + await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]); + await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]); + await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]); + await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]); + await collectCommand(outDir, "uname.txt", ["uname", "-a"]); + await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]); - try { - const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text(); - await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw)); - } catch { - // noop - } + await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText); + await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv); + await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv); + await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml); + await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText); - try { - const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text(); - await Bun.write(`${outDir}/post-install.env`, redactEnv(postInstallRaw)); - } catch { - // noop - } - - try { - const cfgRaw = await Bun.file(`${outDir}/hysteria-config.yaml`).text(); - await Bun.write(`${outDir}/hysteria-config.yaml`, redactYaml(cfgRaw)); - } catch { - // noop - } - - // Журналы и вывод systemctl — такой же артефакт, покидающий сервер, как env - // и YAML. Раньше редактировались только последние два, а journal-admin.log - // копировался как есть — и уносил machine token, который админка логировала - // в составе RequestURI. - for (const logFile of [ - "journal-hysteria.log", - "journal-admin.log", - "systemd-hysteria.txt", - "systemd-admin.txt" - ]) { - try { - const raw = await Bun.file(`${outDir}/${logFile}`).text(); - await Bun.write(`${outDir}/${logFile}`, redactLogText(raw)); - } catch { - // noop - } - } - - await runMutating`sh -c ${`tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; + await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`; + await runMutating`chmod 0600 ${archive}`; info(`diagnostics bundle collected: ${archive}`); } - diff --git a/orchestrator/src/config/env.ts b/orchestrator/src/config/env.ts index 607787d..87a8ea4 100644 --- a/orchestrator/src/config/env.ts +++ b/orchestrator/src/config/env.ts @@ -221,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa } export function parseRuntimeEnv(content: string): RuntimeConfig { + // Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в + // комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть + // при нормализации, и preflight объявил бы повреждённый EnvironmentFile + // корректным. Нормализованные значения проверяются повторно ниже. + assertEnvTransportable("runtime EnvironmentFile", content); const env = parseEnv(content); const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080); diff --git a/orchestrator/src/config/profile.ts b/orchestrator/src/config/profile.ts index d2a03da..3c3efde 100644 --- a/orchestrator/src/config/profile.ts +++ b/orchestrator/src/config/profile.ts @@ -135,10 +135,10 @@ export function assertValidAdminUsername(name: string, value: string): string { * создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор * получал не отказ установки, а сервер без администратора. * - * Запрет управляющих символов — требование транспорта. Значение уезжает в - * /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей - * строки там является байт `\n` или `\r`, и пароль с ними не имеет - * представления, переживающего запись и чтение. См. lib/envFile.ts. + * Управляющие символы формат EnvironmentFile способен нести; их отвергает + * политика HY2XS, потому что невидимое значение нельзя надёжно повторить в + * однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется + * отдельно в lib/envFile.ts. * * Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни * при установке, ни при смене пароля, и оркестратор не имеет права быть строже. @@ -158,10 +158,9 @@ export function isValidAdminPassword(value: string): boolean { let characters = 0; for (const character of value) { const code = character.codePointAt(0) ?? 0; - // Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL, - // C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из - // них невозможно ни увидеть в поле ввода, ни повторить при следующем входе. - if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) { + // Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, + // DEL, C1). U+FEFF уже отвергнут транспортным доменом выше. + if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) { return false; } characters += 1; @@ -179,7 +178,7 @@ export function assertValidAdminPassword(name: string, value: string): string { throw new Error( `invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` + `до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` + - `в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` + + `в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` + `и ${Buffer.byteLength(value, "utf8")} байт. ` + `Набор символов не ограничен, пробелы по краям являются частью пароля. ` + `Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.` diff --git a/orchestrator/src/lib/envFile.ts b/orchestrator/src/lib/envFile.ts index 25f114f..3676cb6 100644 --- a/orchestrator/src/lib/envFile.ts +++ b/orchestrator/src/lib/envFile.ts @@ -293,13 +293,16 @@ function describeCodePoint(code: number): string { } /** - * Домен значений, которые systemd СМОЖЕТ загрузить из EnvironmentFile. + * Документированный домен значений EnvironmentFile. * * Это чужое множество, а не наша политика, и оно проверяется отдельно именно * поэтому: нарушение здесь — не «некрасивое значение», а НЕзагруженный файл * окружения и, следовательно, юнит, который не стартует. * - * Перед тем как принять пару, systemd прогоняет ключ и значение через + * Публичная документация systemd дополнительно запрещает U+FEFF. Реализация + * v257.13 по ошибке пропускает его из-за маски в `unichar_is_valid`; HY2XS + * следует документированному контракту, а не случайному расхождению одной + * версии реализации. Перед тем как принять пару, systemd прогоняет ключ и значение через * `utf8_is_valid` (src/basic/env-file.c, `check_utf8ness_and_warn`), и отказ там * возвращает `-EINVAL`. `utf8_is_valid` отвергает встроенный NUL и всё, что не * является Unicode scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх @@ -321,7 +324,7 @@ function describeCodePoint(code: number): string { export function isEnvTransportable(value: string): boolean { for (const character of value) { const code = character.codePointAt(0) ?? 0; - if (code === 0) { + if (code === 0 || code === 0xfeff) { return false; } if (code >= 0xd800 && code <= 0xdfff) { @@ -359,9 +362,9 @@ export function assertEnvTransportable(name: string, value: string): string { continue; } throw new Error( - `${name} contains ${describeCodePoint(code)}, which systemd refuses to load from an ` + - `EnvironmentFile: значение обязано быть валидным UTF-8 из Unicode scalar values, ` + - `без NUL, без суррогатов и без noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` + + `${name} contains ${describeCodePoint(code)}, which is forbidden by the documented systemd ` + + `EnvironmentFile contract: значение обязано быть валидным UTF-8 из Unicode scalar values, ` + + `без NUL, U+FEFF, суррогатов и noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` + `Файл окружения с таким значением не загрузится, и юнит не стартует.` ); } diff --git a/orchestrator/src/lib/fs.ts b/orchestrator/src/lib/fs.ts index df649a4..03c8cd2 100644 --- a/orchestrator/src/lib/fs.ts +++ b/orchestrator/src/lib/fs.ts @@ -27,8 +27,37 @@ export async function dirExists(path: string): Promise { return st?.isDirectory() ?? false; } +export class InvalidUtf8Error extends Error { + readonly path: string; + + constructor(path: string, cause: unknown) { + super(`файл ${path} содержит некорректную последовательность UTF-8`, { cause }); + this.name = "InvalidUtf8Error"; + this.path = path; + } +} + +/** Явное replacement-декодирование только для диагностического вывода команд. */ +export function decodeUtf8Lossy(bytes: Uint8Array): string { + return new TextDecoder("utf-8", { fatal: false }).decode(bytes); +} + +/** + * Читает текстовые файлы без молчаливой подмены повреждённых байтов на U+FFFD. + * + * `Blob.text()` выполняет обычное декодирование UTF-8 с replacement mode. Для + * конфигурации и секретов это недопустимо: другое байтовое значение не должно + * незаметно превратиться в корректную строку. `ignoreBOM: true` намеренно + * сохраняет начальный BOM как U+FEFF, чтобы транспортный валидатор увидел и + * отверг его по публичному контракту EnvironmentFile. + */ export async function readText(path: string): Promise { - return await Bun.file(path).text(); + const bytes = await Bun.file(path).bytes(); + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch (error) { + throw new InvalidUtf8Error(path, error); + } } export async function writeText(path: string, data: string, mode?: number): Promise { diff --git a/orchestrator/src/lib/packageMeta.ts b/orchestrator/src/lib/packageMeta.ts index 4f155b9..ac6d1bc 100644 --- a/orchestrator/src/lib/packageMeta.ts +++ b/orchestrator/src/lib/packageMeta.ts @@ -1,10 +1,15 @@ -import { readText } from "./fs"; +import { InvalidUtf8Error, readText } from "./fs"; import { runReadOnly } from "./process"; export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise { try { return (await readText(`${packageDir}/metadata/${file}`)).trim(); - } catch { + } catch (error) { + // Отсутствующая metadata допускает fallback, повреждённая — нет: иначе + // битый пакет выдаёт себя за пакет с корректным запасным значением. + if (error instanceof InvalidUtf8Error) { + throw error; + } return fallback; } } diff --git a/orchestrator/src/lib/process.ts b/orchestrator/src/lib/process.ts index 77d1a0f..fc15c78 100644 --- a/orchestrator/src/lib/process.ts +++ b/orchestrator/src/lib/process.ts @@ -35,6 +35,7 @@ */ import { assertMutationAllowed } from "./guard"; +import { decodeUtf8Lossy } from "./fs"; import { info } from "./log"; function shellQuote(value: unknown): string { @@ -190,8 +191,77 @@ export type MutationResult = { exitCode: number; stdout: string; stderr: string; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; }; +async function readDiagnosticPipe( + stream: ReadableStream, + maxBytes: number +): Promise<{ text: string; truncated: boolean }> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let captured = 0; + let truncated = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const remaining = maxBytes - captured; + if (remaining > 0) { + // slice, а не subarray: иначе маленький сохранённый view удерживает в + // памяти весь большой backing buffer и формально обходит лимит. + const chunk = value.byteLength <= remaining ? value : value.slice(0, remaining); + chunks.push(chunk); + captured += chunk.byteLength; + } + if (value.byteLength > remaining) { + truncated = true; + } + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(captured); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: decodeUtf8Lossy(bytes), truncated }; +} + +/** + * Наблюдение готовым argv с сохранением ненулевого кода возврата. + * Диагностический вывод декодируется lossy намеренно; конфигурационные файлы + * всегда проходят через строгий readText. + */ +export async function runReadOnlyArgvStatus( + argv: string[], + maxOutputBytes = 8 * 1024 * 1024 +): Promise { + assertArgv(argv, "runReadOnlyArgvStatus"); + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1) { + throw new Error("runReadOnlyArgvStatus: maxOutputBytes must be a positive integer"); + } + const subprocess = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + readDiagnosticPipe(subprocess.stdout, maxOutputBytes), + readDiagnosticPipe(subprocess.stderr, maxOutputBytes), + subprocess.exited + ]); + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated + }; +} + /** * Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска. * diff --git a/orchestrator/test/diagnostics-safety.test.ts b/orchestrator/test/diagnostics-safety.test.ts new file mode 100644 index 0000000..13718c0 --- /dev/null +++ b/orchestrator/test/diagnostics-safety.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { prepareDiagnosticFile } from "../src/commands/diagnostics"; +import { runReadOnlyArgvStatus } from "../src/lib/process"; +import { redactEnv } from "../src/lib/redaction"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function source(bytes: Uint8Array): Promise { + const directory = await mkdtemp(join(tmpdir(), "hy2xs-diagnostics-")); + directories.push(directory); + const path = join(directory, "hy2xs.env"); + await writeFile(path, bytes); + return path; +} + +describe("безопасная подготовка diagnostics", () => { + test("секрет редактируется до выдачи текста на запись", async () => { + const path = await source(new TextEncoder().encode("HY2XS_ADMIN_CON_PASS=top-secret\n")); + const prepared = await prepareDiagnosticFile(path, redactEnv); + expect(prepared).toContain(""); + expect(prepared).not.toContain("top-secret"); + }); + + test("повреждённый UTF-8 заменяется безопасной причиной, а не U+FFFD", async () => { + const path = await source(new Uint8Array([0x53, 0x45, 0x43, 0x52, 0x45, 0x54, 0x3d, 0xff])); + const prepared = await prepareDiagnosticFile(path, redactEnv); + expect(prepared).toContain("некорректный UTF-8"); + expect(prepared).not.toContain("SECRET="); + expect(prepared).not.toContain("�"); + }); + + test("вывод внешней команды ограничен по памяти и помечен как обрезанный", async () => { + const result = await runReadOnlyArgvStatus( + [process.execPath, "-e", `process.stdout.write("x".repeat(128))`], + 16 + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("x".repeat(16)); + expect(result.stdoutTruncated).toBe(true); + }); +}); diff --git a/orchestrator/test/env-file.test.ts b/orchestrator/test/env-file.test.ts index 3612e3a..107d33b 100644 --- a/orchestrator/test/env-file.test.ts +++ b/orchestrator/test/env-file.test.ts @@ -148,7 +148,8 @@ describe("запись обратима разбором", () => { }); test("непредставимое значение — отказ записи, а не потеря части секрета", () => { - // Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится. + // Отвергается документированное множество EnvironmentFile, а не то, что + // нам не нравится. for (const rejected of [ String.fromCodePoint(0x0000), String.fromCodePoint(0xfdd0), @@ -157,10 +158,10 @@ describe("запись обратима разбором", () => { ]) { expect(() => assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) - ).toThrow(/systemd refuses to load/); + ).toThrow(/documented systemd EnvironmentFile contract/); expect(() => formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) - ).toThrow(/systemd refuses to load/); + ).toThrow(/documented systemd EnvironmentFile contract/); } }); @@ -172,11 +173,11 @@ describe("запись обратима разбором", () => { expect(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd])); expect(isEnvTransportable(lone)).toBe(false); expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow( - /systemd refuses to load/ + /documented systemd EnvironmentFile contract/ ); }); - // Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества systemd. + // Домен транспорта — ЧУЖОЕ документированное множество systemd. // // Управляющие символы формат несёт: внутри двойных кавычек перевод строки // накапливается как обычный байт и переживает round-trip. Запрещает их @@ -191,10 +192,9 @@ describe("запись обратима разбором", () => { expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value); } - // U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid - // его принимает. Комментарий `/* BOM */` в исходнике systemd относится к - // U+xFFFE и является его собственной неточностью. - expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true); + // Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13 + // случайно пропускает его; продукт не закрепляет это расхождение. + expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false); }); // Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно @@ -260,6 +260,16 @@ describe("непредставимая конфигурация отвергае ).toThrow(); }); + test("исходный U+FEFF отвергается до разбора, даже вне используемого значения", () => { + for (const source of [ + `\uFEFF${envText()}`, + `${envText()}\n# comment \uFEFF\n`, + `${envText()}\nUNKNOWN_KEY="value\uFEFF"\n` + ]) { + expect(() => parseRuntimeEnv(source)).toThrow(/U\+FEFF/); + } + }); + // Ограничение принадлежит ФОРМАТУ, а не полю пароля: любой параметр сломал бы // загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через // requireValue и никаких проверок содержимого раньше не имел вовсе. @@ -271,7 +281,7 @@ describe("непредставимая конфигурация отвергае "HY2XS_ACME_EMAIL" ]) { expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow( - /systemd refuses to load/ + /documented systemd EnvironmentFile contract/ ); } }); diff --git a/orchestrator/test/package-meta-utf8.test.ts b/orchestrator/test/package-meta-utf8.test.ts new file mode 100644 index 0000000..a0ba39f --- /dev/null +++ b/orchestrator/test/package-meta-utf8.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InvalidUtf8Error } from "../src/lib/fs"; +import { readPackageValue } from "../src/lib/packageMeta"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function packageDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), "hy2xs-metadata-")); + directories.push(directory); + await mkdir(join(directory, "metadata")); + return directory; +} + +describe("metadata пакета", () => { + test("отсутствующее необязательное значение использует fallback", async () => { + const directory = await packageDir(); + expect(await readPackageValue(directory, "missing", "unknown")).toBe("unknown"); + }); + + test("повреждённый UTF-8 не маскируется fallback-значением", async () => { + const directory = await packageDir(); + await writeFile(join(directory, "metadata", "package.version"), new Uint8Array([0x31, 0xff])); + await expect(readPackageValue(directory, "package.version", "unknown")).rejects.toBeInstanceOf( + InvalidUtf8Error + ); + }); +}); diff --git a/orchestrator/test/strict-text-read.test.ts b/orchestrator/test/strict-text-read.test.ts new file mode 100644 index 0000000..8fa3169 --- /dev/null +++ b/orchestrator/test/strict-text-read.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InvalidUtf8Error, readText } from "../src/lib/fs"; +import { disableReadOnlyGuard } from "../src/lib/guard"; +import { preflightInstall } from "../src/commands/preflight-install"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function fixture(bytes: Uint8Array): Promise { + const directory = await mkdtemp(join(tmpdir(), "hy2xs-utf8-")); + directories.push(directory); + const path = join(directory, "config.env"); + await writeFile(path, bytes); + return path; +} + +describe("строгое чтение текстовых файлов", () => { + for (const [name, bytes] of [ + ["байт FF", new Uint8Array([0x61, 0xff, 0x62])], + ["оборванная последовательность C3", new Uint8Array([0x61, 0xc3])], + ["UTF-8-кодирование суррогата", new Uint8Array([0x61, 0xed, 0xa0, 0x80])] + ] as const) { + test(`отвергает ${name} без U+FFFD`, async () => { + const path = await fixture(bytes); + await expect(readText(path)).rejects.toBeInstanceOf(InvalidUtf8Error); + }); + } + + test("сохраняет начальный BOM как U+FEFF для транспортной проверки", async () => { + const path = await fixture(new Uint8Array([0xef, 0xbb, 0xbf, 0x41, 0x3d, 0x31, 0x0a])); + expect(await readText(path)).toBe("\uFEFFA=1\n"); + }); + + test("не путает настоящий U+FFFD с ошибкой декодирования", async () => { + const text = "A=abcde�\n"; + const path = await fixture(new TextEncoder().encode(text)); + expect(await readText(path)).toBe(text); + }); + + test("byte-level отказ preflight не изменяет каталог пакета", async () => { + for (const bytes of [ + new Uint8Array([0xff]), + new Uint8Array([0xc3]), + new Uint8Array([0xed, 0xa0, 0x80]) + ]) { + const path = await fixture(bytes); + const packageDir = join(path, ".."); + const before = await readdir(packageDir); + try { + await expect( + preflightInstall({ + packageDir, + sourceConfigPath: path, + runtimeConfigPath: path, + nonInteractive: true, + skipFirewall: false, + skipServiceStart: false, + skipSmoke: false + }) + ).rejects.toBeInstanceOf(InvalidUtf8Error); + } finally { + disableReadOnlyGuard(); + } + expect(await readdir(packageDir)).toEqual(before); + } + }); +}); diff --git a/orchestrator/tools/parse-upstream-hashes.ts b/orchestrator/tools/parse-upstream-hashes.ts index 82a5a13..4569fd2 100644 --- a/orchestrator/tools/parse-upstream-hashes.ts +++ b/orchestrator/tools/parse-upstream-hashes.ts @@ -8,6 +8,7 @@ */ import { parseUpstreamHashes } from "../src/build/hysteriaRelease"; +import { readText } from "../src/lib/fs"; function fail(message: string): never { console.error(`[hy2xs-build] ERROR: ${message}`); @@ -47,7 +48,7 @@ function parseArgs(argv: string[]): { file: string; asset: string } { const { file, asset } = parseArgs(Bun.argv.slice(2)); try { - const text = await Bun.file(file).text(); + const text = await readText(file); process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`); } catch (error) { fail(error instanceof Error ? error.message : String(error)); diff --git a/orchestrator/tools/render-canonical-config.ts b/orchestrator/tools/render-canonical-config.ts index 97d07f7..86df9cc 100644 --- a/orchestrator/tools/render-canonical-config.ts +++ b/orchestrator/tools/render-canonical-config.ts @@ -12,6 +12,7 @@ import { parseRuntimeEnv } from "../src/config/env"; import { HYSTERIA_OBFS_TYPES } from "../src/config/profile"; +import { readText } from "../src/lib/fs"; import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config"; import type { RuntimeContext } from "../src/types/context"; @@ -138,7 +139,7 @@ async function main(): Promise { overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort; } - const sourceEnv = await Bun.file(options.configPath).text(); + const sourceEnv = await readText(options.configPath); const config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides)); const context = { @@ -160,7 +161,7 @@ async function main(): Promise { hysteriaResolution: "compat-gate" } satisfies RuntimeContext; - const template = await Bun.file(options.templatePath).text(); + const template = await readText(options.templatePath); await Bun.write(options.outPath, renderHysteriaConfig(context, template)); } diff --git a/package/docs/README.md b/package/docs/README.md index 13469d8..beddd65 100644 --- a/package/docs/README.md +++ b/package/docs/README.md @@ -22,6 +22,11 @@ ./install.sh --config /root/custom-hy2xs.env --non-interactive ``` +Файл конфигурации обязан быть корректным UTF-8 без BOM/U+FEFF, NUL, +суррогатов и Unicode noncharacters. Установщик читает исходные байты строго и +отвергает повреждение на read-only preflight: молчаливой замены на U+FFFD и +изменений хоста при таком отказе нет. + В baseline нет target-side JavaScript, TypeScript, frontend или Go build step. ## Установка выполняется в две фазы diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index b6542de..fbf3272 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -875,7 +875,7 @@ run_admin_login_acceptance() { || 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)' \ + code_has orchestrator/src/config/env.ts -F -- 'renderEnvFile(runtimeEnvEntries(config))' \ || fail "acceptance: запись hy2xs.env снова идёт интерполяцией" code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \ || fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией" @@ -908,6 +908,8 @@ run_admin_login_acceptance() { || fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF" code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \ || fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF" + code_has "$unicode_domain_owner" -E -- '0[xX][fF][eE][fF][fF]' \ + || fail "acceptance: $unicode_domain_owner не отвергает документированный U+FEFF" done # Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без # проверки это была бы не ошибка, а тихая подмена секрета. @@ -918,6 +920,24 @@ run_admin_login_acceptance() { || fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты" done + log_step "Acceptance: text files use fatal UTF-8 decoding" + code_has orchestrator/src/lib/fs.ts -F -- '.bytes()' \ + || fail "acceptance: readText снова не читает исходные байты" + code_has orchestrator/src/lib/fs.ts -F -- 'fatal: true' \ + || fail "acceptance: readText снова допускает replacement decoding" + code_has orchestrator/src/lib/fs.ts -F -- 'ignoreBOM: true' \ + || fail "acceptance: reader снова съедает BOM до проверки U+FEFF" + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8"); + const start = source.indexOf("export async function readText"); + const end = source.indexOf("\nexport async function writeText", start); + const body = source.slice(start, end); + if (body.includes(".text()")) throw new Error("readText использует replacement decoding"); + ' || fail "acceptance: строгий файловый reader заменён на Blob.text()" + ! grep -R -F --include='*.ts' 'Bun.file(' orchestrator/tools \ + | grep -F '.text()' >/dev/null \ + || fail "acceptance: build-time tool обходит строгий файловый reader" + # Порт автомата обязан повторять upstream буквально: в состоянии # DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой # перевод строки, - иначе `\` мы прочитали бы иначе, чем systemd. @@ -935,11 +955,36 @@ run_admin_login_acceptance() { const start = source.indexOf("export function parseRuntimeEnv"); if (start < 0) throw new Error("parseRuntimeEnv отсутствует"); const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig")); + const rawValidation = body.indexOf("assertEnvTransportable"); + const parsing = body.indexOf("parseEnv(content)"); + if (rawValidation < 0 || parsing < 0 || rawValidation > parsing) { + throw new Error("исходный EnvironmentFile не проверяется до разбора"); + } if (!body.includes("validateRuntimeEnvTransport(config)")) { throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста"); } ' || fail "acceptance: проверка транспорта не входит в разбор конфигурации" + log_step "Acceptance: JSON is strict before encoding/json" + code_has apps/controller/json_body.go -F -- 'utf8.Valid(raw)' \ + || fail "acceptance: HTTP JSON снова допускает повреждённый UTF-8" + code_has apps/controller/json_body.go -F -- 'validateStrictJSONEncoding(raw)' \ + || fail "acceptance: strict JSON binder не проверяет Unicode escapes" + code_has apps/controller/validator.go -F -- 'strictBindJSON(c, &field)' \ + || fail "acceptance: DTO снова разбираются напрямую через Gin/encoding/json" + code_has apps/controller/hysteria2.go -F -- 'strictBindJSON(c, &req)' \ + || fail "acceptance: Hysteria auth обходит строгую JSON-границу" + code_has apps/controller/peer.go -F -- 'validateStrictJSONEncoding(content)' \ + || fail "acceptance: импорт пиров обходит строгую JSON-границу" + + log_step "Acceptance: diagnostics never stages raw secrets" + ! code_has orchestrator/src/commands/diagnostics.ts -E -- 'cp -a .*(hy2xs\.env|post-install\.env|config\.yaml)' \ + || fail "acceptance: diagnostics снова копирует секретный конфиг в staging до редакции" + code_has orchestrator/src/commands/diagnostics.ts -F -- 'prepareDiagnosticFile(source, sanitize)' \ + || fail "acceptance: diagnostics не проводит файл через строгую подготовку до записи" + ! code_has orchestrator/src/commands/diagnostics.ts -F -- 'Bun.write' \ + || fail "acceptance: diagnostics снова пишет сырой промежуточный файл напрямую" + log_step "Acceptance: env files are read as data, never executed" # Файл научился нести произвольные значения, и это сделало `source` опасным: # для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение diff --git a/tools/test/frontend-contract.test.ts b/tools/test/frontend-contract.test.ts index 1face29..d44eb03 100644 --- a/tools/test/frontend-contract.test.ts +++ b/tools/test/frontend-contract.test.ts @@ -842,7 +842,7 @@ describe("контракт учётных данных администрато `abcde${String.fromCodePoint(0xfdd0)}`, `abcde${String.fromCodePoint(0xffff)}`, `abcde${String.fromCodePoint(0x1ffff)}`, - // U+FEFF формат несёт — его отвергает политика продукта. + // U+FEFF запрещён публичным транспортным контрактом EnvironmentFile. `abcde${String.fromCodePoint(0xfeff)}`, // Одиночный суррогат: без проверки TextEncoder молча подменил бы его на // U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ.