fix(utf8): закрыть lossy-границы конфигурации и API
This commit is contained in:
@@ -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 превращал в системную
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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))
|
||||
|
||||
@@ -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 читает ПЕРВЫЙ документ и останавливается. Файл с
|
||||
// хвостом принимался целиком, оператор видел «импорт выполнен», а вторая
|
||||
// половина файла молча не применялась.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user