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,