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

This commit is contained in:
2026-09-07 17:25:27 +05:00
parent ab788725cf
commit af9f476658
38 changed files with 759 additions and 159 deletions
+40
View File
@@ -491,6 +491,46 @@ func TestLoginEndpointIssuesTokenForValidCredentials(t *testing.T) {
}
}
// Повреждённые байты и непарные surrogate escape не имеют права превратиться
// в U+FFFD до проверки пароля. Сам U+FFFD остаётся обычным допустимым символом.
func TestLoginEndpointRejectsLossyJSONBeforeAuthentication(t *testing.T) {
password := "abcde" + string(rune(0xFFFD))
id := newAuthTestDB(t, "hy2xsadmin", password)
for _, body := range [][]byte{
[]byte{'{', '"', 'u', 's', 'e', 'r', 'n', 'a', 'm', 'e', '"', ':', '"', 'h', 'y', '2', 'x', 's', 'a', 'd', 'm', 'i', 'n', '"', ',', '"', 'p', 'a', 's', 's', '"', ':', '"', 0xFF, '"', '}'},
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uD800"}`),
[]byte(`{"username":"hy2xsadmin","pass":"abcde\uDC00"}`),
} {
status, result := postLoginRaw(t, body, false)
if status != http.StatusOK {
t.Fatalf("повреждённое тело дало HTTP %d", status)
}
if result.Code != constant.CodeInvalidError {
t.Fatalf("повреждённое тело не отклонено как invalid: %+v", result)
}
var found bool
for _, reason := range result.Errors {
found = found || reason.Code == constant.ErrCodeBodyInvalid
}
if !found {
t.Fatalf("нет причины %q: %+v", constant.ErrCodeBodyInvalid, result)
}
}
admin, err := dao.GetAdminUser("id = ?", id)
if err != nil {
t.Fatalf("не удалось проверить состояние администратора: %v", err)
}
if admin.LastLoginAt != nil && *admin.LastLoginAt != 0 {
t.Fatalf("отклонённое JSON-тело изменило last_login_at: %d", *admin.LastLoginAt)
}
_, valid := postLogin(t, map[string]any{"username": "hy2xsadmin", "pass": password})
if valid.Code != constant.CodeSuccess {
t.Fatalf("настоящий U+FFFD ошибочно отклонён: %+v", valid)
}
}
// Тот же запрос ЗА gin.Recovery: именно так он шёл в бою.
//
// В RC2 здесь был HTTP 500 — панику валидатора Recovery превращал в системную
+1 -1
View File
@@ -11,7 +11,7 @@ import (
func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
if err := strictBindJSON(c, &req); err != nil {
vo.Hysteria2AuthBadRequest(c)
return
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"bytes"
"errors"
"fmt"
"io"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
const jsonBodyMaxBytes = 1024 * 1024
var errJSONBodyTooLarge = errors.New("тело JSON превышает 1 МиБ")
// strictBindJSON не позволяет стандартному encoding/json молча заменить
// повреждённый UTF-8 или непарный UTF-16 surrogate escape на U+FFFD.
//
// Это критично для секретов: U+FFFD сам по себе разрешён, поэтому без этой
// границы другое байтовое значение могло аутентифицироваться как пароль,
// который фактически хранится в базе. После проверки тело восстанавливается и
// передаётся штатному Gin binder, чтобы сохранить действующий API-контракт.
func strictBindJSON(c *gin.Context, dst any) error {
body := c.Request.Body
if body == nil {
return errors.New("пустое тело JSON")
}
raw, err := io.ReadAll(io.LimitReader(body, jsonBodyMaxBytes+1))
_ = body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("не удалось прочитать тело JSON: %w", err)
}
if len(raw) > jsonBodyMaxBytes {
return errJSONBodyTooLarge
}
if err := validateStrictJSONEncoding(raw); err != nil {
return err
}
return c.ShouldBindJSON(dst)
}
// validateStrictJSONEncoding проверяет только границу кодировки. Синтаксис,
// типы и структуру затем проверяет encoding/json; здесь отдельно закрывается
// его документированное replacement-поведение.
func validateStrictJSONEncoding(raw []byte) error {
if !utf8.Valid(raw) {
return errors.New("тело JSON содержит некорректный UTF-8")
}
inString := false
for i := 0; i < len(raw); i++ {
switch raw[i] {
case '"':
inString = !inString
case '\\':
if !inString {
continue
}
i++
if i >= len(raw) {
return errors.New("незавершённая escape-последовательность JSON")
}
if raw[i] != 'u' {
continue
}
code, ok := parseJSONHex4(raw, i+1)
if !ok {
return errors.New("некорректная Unicode escape-последовательность JSON")
}
i += 4
switch {
case code >= 0xD800 && code <= 0xDBFF:
// Старший суррогат допустим только как первая половина пары и
// только в соседней escape-последовательности \uXXXX.
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
low, lowOK := parseJSONHex4(raw, i+3)
if !lowOK || low < 0xDC00 || low > 0xDFFF {
return errors.New("непарный старший UTF-16 суррогат в JSON")
}
i += 6
case code >= 0xDC00 && code <= 0xDFFF:
return errors.New("непарный младший UTF-16 суррогат в JSON")
}
}
}
return nil
}
func parseJSONHex4(raw []byte, start int) (uint16, bool) {
if start < 0 || start+4 > len(raw) {
return 0, false
}
var value uint16
for _, character := range raw[start : start+4] {
value <<= 4
switch {
case character >= '0' && character <= '9':
value += uint16(character - '0')
case character >= 'a' && character <= 'f':
value += uint16(character-'a') + 10
case character >= 'A' && character <= 'F':
value += uint16(character-'A') + 10
default:
return 0, false
}
}
return value, true
}
+31
View File
@@ -0,0 +1,31 @@
package controller
import "testing"
func TestValidateStrictJSONEncoding(t *testing.T) {
tests := []struct {
name string
body []byte
wantErr bool
}{
{"обычный UTF-8", []byte(`{"password":"пароль"}`), false},
{"настоящий U+FFFD", []byte(`{"password":"abcde"}`), false},
{"валидная surrogate pair", []byte(`{"value":"\uD83D\uDE00"}`), false},
{"экранированный обратный слеш", []byte(`{"value":"\\uD800"}`), false},
{"байт FF", []byte{'{', '"', 'x', '"', ':', '"', 0xFF, '"', '}'}, true},
{"оборванный UTF-8", []byte{'{', '"', 'x', '"', ':', '"', 0xC3, '"', '}'}, true},
{"UTF-8 суррогата", []byte{'{', '"', 'x', '"', ':', '"', 0xED, 0xA0, 0x80, '"', '}'}, true},
{"непарный старший", []byte(`{"value":"\uD800"}`), true},
{"старший перед обычным", []byte(`{"value":"\uD800\u0041"}`), true},
{"непарный младший", []byte(`{"value":"\uDC00"}`), true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gotErr := validateStrictJSONEncoding(test.body) != nil
if gotErr != test.wantErr {
t.Fatalf("ошибка=%v, ожидалась=%v", gotErr, test.wantErr)
}
})
}
}
+4
View File
@@ -189,6 +189,10 @@ func ImportPeer(c *gin.Context) {
vo.Fail("the file is too big", c)
return
}
if err = validateStrictJSONEncoding(content); err != nil {
vo.Fail("content contains invalid UTF-8 or UTF-16", c)
return
}
var peerExports []bo.PeerExport
decoder := json.NewDecoder(bytes.NewReader(content))
+20 -2
View File
@@ -17,7 +17,7 @@ import (
// Разбор загруженного файла проверяется без базы: все три отказа наступают до
// первого обращения к слою данных, и это часть контракта — испорченный файл не
// должен доходить до транзакции.
func postImportFile(t *testing.T, fileName string, content string) apiResult {
func postImportBytes(t *testing.T, fileName string, content []byte) apiResult {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
if err != nil {
t.Fatalf("не удалось собрать форму: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
if _, err := part.Write(content); err != nil {
t.Fatalf("не удалось записать файл в форму: %v", err)
}
if err := writer.Close(); err != nil {
@@ -49,6 +49,24 @@ func postImportFile(t *testing.T, fileName string, content string) apiResult {
return result
}
func postImportFile(t *testing.T, fileName string, content string) apiResult {
t.Helper()
return postImportBytes(t, fileName, []byte(content))
}
func TestImportPeerRejectsInvalidUTF8(t *testing.T) {
for _, content := range [][]byte{
{'[', '"', 0xFF, '"', ']'},
{'[', '"', 0xC3, '"', ']'},
{'[', '"', 0xED, 0xA0, 0x80, '"', ']'},
} {
result := postImportBytes(t, "peers.json", content)
if result.Type != "no" {
t.Fatalf("повреждённый UTF-8 принят: %+v", result)
}
}
}
// Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
// половина файла молча не применялась.
+2 -2
View File
@@ -109,7 +109,7 @@ func validateField[T interface{}](c *gin.Context, field T) (T, error) {
case http.MethodGet:
bindErr = c.ShouldBindQuery(&field)
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
bindErr = c.ShouldBindJSON(&field)
bindErr = strictBindJSON(c, &field)
}
if bindErr != nil {
vo.FailValidation(
@@ -246,7 +246,7 @@ func describeFieldError(fieldErr validator.FieldError) vo.FieldError {
"maxBytes": fmt.Sprintf("%d", credential.AdminPasswordMaxBytes),
}
described.Message = fmt.Sprintf(
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов",
"поле %q: от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF",
field,
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
+8 -19
View File
@@ -131,10 +131,9 @@ func IsValidAdminUsername(username string) bool {
// же класс дефекта, ради уничтожения которого контракт и существует, только на
// один слой ниже.
//
// U+FEFF в этот список НЕ входит: `0xFEFF & 0xFFFE` — это `0xFEFE`, и systemd
// такое значение принимает (комментарий `/* BOM */` в его исходнике относится к
// U+xFFFE и является неточностью самого systemd). Запрет U+FEFF — отдельное
// продуктовое решение, см. hasForbiddenRune.
// Публичная документация systemd также запрещает U+FEFF. Реализация v257.13
// случайно пропускает его из-за маски в `unichar_is_valid`; продукт следует
// документированному контракту, а не этому расхождению реализации.
func isUnicodeNoncharacter(r rune) bool {
if r >= 0xFDD0 && r <= 0xFDEF {
return true
@@ -144,8 +143,7 @@ func isUnicodeNoncharacter(r rune) bool {
// IsEnvTransportableText сообщает, переживёт ли значение файл окружения.
//
// Это ДОМЕН systemd, а не политика HY2XS: здесь перечислено ровно то, что
// `utf8_is_valid` отвергает, и ничего сверх того. Продуктовые ограничения
// Это документированный ДОМЕН systemd, а не политика HY2XS. Продуктовые ограничения
// (границы длины, запрет управляющих символов) живут отдельно — иначе
// невозможно объяснить, какое из двух правил нарушено и чем это грозит.
func IsEnvTransportableText(value string) bool {
@@ -159,7 +157,7 @@ func IsEnvTransportableText(value string) bool {
for _, r := range value {
// NUL — валидный UTF-8, но границей строки в C; systemd отвергает его
// отдельной веткой (`embedded NUL`).
if r == 0 {
if r == 0 || r == 0xFEFF {
return false
}
if isUnicodeNoncharacter(r) {
@@ -178,7 +176,7 @@ func IsEnvTransportableText(value string) bool {
// (IsEnvTransportableText);
// 6-64 code points — граница, которую видит оператор;
// <= 72 UTF-8 bytes — граница, которую ставит bcrypt;
// Cc и U+FEFF — продуктовая политика, см. hasForbiddenRune.
// Cc — продуктовая политика, см. hasForbiddenRune.
//
// Длина в СИМВОЛАХ, а не в байтах, потому что именно так её считает
// go-playground/validator (`utf8.RuneCountInString` в baked_in.go) и так её
@@ -226,22 +224,13 @@ func IsValidAdminPassword(password string) bool {
// символов» — то есть документация была шире кода ровно на C1. Проверяется
// теперь то, что обещано.
//
// И отдельно U+FEFF. Формат его несёт: `0xFEFF & 0xFFFE` — это `0xFEFE`, и
// `unichar_is_valid` такое значение ПРИНИМАЕТ (комментарий `/* BOM */` в
// исходнике systemd относится к U+xFFFE и является его собственной
// неточностью). Запрет здесь — решение продукта: невидимый символ нулевой
// ширины в пароле нельзя ни увидеть в поле ввода, ни повторить при следующем
// входе, то есть он умеет ровно одно — запереть оператора снаружи. Ровно тем же
// обоснованием запрещены и управляющие символы: перевод строки невозможно
// ввести в однострочное поле формы входа.
// U+FEFF здесь уже не проверяется: он принадлежит документированному домену
// EnvironmentFile и отвергается IsEnvTransportableText.
func hasForbiddenRune(value string) bool {
for _, r := range value {
if r < 0x20 || (r >= 0x7F && r <= 0x9F) {
return true
}
if r == 0xFEFF {
return true
}
}
return false
}
+14 -13
View File
@@ -146,8 +146,7 @@ func TestAdminPasswordBoundaries(t *testing.T) {
{"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true},
{"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true},
// U+FEFF формат несёт (0xFEFF & 0xFFFE == 0xFEFE), и отвергается он
// продуктовой политикой, а не systemd.
// U+FEFF отвергается документированным транспортным доменом systemd.
{"U+FEFF", "abcde" + string(rune(0xFEFF)), false},
}
@@ -187,18 +186,18 @@ func TestAdminPasswordLimitsAreIndependent(t *testing.T) {
}
}
// Домен транспорта повторяет множество systemd ТОЧНО — не шире и не уже.
// Домен транспорта повторяет публично документированное множество systemd.
//
// Шире означало бы, что HY2XS отвергает значение, которое systemd загрузил бы;
// уже — что значение проходит все двери продукта и делает файл окружения
// незагружаемым, а юнит — не стартующим. Второе и было дефектом: контракт знал
// про управляющие символы и не знал про noncharacters.
// Шире означало бы собственный запрет сверх публичного контракта; уже — что
// значение проходит все двери продукта вопреки документации EnvironmentFile.
// Второе и было дефектом: контракт знал про управляющие символы и не знал про
// noncharacters.
//
// Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются
// вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность.
func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) {
rejected := []rune{
0x0000, // embedded NUL — отдельная ветка utf8_is_valid
0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом
0xFDD0, 0xFDEF, // U+FDD0..U+FDEF
0xFFFE, 0xFFFF,
0x1FFFE, 0x1FFFF,
@@ -206,21 +205,20 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
}
for _, r := range rejected {
if IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X принят: systemd откажется загружать такой EnvironmentFile", r)
t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r)
}
}
accepted := []rune{
0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта
0xFDCF, 0xFDF0, // соседи зарезервированного диапазона
0xFEFF, // 0xFEFF & 0xFFFE == 0xFEFE — systemd принимает
0xFFFD, // replacement character — обычный символ
0x10FFFD, // последний scalar value, не noncharacter
0x1F600, // 😀
}
for _, r := range accepted {
if !IsEnvTransportableText(string(r)) {
t.Errorf("U+%04X отвергнут: правило шире множества systemd", r)
t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r)
}
}
@@ -239,7 +237,7 @@ func TestEnvTransportDomainMatchesSystemd(t *testing.T) {
// одно из двух правил отсутствует.
func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
// Управляющие символы формат несёт — их запрещает HY2XS.
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F, 0xFEFF} {
for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} {
password := "abcde" + string(r)
if !IsEnvTransportableText(password) && r != 0x00 {
t.Errorf("U+%04X: ожидалось, что формат его несёт", r)
@@ -248,6 +246,9 @@ func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) {
t.Errorf("U+%04X принят паролем вопреки политике продукта", r)
}
}
if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) {
t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile")
}
}
// Пароль НЕ триммится нигде, включая сам контракт.
+1 -1
View File
@@ -163,7 +163,7 @@ func ensureSecureBootstrapAdmin() error {
if !credential.IsValidAdminPassword(adminPassword) {
return fmt.Errorf(
"HY2XS_ADMIN_INITIAL_PASSWORD не удовлетворяет контракту панели: "+
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов.\n"+
"от %d до %d символов, не более %d байт в UTF-8, без управляющих символов и U+FEFF.\n"+
"Набор символов не ограничен, пробелы по краям являются частью пароля.\n"+
"Учётная запись администратора с таким значением создана НЕ будет: "+
"установка завершилась бы успешно, а войти в панель было бы нельзя.\n"+
+7 -5
View File
@@ -82,8 +82,9 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
* `/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`, и
* перед принятием пары прогоняет значение через `utf8_is_valid`. Отказ там —
* это не предупреждение, а незагруженный файл окружения и, следовательно,
* юнит, который не стартует. Отвергаются NUL, суррогаты, `U+FDD0..U+FDEF` и все
* code points вида `*FFFE`/`*FFFF`. Панель обязана знать это множество: иначе
* юнит, который не стартует. Публичный контракт отвергает NUL, U+FEFF,
* суррогаты, `U+FDD0..U+FDEF` и все code points вида `*FFFE`/`*FFFF`. Реализация
* v257.13 случайно пропускает U+FEFF; панель следует документации. Иначе
* она предложила бы оператору назначить пароль, после которого сервер
* перестанет подниматься.
*
@@ -93,7 +94,7 @@ export const ADMIN_PASSWORD_MAX_BYTES = 72;
* не отказ.
*
* Второе — продуктовая политика: управляющие символы Unicode целиком (Cc — C0,
* DEL, C1) и U+FEFF. Формат их несёт, запрещает их HY2XS: ни один из них
* DEL, C1). U+FEFF запрещён документированным транспортным доменом. Ни один из них
* невозможно ни увидеть в поле ввода, ни повторить при следующем входе, то есть
* они умеют ровно одно — запереть оператора снаружи.
*
@@ -109,6 +110,7 @@ export function isValidAdminPassword(value: string): boolean {
// Домен systemd: значение вне него не доедет до сервера вовсе.
if (
code === 0 ||
code === 0xfeff ||
(code >= 0xd800 && code <= 0xdfff) ||
(code >= 0xfdd0 && code <= 0xfdef) ||
(code & 0xfffe) === 0xfffe
@@ -116,8 +118,8 @@ export function isValidAdminPassword(value: string): boolean {
return false;
}
// Политика HY2XS: Cc и U+FEFF.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0xfeff) {
// Политика HY2XS: Cc. U+FEFF уже отвергнут транспортной веткой.
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
return false;
}
+2 -2
View File
@@ -27,7 +27,7 @@ export default {
credentials: {
usernameFormat: "Username: {min} to {max} characters from {charset}",
passwordFormat:
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
"Password: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
},
dashboard: {
stale: "Dashboard data is stale. Retrying automatically...",
@@ -168,7 +168,7 @@ export default {
"“{field}”: {min} to {max} characters from {charset}. Spaces, non-latin letters and / : ; . are not allowed",
credential_format: "“{field}”: {min} to {max} characters from {charset}",
admin_password_format:
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters",
"“{field}”: {min} to {max} characters and at most {maxBytes} UTF-8 bytes, no control characters or U+FEFF",
rule_violated: "“{field}”: value is not acceptable",
validation_failed: "Validation failed",
body_invalid:
+2 -2
View File
@@ -36,7 +36,7 @@ export default {
// байта; сообщение «не длиннее {max} символов» отправило бы оператора
// сокращать пароль, отвергнутый не за это.
passwordFormat:
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
"Пароль: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
},
dashboard: {
stale:
@@ -189,7 +189,7 @@ export default {
credential_format:
"«{field}»: от {min} до {max} символов из набора {charset}",
admin_password_format:
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов",
"«{field}»: от {min} до {max} символов и не более {maxBytes} байт в UTF-8, без управляющих символов и U+FEFF",
rule_violated: "«{field}»: значение не подходит",
validation_failed: "Проверка данных не пройдена",
body_invalid: "Запрос не разобран: проверьте формат и типы полей",
+1 -1
View File
@@ -59,7 +59,7 @@ func HashPassword(password string) (string, error) {
if !credential.IsValidAdminPassword(password) {
return "", fmt.Errorf(
"пароль администратора не удовлетворяет контракту: от %d до %d символов, "+
"не более %d байт в UTF-8, без управляющих символов",
"не более %d байт в UTF-8, без управляющих символов и U+FEFF",
credential.AdminPasswordMinLength,
credential.AdminPasswordMaxLength,
credential.AdminPasswordMaxBytes,