fix(utf8): закрыть lossy-границы конфигурации и API

This commit is contained in:
2026-09-07 17:25:27 +05:00
parent ab788725cf
commit af9f476658
38 changed files with 759 additions and 159 deletions
+10
View File
@@ -8,6 +8,16 @@
## [Unreleased] ## [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`. Основная тема — сделать политику Hardening-проход перед релизом `1.0.0`. Основная тема — сделать политику
«только чистая установка» настоящим системным инвариантом, а не строчкой в «только чистая установка» настоящим системным инвариантом, а не строчкой в
документации. документации.
+6 -1
View File
@@ -635,7 +635,7 @@ hy2xs-orchestrator status \
| `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` | | `HY2XS_UI_PUBLIC_ACCESS` | Флаг публичного UI‑доступа. В baseline оставляйте `false` | `false` |
| `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` | | `HY2XS_UI_PORT` | Порт HY2XS admin | `8080` |
| `HY2XS_ADMIN_USER` | Bootstrap‑логин администратора: 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=`. Значение вне контракта роняет установку — панель его не приняла бы на форме входа | `hy2xsadmin` | | `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_ADMIN_CON_PASS` | Bootstrap‑секрет peer/auth слоя; `__GENERATE__` генерируется при install | `__GENERATE__` |
| `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` | | `HY2XS_FORCE_PASSWORD_CHANGE` | Принудительная смена пароля. UX‑flow пока не включён в production baseline | `false` |
| `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` | | `HY2XS_ALLOW_SELF_SIGNED_DEV` | Разрешает `self_signed_dev` TLS‑режим | `false` |
@@ -766,6 +766,11 @@ hy2xs-orchestrator diagnostics collect \
--config /etc/hy2xs/hy2xs.env --config /etc/hy2xs/hy2xs.env
``` ```
Бандл не содержит сырых промежуточных копий конфигурации или журналов:
редакция выполняется в памяти до записи. Файл с повреждённым UTF-8 не
декодируется с заменой и не попадает в архив; вместо него записывается
безопасная причина пропуска.
## Проверка безопасности после установки ## Проверка безопасности после установки
Минимальный набор проверок: Минимальный набор проверок:
+40
View File
@@ -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: именно так он шёл в бою. // Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
// //
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную // В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func Hysteria2Auth(c *gin.Context) { func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil { if err := strictBindJSON(c, &req); err != nil {
vo.Hysteria2AuthBadRequest(c) vo.Hysteria2AuthBadRequest(c)
return return
} }
+113
View File
@@ -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
}
+31
View File
@@ -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)
}
})
}
}
+4
View File
@@ -189,6 +189,10 @@ func ImportPeer(c *gin.Context) {
vo.Fail("the file is too big", c) vo.Fail("the file is too big", c)
return return
} }
if err = validateStrictJSONEncoding(content); err != nil {
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
return
}
var peerExports []bo.PeerExport var peerExports []bo.PeerExport
decoder := json.NewDecoder(bytes.NewReader(content)) decoder := json.NewDecoder(bytes.NewReader(content))
+20 -2
View File
@@ -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() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
if err != nil { if err != nil {
t.Fatalf("не удалось собрать форму: %v", err) t.Fatalf("не удалось собрать форму: %v", err)
} }
if _, err := part.Write([]byte(content)); err != nil { if _, err := part.Write(content); err != nil {
t.Fatalf("не удалось записать файл в форму: %v", err) t.Fatalf("не удалось записать файл в форму: %v", err)
} }
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
return result 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 читает ПЕРВЫЙ документ и останавливается. Файл с // Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая // хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
// половина файла молча не применялась. // половина файла молча не применялась.
+2 -2
View File
@@ -109,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
case http.MethodGet: case http.MethodGet:
bindErr = c.ShouldBindQuery(&field) bindErr = c.ShouldBindQuery(&field)
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = c.ShouldBindJSON(&field) bindErr = strictBindJSON(c, &field)
} }
if bindErr != nil { if bindErr != nil {
vo.FailValidation( vo.FailValidation(
@@ -246,7 +246,7 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes), "maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
} }
described.Message = fmt.Sprintf( described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов", "поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
field, field,
credential.AdminPasswordMinLength, credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength, credential.AdminPasswordMaxLength,
+8 -19
View File
@@ -131,10 +131,9 @@ func IsValidAdminUsername(username string) bool {
// же класс дефекта, ради уничтожения которого контракт и существует, только на // же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже. // один слой ниже.
// //
// U+FEFF в этот список НЕ входит: `0xFEFF & 0xFFFE` — это `0xFEFE`, и systemd // Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
// такое значение принимает (комментарий `/* BOM */` в его исходнике относится к // случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
// U+xFFFE и является неточностью самого systemd). Запрет U+FEFF — отдельное // документированному контракту, а не этому расхождению реализации.
// продуктовое решение, см. hasForbiddenRune.
func isUnicodeNoncharacter(r rune) bool { func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF { if r >= 0xFDD0 && r <= 0xFDEF {
return true return true
@@ -144,8 +143,7 @@ func isUnicodeNoncharacter(r rune) bool {
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения. // IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
// //
// Это ДОМЕН systemd, а не политика HY2XS: здесь перечислено ровно то, что // Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
// `utf8_is_valid` отвергает, и ничего сверх того. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе // (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит. // невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool { func IsEnvTransportableText(value string) bool {
@@ -159,7 +157,7 @@ func IsEnvTransportableText(value string) bool {
for _, r := range value { for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его // NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`). // отдельной веткой (`embedded NUL`).
if r == 0 { if r == 0 || r == 0xFEFF {
return false return false
} }
if isUnicodeNoncharacter(r) { if isUnicodeNoncharacter(r) {
@@ -178,7 +176,7 @@ func IsEnvTransportableText(value string) bool {
// (IsEnvTransportableText); // (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор; // 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt; // <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// Cc и U+FEFF — продуктовая политика, см. hasForbiddenRune. // Cc — продуктовая политика, см. hasForbiddenRune.
// //
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает // Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её // go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
@@ -226,22 +224,13 @@ func IsValidAdminPassword(password string) bool {
// символов» — то есть документация была шире кода ровно на C1. Проверяется // символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано. // теперь то, что обещано.
// //
// И отдельно U+FEFF. Формат его несёт: `0xFEFF & 0xFFFE` — это `0xFEFE`, и // U+FEFF здесь уже не проверяется: он принадлежит документированному домену
// `unichar_is_valid` такое значение ПРИНИМАЕТ (комментарий `/* BOM */` в // EnvironmentFile и отвергается IsEnvTransportableText.
// исходнике systemd относится к U+xFFFE и является его собственной
// неточностью). Запрет здесь — решение продукта: невидимый символ нулевой
// ширины в пароле нельзя ни увидеть в поле ввода, ни повторить при следующем
// входе, то есть он умеет ровно одно — запереть оператора снаружи. Ровно тем же
// обоснованием запрещены и управляющие символы: перевод строки невозможно
// ввести в однострочное поле формы входа.
func hasForbiddenRune(value string) bool { func hasForbiddenRune(value string) bool {
for _, r := range value { for _, r := range value {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) { if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true return true
} }
if r == 0xFEFF {
return true
}
} }
return false return false
} }
+14 -13
View File
@@ -146,8 +146,7 @@ func TestAdminPasswordBoundaries(t *testing.T) {
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true}, {"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true}, {"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF формат несёт (0xFEFF & 0xFFFE == 0xFEFE), и отвергается он // U+FEFF отвергается документированным транспортным доменом systemd.
// продуктовой политикой, а не systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false}, {"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
} }
@@ -187,18 +186,18 @@ func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
} }
} }
// Домен транспорта повторяет множество systemd ТОЧНО — не шире и не уже. // Домен транспорта повторяет публично документированное множество systemd.
// //
// Шире означало бы, что HY2XS отвергает значение, которое systemd загрузил бы; // Шире означало бы собственный запрет сверх публичного контракта; уже — что
// уже — что значение проходит все двери продукта и делает файл окружения // значение проходит все двери продукта вопреки документации EnvironmentFile.
// незагружаемым, а юнит — не стартующим. Второе и было дефектом: контракт знал // Второе и было дефектом: контракт знал про управляющие символы и не знал про
// про управляющие символы и не знал про noncharacters. // noncharacters.
// //
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются // Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность. // вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesSystemd(t *testing.T) { func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
rejected := []rune{ rejected := []rune{
0x0000, // embedded NUL — отдельная ветка utf8_is_valid 0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF 0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF, 0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF, 0x1FFFE, 0x1FFFF,
@@ -206,21 +205,20 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
} }
for _, r := range rejected { for _, r := range rejected {
if IsEnvTransportableText(string(r)) { if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят: systemd откажется загружать такой EnvironmentFile", r) t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
} }
} }
accepted := []rune{ accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта 0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона 0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFEFF, // 0xFEFF & 0xFFFE == 0xFEFE — systemd принимает
0xFFFD, // replacement character — обычный символ 0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter 0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀 0x1F600, // 😀
} }
for _, r := range accepted { for _, r := range accepted {
if !IsEnvTransportableText(string(r)) { 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) { func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS. // Управляющие символы формат несёт — их запрещает 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) password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 { if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r) t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
@@ -248,6 +246,9 @@ func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r) t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
} }
} }
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
}
} }
// Пароль НЕ триммится нигде, включая сам контракт. // Пароль НЕ триммится нигде, включая сам контракт.
+1 -1
View File
@@ -163,7 +163,7 @@ func ensureSecureBootstrapAdmin() error {
if !credential.IsValidAdminPassword(adminPassword) { if !credential.IsValidAdminPassword(adminPassword) {
return fmt.Errorf( return fmt.Errorf(
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+ "HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+ "от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+ "Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
"Учётная запись администратора с таким значением создана НЕ будет: "+ "Учётная запись администратора с таким значением создана НЕ будет: "+
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+ "установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
+7 -5
View File
@@ -82,8 +82,9 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и * `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там * перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там
* это не предупреждение, а незагруженный файл окружения и, следовательно, * это не предупреждение, а незагруженный файл окружения и, следовательно,
* юнит, который не стартует. Отвергаются NUL, суррогаты, `U+FDD0..U+FDEF` и все * юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
* code points вида `*FFFE`/`*FFFF`. Панель обязана знать это множество: иначе * суррогаты, `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, * Второе продуктовая политика: управляющие символы Unicode целиком (Cc C0,
* DEL, C1) и U+FEFF. Формат их несёт, запрещает их HY2XS: ни один из них * DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть * невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
* они умеют ровно одно запереть оператора снаружи. * они умеют ровно одно запереть оператора снаружи.
* *
@@ -109,6 +110,7 @@ export function isValidAdminPassword(value: string): boolean {
// Домен systemd: значение вне него не доедет до сервера вовсе. // Домен systemd: значение вне него не доедет до сервера вовсе.
if ( if (
code === 0 || code === 0 ||
code === 0xfeff ||
(code >= 0xd800 && code <= 0xdfff) || (code >= 0xd800 && code <= 0xdfff) ||
(code >= 0xfdd0 && code <= 0xfdef) || (code >= 0xfdd0 && code <= 0xfdef) ||
(code & 0xfffe) === 0xfffe (code & 0xfffe) === 0xfffe
@@ -116,8 +118,8 @@ export function isValidAdminPassword(value: string): boolean {
return false; return false;
} }
// Политика HY2XS: Cc и U+FEFF. // Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) { if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
return false; return false;
} }
+2 -2
View File
@@ -27,7 +27,7 @@ export default {
credentials: { credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}", usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordFormat: 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: { dashboard: {
stale: "Dashboard data is stale. Retrying automatically...", 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", "“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: {min} to {max} characters from {charset}", credential_format: "“{field}”: {min} to {max} characters from {charset}",
admin_password_format: 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", rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed", validation_failed: "Validation failed",
body_invalid: body_invalid:
+2 -2
View File
@@ -36,7 +36,7 @@ export default {
// байта; сообщение «не длиннее {max} символов» отправило бы оператора // байта; сообщение «не длиннее {max} символов» отправило бы оператора
// сокращать пароль, отвергнутый не за это. // сокращать пароль, отвергнутый не за это.
passwordFormat: passwordFormat:
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", "Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
}, },
dashboard: { dashboard: {
stale: stale:
@@ -189,7 +189,7 @@ export default {
credential_format: credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}", "«{field}»: от {min} до {max} символов из набора {charset}",
admin_password_format: admin_password_format:
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов", "«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
rule_violated: "«{field}»: значение не подходит", rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена", validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей", body_invalid: "Запрос не разобран: проверьте формат и типы полей",
+1 -1
View File
@@ -59,7 +59,7 @@ func HashPassword(password string) (string, error) {
if !credential.IsValidAdminPassword(password) { if !credential.IsValidAdminPassword(password) {
return "", fmt.Errorf( return "", fmt.Errorf(
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+ "пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
"не более %d байт в UTF-8, без управляющих символов", "не более %d байт в UTF-8, без управляющих символов и U+FEFF",
credential.AdminPasswordMinLength, credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength, credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes, credential.AdminPasswordMaxBytes,
+14 -6
View File
@@ -278,8 +278,8 @@ control plane показывал всех пиров офлайн. Теперь
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` | | Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` | | Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` | | Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` | | Домен пароля | документированный домен systemd `EnvironmentFile=`: валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | `IsEnvTransportableText` |
| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` | | Набор символов пароля | не ограничен, кроме `Cc` | `hasForbiddenRune` |
| Пробелы по краям пароля | часть значения, не снимаются | — | | Пробелы по краям пароля | часть значения, не снимаются | — |
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
@@ -323,7 +323,11 @@ U+FDD0..U+FDEF noncharacters
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF (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` — шесть символов, восемь Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование, байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
@@ -335,13 +339,17 @@ U+FDD0..U+FDEF noncharacters
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
изменение пароля по дороге в файл. изменение пароля по дороге в файл.
**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие **Политика HY2XS — наше решение.** Сверх транспортного домена запрещены управляющие
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`, символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`, `U+0080..U+009F`). Их невозможно ни увидеть в
и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
оператора снаружи. оператора снаружи.
На HTTP-границе проверяется не только уже декодированная Go-строка. Сырые JSON
байты должны быть валидным UTF-8, а `\uXXXX` — не содержать непарных UTF-16
суррогатов. Это делается до `encoding/json`, который иначе молча заменил бы оба
дефекта на допустимый U+FFFD и мог бы аутентифицировать другое значение.
### Почему границ у пароля две ### Почему границ у пароля две
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя. Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
@@ -553,7 +553,8 @@ grep -c '^ADMIN_INITIAL_PASSWORD=' /etc/hy2xs/bootstrap-admin.secret
| --- | --- | | --- | --- |
| 6-64 символа Unicode | форма входа и форма смены пароля | | 6-64 символа Unicode | форма входа и форма смены пароля |
| не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) | | не более 72 байт в UTF-8 | bcrypt (`ErrPasswordTooLong`) |
| без управляющих символов | формат `KEY=VALUE`, который читает systemd | | валидный UTF-8 без NUL, U+FEFF, суррогатов и noncharacters | документированный домен systemd `EnvironmentFile=` |
| без управляющих символов Unicode (`Cc`) | политика HY2XS: значение должно воспроизводиться в однострочной форме входа |
Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64 Границы независимы: у 64 символов длина от 64 до 256 байт. Пароль из 64
кириллических букв — это 128 байт, и он отвергается, хотя в границу символов кириллических букв — это 128 байт, и он отвергается, хотя в границу символов
+19 -3
View File
@@ -597,10 +597,12 @@ production-профилем, а не ищет подстроки. Проверя
(`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный (`check_utf8ness_and_warn`), и отказ там — `-EINVAL`, то есть **незагруженный
файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает файл окружения** и юнит, который не стартует. `unichar_is_valid` отвергает
суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`, а сам суррогаты, `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, — и запрещает их строки накапливается как обычный байт и переживает round-trip, — и запрещает их
контракт учётных данных, а не транспорт. Приписывать формату чужие запреты контракт учётных данных, а не транспорт. Приписывать формату чужие запреты
нельзя: именно так проверка и пропустила noncharacters, о которых ничего не нельзя: именно так проверка и пропустила noncharacters, о которых ничего не
@@ -610,6 +612,13 @@ production-профилем, а не ищет подстроки. Проверя
вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на вправе их содержать, а `TextEncoder` молча заменит непарный суррогат на
`U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ. `U+FFFD` — то есть без проверки в файл уехал бы **другой** секрет, а не отказ.
Сам файл читается только как байты и декодируется через
`TextDecoder("utf-8", { fatal: true, ignoreBOM: true })`. Обычный
`Bun.file(...).text()` запрещён на этой границе: он заменяет повреждённые байты
на U+FFFD. `ignoreBOM: true` сохраняет BOM как U+FEFF, чтобы тот не исчез до
транспортной проверки. Исходный текст целиком проверяется **до** разбора ключей:
запрещённый символ не может спрятаться в комментарии или неизвестной переменной.
### Непригодная конфигурация отвергается до первой мутации ### Непригодная конфигурация отвергается до первой мутации
`validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи `validateRuntimeEnvTransport` вызывается из `parseRuntimeEnv`, а не при записи
@@ -681,6 +690,13 @@ production-профилем, а не ищет подстроки. Проверя
`redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML `redact-config` и diagnostics-бандл используют **структурную** редакцию: YAML
разбирается и обходится как дерево. разбирается и обходится как дерево.
Diagnostics не копирует env/YAML и не перенаправляет сырой journal/systemctl
сразу в staging. Сначала данные читаются или захватываются в память, проходят
редакцию и лишь затем записываются с режимом `0600`. Некорректный UTF-8 в
конфигурационном файле даёт безопасный маркер пропуска без исходных байтов.
Вывод каждой внешней команды ограничен 8 МиБ на поток и при усечении явно
помечается; архив создаётся сразу под `umask 077`.
Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в Это не косметика. Построчное правило `auth:\s*(.*)` подставляло маркер в
заголовок mapping'а и оставляло нетронутым вложенный заголовок mapping'а и оставляло нетронутым вложенный
`auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token `auth.http.url` с `access_token=<секрет>`, то есть бандл уносил machine token
+7 -3
View File
@@ -153,7 +153,7 @@ anycast. Отсутствие A-записи фатально при любом
| Переменная | Требование | Значение по умолчанию | | Переменная | Требование | Значение по умолчанию |
| --- | --- | --- | | --- | --- | --- |
| `HY2XS_ADMIN_USER` | 6-32 символа из набора `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `hy2xsadmin` | | `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=`: **Домен systemd.** Значение обязано быть загружаемым из `EnvironmentFile=`:
валидный UTF-8 из Unicode scalar values, без NUL, без суррогатов и без валидный UTF-8 из Unicode scalar values, без NUL, U+FEFF, суррогатов и
noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило — noncharacters (`U+FDD0..U+FDEF` и все `*FFFE`/`*FFFF`). Это не наше правило —
systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть systemd прогоняет значение через `utf8_is_valid` и отвечает `-EINVAL`, то есть
файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет файл окружения **не загружается** и юнит не стартует. Оркестратор проверяет
@@ -201,9 +201,13 @@ systemd прогоняет значение через `utf8_is_valid` и отв
или obfs-пароль сломали бы загрузку юнита ровно так же. или obfs-пароль сломали бы загрузку юнита ровно так же.
**Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode **Политика HY2XS.** Сверх этого запрещены управляющие символы Unicode
(категория `Cc`) и `U+FEFF`. Формат их несёт, но ввести такой пароль в (категория `Cc`). Формат их несёт, но ввести такой пароль в
однострочное поле формы входа всё равно нельзя. однострочное поле формы входа всё равно нельзя.
Ограничение U+FEFF берётся из публичной документации systemd. В реализации
v257.13 он случайно проходит через `unichar_is_valid` из-за маски; HY2XS не
делает эту ошибку версии частью собственного контракта.
Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до Отказ по любому из двух правил приходит **при разборе конфигурации**, то есть до
первой необратимой операции над хостом: `preflight-install` и `install` видят первой необратимой операции над хостом: `preflight-install` и `install` видят
его одинаково. его одинаково.
+16 -8
View File
@@ -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+D800` | отказ — иначе `TextEncoder` молча заменил бы его на `U+FFFD`, то есть подменил бы секрет |
| `U+FDCF`, `U+FDF0`, `U+FFFD`, `U+10FFFD`, `U+1F600` | принимаются: правило описывает диапазон, а не окрестность | | `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. Непригодная конфигурация отвергается до первой мутации ### A4b. Непригодная конфигурация отвергается до первой мутации
@@ -591,18 +591,27 @@ wildcard-маршрутом фронтенда или дублирующая р
- управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`, - управляющие символы Unicode целиком, то есть `Cc`: `\n`, `\r`, `\t`, `NUL`,
`DEL` и C1 (`U+0085`, `U+009F`). Раньше проверялись только C0 и DEL, а `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) отвергаются: с ними `U+1FFFF`, `U+10FFFF`, невалидный UTF-8) отвергаются: с ними
`/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует; `/etc/hy2xs/hy2xs.env` не загрузится и юнит не стартует;
- соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются: - соседи запрещённых диапазонов (`U+FDCF`, `U+FDF0`, `U+FFFD`) принимаются:
правило описывает множество systemd, а не окрестность подозрительных значений; правило описывает множество systemd, а не окрестность подозрительных значений;
- `U+FEFF` отвергается **политикой продукта**, а не форматом: - `U+FEFF` отвергается **транспортным доменом**: публичная документация systemd
`0xFEFF & 0xFFFE == 0xFEFE`, и systemd такое значение принимает. Отдельный запрещает его, хотя реализация v257.13 случайно пропускает из-за маски
тест `TestEnvTransportDomainMatchesSystemd` закрепляет, что домен транспорта `0xFEFF & 0xFFFE == 0xFEFE`. Тест
не шире и не уже множества systemd, а `TestEnvTransportDomainMatchesDocumentedSystemdContract` закрепляет публичный контракт, а
`TestProductPolicyIsWiderThanTransportDomain` — что политика и домен остаются `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` — ПРОВОДКА, а не контракт: теги `apps/controller/validator_test.go` — ПРОВОДКА, а не контракт: теги
`credentialStr` и `adminPassword` прогоняются через production-валидатор и `credentialStr` и `adminPassword` прогоняются через production-валидатор и
обязаны отвечать так же, как функции контракта, на тех же граничных значениях. обязаны отвечать так же, как функции контракта, на тех же граничных значениях.
@@ -932,4 +941,3 @@ Hysteria. Вторая не была замечена только потому,
Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а Отсутствие legacy-маршрута доказывает не `grep` по исходникам, а
`TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера — `TestRouterHasNoLegacyNamespace` на таблице маршрутов собранного роутера —
и существование этого теста само проверяется контрактом. и существование этого теста само проверяется контрактом.
+2 -2
View File
@@ -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` 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 байта) — отвергается конвертом валидации, а не системной ошибкой 36. пароль предельной длины, назначенный формой смены пароля, принимается формой входа: границы обеих форм совпадают с серверными. Границ **две** — 6-64 символа Unicode и не более 72 байт в UTF-8 (предел bcrypt): пароль из 36 кириллических букв (72 байта) принимается, из 37 (74 байта) — отвергается конвертом валидации, а не системной ошибкой
37. `HY2XS_ADMIN_INITIAL_PASSWORD` с пробелом по краям доезжает до учётной записи неизменным: значение записано в `hy2xs.env` в двойных кавычках, и вход выполняется ровно им, а не обрезанным 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` администратора обновляется после успешного входа и не меняется после неудачной попытки 38. `last_login_at` администратора обновляется после успешного входа и не меняется после неудачной попытки
## C0. Панель обязана впускать, а не слушать порт ## C0. Панель обязана впускать, а не слушать порт
@@ -237,4 +238,3 @@ idle timeout проходил семантическую проверку. То
Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами: Го- и TS-санитайзеры описывают один контракт и покрыты зеркальными тестами:
граница определяется значением, а не именем ключа. граница определяется значением, а не именем ключа.
+76 -54
View File
@@ -1,74 +1,96 @@
import { join } from "node:path";
import type { CommonOptions } from "../types/context"; import type { CommonOptions } from "../types/context";
import { InvalidUtf8Error, readText, writeTextAtomic } from "../lib/fs";
import { info, setOperationContext } from "../lib/log"; import { info, setOperationContext } from "../lib/log";
import { runMutating } from "../lib/process"; import { runMutating, runReadOnlyArgvStatus } from "../lib/process";
import { redactEnv, redactLogText, redactYaml } from "../lib/redaction"; import { redactEnv, redactLogText, redactYaml } from "../lib/redaction";
function shellEscapeSingleQuotes(value: string): string { function shellEscapeSingleQuotes(value: string): string {
return value.replaceAll("'", "'\\''"); return value.replaceAll("'", "'\\''");
} }
const unavailable = (reason: string): string =>
`[HY2XS: источник не включён в диагностику: ${reason}]\n`;
async function writeDiagnostic(outDir: string, name: string, content: string): Promise<void> {
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<void> {
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<void> {
await writeDiagnostic(outDir, name, await prepareDiagnosticFile(source, sanitize));
}
/** Чистая граница «прочитать строго -> санитизировать -> вернуть текст». */
export async function prepareDiagnosticFile(
source: string,
sanitize: (content: string) => string
): Promise<string> {
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<void> { export async function diagnosticsCollect(_options: CommonOptions): Promise<void> {
const opId = `diag-${Date.now().toString(36)}`; const opId = `diag-${Date.now().toString(36)}`;
setOperationContext(opId); setOperationContext(opId);
const outDir = `/var/log/hy2xs/diagnostics/${opId}`; const outDir = `/var/log/hy2xs/diagnostics/${opId}`;
const archive = `/var/log/hy2xs/diagnostics/${opId}.tar.gz`; 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 collectCommand(outDir, "systemd-hysteria.txt", ["systemctl", "status", "hysteria-server"]);
await runMutating`sh -c ${`systemctl status hy2xs-admin > '${shellEscapeSingleQuotes(`${outDir}/systemd-admin.txt`)}' 2>&1 || true`}`; await collectCommand(outDir, "systemd-admin.txt", ["systemctl", "status", "hy2xs-admin"]);
await runMutating`sh -c ${`journalctl -u hysteria-server -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-hysteria.log`)}' 2>&1 || true`}`; await collectCommand(outDir, "journal-hysteria.log", ["journalctl", "-u", "hysteria-server", "-n", "300", "--no-pager"]);
await runMutating`sh -c ${`journalctl -u hy2xs-admin -n 300 --no-pager > '${shellEscapeSingleQuotes(`${outDir}/journal-admin.log`)}' 2>&1 || true`}`; await collectCommand(outDir, "journal-admin.log", ["journalctl", "-u", "hy2xs-admin", "-n", "300", "--no-pager"]);
await runMutating`sh -c ${`nft list ruleset > '${shellEscapeSingleQuotes(`${outDir}/nftables.ruleset`)}' 2>&1 || true`}`; await collectCommand(outDir, "nftables.ruleset", ["nft", "list", "ruleset"]);
await runMutating`sh -c ${`uname -a > '${shellEscapeSingleQuotes(`${outDir}/uname.txt`)}' 2>&1 || true`}`; await collectCommand(outDir, "uname.txt", ["uname", "-a"]);
await runMutating`sh -c ${`cat /etc/os-release > '${shellEscapeSingleQuotes(`${outDir}/os-release.txt`)}' 2>&1 || true`}`; await collectCommand(outDir, "ss-ltnup.txt", ["ss", "-ltnup"]);
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`}`;
try { await collectFile(outDir, "os-release.txt", "/etc/os-release", redactLogText);
const envRaw = await Bun.file(`${outDir}/hy2xs.env`).text(); await collectFile(outDir, "post-install.env", "/etc/hysteria/post-install.env", redactEnv);
await Bun.write(`${outDir}/hy2xs.env`, redactEnv(envRaw)); await collectFile(outDir, "hy2xs.env", "/etc/hy2xs/hy2xs.env", redactEnv);
} catch { await collectFile(outDir, "hysteria-config.yaml", "/etc/hysteria/config.yaml", redactYaml);
// noop await collectFile(outDir, "install-state.json", "/var/lib/hy2xs/install-state.json", redactLogText);
}
try { await runMutating`sh -c ${`umask 077; tar -czf '${shellEscapeSingleQuotes(archive)}' -C '${shellEscapeSingleQuotes(outDir)}' .`}`;
const postInstallRaw = await Bun.file(`${outDir}/post-install.env`).text(); await runMutating`chmod 0600 ${archive}`;
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)}' .`}`;
info(`diagnostics bundle collected: ${archive}`); info(`diagnostics bundle collected: ${archive}`);
} }
+5
View File
@@ -221,6 +221,11 @@ function normalizeSafeAbsolutePath(name: string, value: string, options?: { disa
} }
export function parseRuntimeEnv(content: string): RuntimeConfig { export function parseRuntimeEnv(content: string): RuntimeConfig {
// Проверяется ИСХОДНЫЙ текст до разбора. Иначе запрещённый символ в
// комментарии, неизвестном ключе или перед первым ключом мог бы исчезнуть
// при нормализации, и preflight объявил бы повреждённый EnvironmentFile
// корректным. Нормализованные значения проверяются повторно ниже.
assertEnvTransportable("runtime EnvironmentFile", content);
const env = parseEnv(content); const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080); const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
+8 -9
View File
@@ -135,10 +135,10 @@ export function assertValidAdminUsername(name: string, value: string): string {
* создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор * создавалась: bcrypt отвечал ErrPasswordTooLong при старте админки, и оператор
* получал не отказ установки, а сервер без администратора. * получал не отказ установки, а сервер без администратора.
* *
* Запрет управляющих символов требование транспорта. Значение уезжает в * Управляющие символы формат EnvironmentFile способен нести; их отвергает
* /etc/hy2xs/hy2xs.env, который systemd читает как EnvironmentFile; границей * политика HY2XS, потому что невидимое значение нельзя надёжно повторить в
* строки там является байт `\n` или `\r`, и пароль с ними не имеет * однострочной форме входа. Транспортный домен (включая U+FEFF) проверяется
* представления, переживающего запись и чтение. См. lib/envFile.ts. * отдельно в lib/envFile.ts.
* *
* Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни * Набор символов сверх этого НЕ ограничивается: сервер его не ограничивает ни
* при установке, ни при смене пароля, и оркестратор не имеет права быть строже. * при установке, ни при смене пароля, и оркестратор не имеет права быть строже.
@@ -158,10 +158,9 @@ export function isValidAdminPassword(value: string): boolean {
let characters = 0; let characters = 0;
for (const character of value) { for (const character of value) {
const code = character.codePointAt(0) ?? 0; const code = character.codePointAt(0) ?? 0;
// Продуктовая политика: управляющие символы Unicode целиком (Cc — C0, DEL, // Продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
// C1) и U+FEFF. Формат их несёт; запрещает их HY2XS, потому что ни один из // DEL, C1). U+FEFF уже отвергнут транспортным доменом выше.
// них невозможно ни увидеть в поле ввода, ни повторить при следующем входе. if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
return false; return false;
} }
characters += 1; characters += 1;
@@ -179,7 +178,7 @@ export function assertValidAdminPassword(name: string, value: string): string {
throw new Error( throw new Error(
`invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` + `invalid ${name}: панель принимает пароль длиной от ${ADMIN_PASSWORD_MIN_LENGTH} ` +
`до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` + `до ${ADMIN_PASSWORD_MAX_LENGTH} символов Unicode, не длиннее ${ADMIN_PASSWORD_MAX_BYTES} байт ` +
`в UTF-8 и без управляющих символов; получено ${[...value].length} символов ` + `в UTF-8, без управляющих символов и U+FEFF; получено ${[...value].length} символов ` +
`и ${Buffer.byteLength(value, "utf8")} байт. ` + `и ${Buffer.byteLength(value, "utf8")} байт. ` +
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` + `Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.` `Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
+9 -6
View File
@@ -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`), и отказ там * `utf8_is_valid` (src/basic/env-file.c, `check_utf8ness_and_warn`), и отказ там
* возвращает `-EINVAL`. `utf8_is_valid` отвергает встроенный NUL и всё, что не * возвращает `-EINVAL`. `utf8_is_valid` отвергает встроенный NUL и всё, что не
* является Unicode scalar value, а `unichar_is_valid` (src/basic/utf8.c) сверх * является 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 { export function isEnvTransportable(value: string): boolean {
for (const character of value) { for (const character of value) {
const code = character.codePointAt(0) ?? 0; const code = character.codePointAt(0) ?? 0;
if (code === 0) { if (code === 0 || code === 0xfeff) {
return false; return false;
} }
if (code >= 0xd800 && code <= 0xdfff) { if (code >= 0xd800 && code <= 0xdfff) {
@@ -359,9 +362,9 @@ export function assertEnvTransportable(name: string, value: string): string {
continue; continue;
} }
throw new Error( throw new Error(
`${name} contains ${describeCodePoint(code)}, which systemd refuses to load from an ` + `${name} contains ${describeCodePoint(code)}, which is forbidden by the documented systemd ` +
`EnvironmentFile: значение обязано быть валидным UTF-8 из Unicode scalar values, ` + `EnvironmentFile contract: значение обязано быть валидным UTF-8 из Unicode scalar values, ` +
`без NUL, без суррогатов и без noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` + `без NUL, U+FEFF, суррогатов и noncharacters (U+FDD0..U+FDEF и *FFFE/*FFFF). ` +
`Файл окружения с таким значением не загрузится, и юнит не стартует.` `Файл окружения с таким значением не загрузится, и юнит не стартует.`
); );
} }
+30 -1
View File
@@ -27,8 +27,37 @@ export async function dirExists(path: string): Promise<boolean> {
return st?.isDirectory() ?? false; 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<string> { export async function readText(path: string): Promise<string> {
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<void> { export async function writeText(path: string, data: string, mode?: number): Promise<void> {
+7 -2
View File
@@ -1,10 +1,15 @@
import { readText } from "./fs"; import { InvalidUtf8Error, readText } from "./fs";
import { runReadOnly } from "./process"; import { runReadOnly } from "./process";
export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> { export async function readPackageValue(packageDir: string, file: string, fallback: string): Promise<string> {
try { try {
return (await readText(`${packageDir}/metadata/${file}`)).trim(); return (await readText(`${packageDir}/metadata/${file}`)).trim();
} catch { } catch (error) {
// Отсутствующая metadata допускает fallback, повреждённая — нет: иначе
// битый пакет выдаёт себя за пакет с корректным запасным значением.
if (error instanceof InvalidUtf8Error) {
throw error;
}
return fallback; return fallback;
} }
} }
+70
View File
@@ -35,6 +35,7 @@
*/ */
import { assertMutationAllowed } from "./guard"; import { assertMutationAllowed } from "./guard";
import { decodeUtf8Lossy } from "./fs";
import { info } from "./log"; import { info } from "./log";
function shellQuote(value: unknown): string { function shellQuote(value: unknown): string {
@@ -190,8 +191,77 @@ export type MutationResult = {
exitCode: number; exitCode: number;
stdout: string; stdout: string;
stderr: string; stderr: string;
stdoutTruncated?: boolean;
stderrTruncated?: boolean;
}; };
async function readDiagnosticPipe(
stream: ReadableStream<Uint8Array>,
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<MutationResult> {
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
};
}
/** /**
* Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска. * Мутация, которая ВОЗВРАЩАЕТ код возврата вместо броска.
* *
@@ -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<string> {
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("<redacted>");
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);
});
});
+20 -10
View File
@@ -148,7 +148,8 @@ describe("запись обратима разбором", () => {
}); });
test("непредставимое значение — отказ записи, а не потеря части секрета", () => { test("непредставимое значение — отказ записи, а не потеря части секрета", () => {
// Отвергается то, что НЕ ЗАГРУЗИТ systemd, а не то, что нам не нравится. // Отвергается документированное множество EnvironmentFile, а не то, что
// нам не нравится.
for (const rejected of [ for (const rejected of [
String.fromCodePoint(0x0000), String.fromCodePoint(0x0000),
String.fromCodePoint(0xfdd0), String.fromCodePoint(0xfdd0),
@@ -157,10 +158,10 @@ describe("запись обратима разбором", () => {
]) { ]) {
expect(() => expect(() =>
assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) assertEnvTransportable("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`)
).toThrow(/systemd refuses to load/); ).toThrow(/documented systemd EnvironmentFile contract/);
expect(() => expect(() =>
formatEnvAssignment("HY2XS_ADMIN_INITIAL_PASSWORD", `abcde${rejected}`) 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(new TextEncoder().encode(lone)).toEqual(new Uint8Array([0xef, 0xbf, 0xbd]));
expect(isEnvTransportable(lone)).toBe(false); expect(isEnvTransportable(lone)).toBe(false);
expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow( expect(() => assertEnvTransportable("HY2XS_ADMIN_CON_PASS", `abcde${lone}`)).toThrow(
/systemd refuses to load/ /documented systemd EnvironmentFile contract/
); );
}); });
// Домен транспорта — ЧУЖОЕ множество, и он не шире и не уже множества systemd. // Домен транспорта — ЧУЖОЕ документированное множество systemd.
// //
// Управляющие символы формат несёт: внутри двойных кавычек перевод строки // Управляющие символы формат несёт: внутри двойных кавычек перевод строки
// накапливается как обычный байт и переживает round-trip. Запрещает их // накапливается как обычный байт и переживает round-trip. Запрещает их
@@ -191,10 +192,9 @@ describe("запись обратима разбором", () => {
expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value); expect(parseEnvFile(rendered).HY2XS_ADMIN_INITIAL_PASSWORD).toBe(value);
} }
// U+FEFF формат тоже несёт: 0xFEFF & 0xFFFE === 0xFEFE, и unichar_is_valid // Публичный контракт EnvironmentFile запрещает U+FEFF. Реализация v257.13
// его принимает. Комментарий `/* BOM */` в исходнике systemd относится к // случайно пропускает его; продукт не закрепляет это расхождение.
// U+xFFFE и является его собственной неточностью. expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(false);
expect(isEnvTransportable(String.fromCodePoint(0xfeff))).toBe(true);
}); });
// Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно // Соседи запрещённых диапазонов обязаны проходить: правило описывает ровно
@@ -260,6 +260,16 @@ describe("непредставимая конфигурация отвергае
).toThrow(); ).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 проходит через // загрузку юнита тем же способом. HY2XS_ADMIN_CON_PASS проходит через
// requireValue и никаких проверок содержимого раньше не имел вовсе. // requireValue и никаких проверок содержимого раньше не имел вовсе.
@@ -271,7 +281,7 @@ describe("непредставимая конфигурация отвергае
"HY2XS_ACME_EMAIL" "HY2XS_ACME_EMAIL"
]) { ]) {
expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow( expect(() => parseRuntimeEnv(envText({ [key]: `"value${noncharacter}"` }))).toThrow(
/systemd refuses to load/ /documented systemd EnvironmentFile contract/
); );
} }
}); });
@@ -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<string> {
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
);
});
});
@@ -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<string> {
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);
}
});
});
+2 -1
View File
@@ -8,6 +8,7 @@
*/ */
import { parseUpstreamHashes } from "../src/build/hysteriaRelease"; import { parseUpstreamHashes } from "../src/build/hysteriaRelease";
import { readText } from "../src/lib/fs";
function fail(message: string): never { function fail(message: string): never {
console.error(`[hy2xs-build] ERROR: ${message}`); 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)); const { file, asset } = parseArgs(Bun.argv.slice(2));
try { try {
const text = await Bun.file(file).text(); const text = await readText(file);
process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`); process.stdout.write(`${parseUpstreamHashes(text, asset)}\n`);
} catch (error) { } catch (error) {
fail(error instanceof Error ? error.message : String(error)); fail(error instanceof Error ? error.message : String(error));
@@ -12,6 +12,7 @@
import { parseRuntimeEnv } from "../src/config/env"; import { parseRuntimeEnv } from "../src/config/env";
import { HYSTERIA_OBFS_TYPES } from "../src/config/profile"; import { HYSTERIA_OBFS_TYPES } from "../src/config/profile";
import { readText } from "../src/lib/fs";
import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config"; import { hysteriaConfigTemplatePath, renderHysteriaConfig } from "../src/steps/config";
import type { RuntimeContext } from "../src/types/context"; import type { RuntimeContext } from "../src/types/context";
@@ -138,7 +139,7 @@ async function main(): Promise<void> {
overrides.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT = options.trafficStatsPort; 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 config = parseRuntimeEnv(overrideEnv(sourceEnv, overrides));
const context = { const context = {
@@ -160,7 +161,7 @@ async function main(): Promise<void> {
hysteriaResolution: "compat-gate" hysteriaResolution: "compat-gate"
} satisfies RuntimeContext; } satisfies RuntimeContext;
const template = await Bun.file(options.templatePath).text(); const template = await readText(options.templatePath);
await Bun.write(options.outPath, renderHysteriaConfig(context, template)); await Bun.write(options.outPath, renderHysteriaConfig(context, template));
} }
+5
View File
@@ -22,6 +22,11 @@
./install.sh --config /root/custom-hy2xs.env --non-interactive ./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. В baseline нет target-side JavaScript, TypeScript, frontend или Go build step.
## Установка выполняется в две фазы ## Установка выполняется в две фазы
+46 -1
View File
@@ -875,7 +875,7 @@ run_admin_login_acceptance() {
|| fail "acceptance: модуль формата env-файлов отсутствует" || fail "acceptance: модуль формата env-файлов отсутствует"
code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \ code_has orchestrator/src/config/env.ts -F -- 'parseEnvFile(content)' \
|| fail "acceptance: разбор hy2xs.env снова расходится с systemd" || 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 снова идёт интерполяцией" || fail "acceptance: запись hy2xs.env снова идёт интерполяцией"
code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \ code_has orchestrator/src/steps/env.ts -F -- 'renderEnvFile(' \
|| fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией" || fail "acceptance: bootstrap-admin.secret снова пишется интерполяцией"
@@ -908,6 +908,8 @@ run_admin_login_acceptance() {
|| fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF" || fail "acceptance: $unicode_domain_owner не знает диапазон noncharacters U+FDD0..U+FDEF"
code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \ code_has "$unicode_domain_owner" -E -- '0[xX][fF][fF][fF][eE]' \
|| fail "acceptance: $unicode_domain_owner не знает noncharacters вида *FFFE/*FFFF" || 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 done
# Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без # Одиночный суррогат в JS-строке TextEncoder молча заменяет на U+FFFD: без
# проверки это была бы не ошибка, а тихая подмена секрета. # проверки это была бы не ошибка, а тихая подмена секрета.
@@ -918,6 +920,24 @@ run_admin_login_acceptance() {
|| fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты" || fail "acceptance: $surrogate_owner не отвергает одиночные суррогаты"
done 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 буквально: в состоянии # Порт автомата обязан повторять upstream буквально: в состоянии
# DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой # DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не проверку на любой
# перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd. # перевод строки, - иначе `\<CR>` мы прочитали бы иначе, чем systemd.
@@ -935,11 +955,36 @@ run_admin_login_acceptance() {
const start = source.indexOf("export function parseRuntimeEnv"); const start = source.indexOf("export function parseRuntimeEnv");
if (start < 0) throw new Error("parseRuntimeEnv отсутствует"); if (start < 0) throw new Error("parseRuntimeEnv отсутствует");
const body = source.slice(start, source.indexOf("\nexport function validateRuntimeConfig")); 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)")) { if (!body.includes("validateRuntimeEnvTransport(config)")) {
throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста"); throw new Error("parseRuntimeEnv не проверяет транспорт: отказ придёт уже после мутаций хоста");
} }
' || fail "acceptance: проверка транспорта не входит в разбор конфигурации" ' || 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" log_step "Acceptance: env files are read as data, never executed"
# Файл научился нести произвольные значения, и это сделало `source` опасным: # Файл научился нести произвольные значения, и это сделало `source` опасным:
# для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение # для systemd `HY2XS_ADMIN_INITIAL_PASSWORD="$(...)"` - буквальное значение
+1 -1
View File
@@ -842,7 +842,7 @@ describe("контракт учётных данных администрато
`abcde${String.fromCodePoint(0xfdd0)}`, `abcde${String.fromCodePoint(0xfdd0)}`,
`abcde${String.fromCodePoint(0xffff)}`, `abcde${String.fromCodePoint(0xffff)}`,
`abcde${String.fromCodePoint(0x1ffff)}`, `abcde${String.fromCodePoint(0x1ffff)}`,
// U+FEFF формат несёт — его отвергает политика продукта. // U+FEFF запрещён публичным транспортным контрактом EnvironmentFile.
`abcde${String.fromCodePoint(0xfeff)}`, `abcde${String.fromCodePoint(0xfeff)}`,
// Одиночный суррогат: без проверки TextEncoder молча подменил бы его на // Одиночный суррогат: без проверки TextEncoder молча подменил бы его на
// U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ. // U+FFFD, то есть на сервер уехал бы другой пароль, а не отказ.