fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -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`. Основная тема — сделать политику
|
||||||
«только чистая установка» настоящим системным инвариантом, а не строчкой в
|
«только чистая установка» настоящим системным инвариантом, а не строчкой в
|
||||||
документации.
|
документации.
|
||||||
|
|||||||
@@ -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 не
|
||||||
|
декодируется с заменой и не попадает в архив; вместо него записывается
|
||||||
|
безопасная причина пропуска.
|
||||||
|
|
||||||
## Проверка безопасности после установки
|
## Проверка безопасности после установки
|
||||||
|
|
||||||
Минимальный набор проверок:
|
Минимальный набор проверок:
|
||||||
|
|||||||
@@ -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 превращал в системную
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||||
// половина файла молча не применялась.
|
// половина файла молча не применялась.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"+
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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: "Запрос не разобран: проверьте формат и типы полей",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 байт, и он отвергается, хотя в границу символов
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` видят
|
||||||
его одинаково.
|
его одинаково.
|
||||||
|
|||||||
@@ -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` на таблице маршрутов собранного роутера —
|
||||||
и существование этого теста само проверяется контрактом.
|
и существование этого теста само проверяется контрактом.
|
||||||
|
|
||||||
|
|||||||
@@ -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-санитайзеры описывают один контракт и покрыты зеркальными тестами:
|
||||||
граница определяется значением, а не именем ключа.
|
граница определяется значением, а не именем ключа.
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")} байт. ` +
|
||||||
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
`Набор символов не ограничен, пробелы по краям являются частью пароля. ` +
|
||||||
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
`Установка с другим значением завершилась бы успешно, а войти в панель было бы нельзя.`
|
||||||
|
|||||||
@@ -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). ` +
|
||||||
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
|
`Файл окружения с таким значением не загрузится, и юнит не стартует.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
## Установка выполняется в две фазы
|
## Установка выполняется в две фазы
|
||||||
|
|||||||
@@ -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="$(...)"` - буквальное значение
|
||||||
|
|||||||
@@ -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, то есть на сервер уехал бы другой пароль, а не отказ.
|
||||||
|
|||||||
Reference in New Issue
Block a user