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 }