fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.
verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.
Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.
Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.
Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.
updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.
Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.
Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
достижима с неаутентифицированного запроса; набор алгоритмов подписи
зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
проверявшая меньше, чем middleware.
Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
This commit is contained in:
+87
-36
@@ -2,59 +2,110 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/bo"
|
||||
"hy2xs-admin/model/constant"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Токены подписываются и проверяются библиотекой golang-jwt/jwt/v5.
|
||||
//
|
||||
// Переход с v3 не косметический. У `github.com/golang-jwt/jwt` v3.2.2 есть
|
||||
// GO-2025-3553 (чрезмерное выделение памяти при разборе заголовка), и у неё
|
||||
// НЕТ исправленной версии в ветке v3: `Fixed in: N/A`. Уязвимый код
|
||||
// достигается из ParseToken, то есть с неаутентифицированного запроса — любой,
|
||||
// кто может обратиться к панели, попадает в него до всякой проверки прав.
|
||||
// Обновить здесь было нечего, поэтому лечится только сменой мажорной ветки.
|
||||
//
|
||||
// Заодно закрыт второй, тихий недостаток прежней реализации: keyfunc возвращал
|
||||
// ключ, НЕ проверяя алгоритм подписи. Разбор без ограничения набора алгоритмов
|
||||
// — это тот класс ошибок, из которого растёт подмена алгоритма; ниже стоит
|
||||
// jwt.WithValidMethods, и допустимый алгоритм ровно один.
|
||||
|
||||
const TokenExpireDuration = time.Hour * 24
|
||||
|
||||
// tokenIssuer попадает и в подпись, и в проверку: токен, выписанный не нами,
|
||||
// не должен разбираться как наш даже при совпадении ключа.
|
||||
const tokenIssuer = "hy2xs-admin"
|
||||
|
||||
type MyClaims struct {
|
||||
Admin bo.AccountBo `json:"admin"`
|
||||
jwt.StandardClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||
c := MyClaims{
|
||||
Admin: accountBo,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
|
||||
Issuer: "hy2xs-admin",
|
||||
},
|
||||
}
|
||||
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
||||
if err != nil {
|
||||
return "", errors.New(constant.SysError)
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
||||
return token.SignedString([]byte(*config.Value))
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
func jwtSecret() ([]byte, error) {
|
||||
config, err := dao.GetConfig("key = ?", constant.JwtSecret)
|
||||
if err != nil {
|
||||
return nil, errors.New(constant.SysError)
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(tokenString, &MyClaims{}, func(token *jwt.Token) (i interface{}, err error) {
|
||||
return []byte(*config.Value), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
if config.Value == nil || strings.TrimSpace(*config.Value) == "" {
|
||||
// Подпись пустым ключом дала бы токены, которые подделывает кто угодно.
|
||||
// Раньше значение разыменовывалось без проверки: пустой JWT_SECRET
|
||||
// молча превращался в ключ нулевой длины.
|
||||
return nil, errors.New(constant.SysError)
|
||||
}
|
||||
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, errors.New(constant.TokenExpiredError)
|
||||
return []byte(*config.Value), nil
|
||||
}
|
||||
|
||||
func GetToken(c *gin.Context) string {
|
||||
tokenStr := c.Request.Header.Get("Authorization")
|
||||
if tokenStr == "" {
|
||||
return ""
|
||||
func GenToken(accountBo bo.AccountBo) (string, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.SplitN(tokenStr, " ", 2)[1]
|
||||
|
||||
now := time.Now()
|
||||
claims := MyClaims{
|
||||
Admin: accountBo,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(TokenExpireDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
Issuer: tokenIssuer,
|
||||
},
|
||||
}
|
||||
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*MyClaims, error) {
|
||||
secret, err := jwtSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&MyClaims{},
|
||||
func(*jwt.Token) (interface{}, error) { return secret, nil },
|
||||
// Допустимый алгоритм ровно один. Без этого ограничения набор
|
||||
// алгоритмов задаёт сам токен, то есть недоверенный вход.
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithIssuer(tokenIssuer),
|
||||
// Токен без срока жизни не должен считаться валидным бессрочным.
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, errors.New(constant.TokenExpiredError)
|
||||
}
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*MyClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New(constant.IllegalTokenError)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Функции GetToken здесь больше нет.
|
||||
//
|
||||
// Она доставала токен из заголовка Authorization вторым, независимым от
|
||||
// middleware.JWTHandler способом — и делала это через
|
||||
// strings.SplitN(tokenStr, " ", 2)[1], то есть паниковала на заголовке без
|
||||
// пробела: `Authorization: abc` давал index out of range. Единственный
|
||||
// потребитель — резервная ветка GetAdminInfo, которая была недостижима и
|
||||
// проверяла меньше, чем middleware. Разбор токена у продукта ровно один.
|
||||
|
||||
Reference in New Issue
Block a user