Blocked
+{error}
+nezha WAF
+diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go index 720466a..2efd00a 100644 --- a/cmd/dashboard/controller/controller.go +++ b/cmd/dashboard/controller/controller.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net/http" - "net/netip" "os" "path/filepath" "strings" @@ -16,6 +15,7 @@ import ( swaggerfiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" + "github.com/naiba/nezha/cmd/dashboard/controller/waf" docs "github.com/naiba/nezha/cmd/dashboard/docs" "github.com/naiba/nezha/model" "github.com/naiba/nezha/service/singleton" @@ -34,39 +34,14 @@ func ServeWeb() http.Handler { r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) } - r.Use(realIp) + r.Use(waf.RealIp) + r.Use(waf.Waf) r.Use(recordPath) routers(r) return r } -func realIp(c *gin.Context) { - if singleton.Conf.RealIPHeader == "" { - c.Next() - return - } - - if singleton.Conf.RealIPHeader == model.ConfigUsePeerIP { - c.Set(model.CtxKeyRealIPStr, c.RemoteIP()) - c.Next() - return - } - - vals := c.Request.Header.Get(singleton.Conf.RealIPHeader) - if vals == "" { - c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: "real ip header not found"}) - return - } - ip, err := netip.ParseAddr(vals) - if err != nil { - c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: err.Error()}) - return - } - c.Set(model.CtxKeyRealIPStr, ip.String()) - c.Next() -} - func routers(r *gin.Engine) { authMiddleware, err := jwt.New(initParams()) if err != nil { @@ -154,7 +129,6 @@ func routers(r *gin.Engine) { } func recordPath(c *gin.Context) { - log.Printf("bingo web real ip: %s", c.GetString(model.CtxKeyRealIPStr)) url := c.Request.URL.String() for _, p := range c.Params { url = strings.Replace(url, p.Value, ":"+p.Key, 1) diff --git a/cmd/dashboard/controller/jwt.go b/cmd/dashboard/controller/jwt.go index bc5ec4e..349ca9f 100644 --- a/cmd/dashboard/controller/jwt.go +++ b/cmd/dashboard/controller/jwt.go @@ -9,6 +9,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" + "github.com/naiba/nezha/cmd/dashboard/controller/waf" "github.com/naiba/nezha/model" "github.com/naiba/nezha/pkg/utils" "github.com/naiba/nezha/service/singleton" @@ -87,10 +88,12 @@ func authenticator() func(c *gin.Context) (interface{}, error) { var user model.User if err := singleton.DB.Select("id", "password").Where("username = ?", loginVals.Username).First(&user).Error; err != nil { + model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeLoginFail) return nil, jwt.ErrFailedAuthentication } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(loginVals.Password)); err != nil { + model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeLoginFail) return nil, jwt.ErrFailedAuthentication } @@ -163,6 +166,10 @@ func optionalAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) { identity := mw.IdentityHandler(c) if identity != nil { + if err := model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeBruteForceToken); err != nil { + waf.ShowBlockPage(c, err) + return + } c.Set(mw.IdentityKey, identity) } diff --git a/cmd/dashboard/controller/waf/waf.go b/cmd/dashboard/controller/waf/waf.go new file mode 100644 index 0000000..4a559ea --- /dev/null +++ b/cmd/dashboard/controller/waf/waf.go @@ -0,0 +1,90 @@ +package waf + +import ( + _ "embed" + "errors" + "log" + "math/big" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/naiba/nezha/model" + "github.com/naiba/nezha/service/singleton" + "gorm.io/gorm" +) + +//go:embed waf.html +var errorPageTemplate string + +func RealIp(c *gin.Context) { + if singleton.Conf.RealIPHeader == "" { + c.Next() + return + } + + if singleton.Conf.RealIPHeader == model.ConfigUsePeerIP { + c.Set(model.CtxKeyRealIPStr, c.RemoteIP()) + c.Next() + return + } + + vals := c.Request.Header.Get(singleton.Conf.RealIPHeader) + if vals == "" { + c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: "real ip header not found"}) + return + } + ip, err := netip.ParseAddr(vals) + if err != nil { + c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: err.Error()}) + return + } + c.Set(model.CtxKeyRealIPStr, ip.String()) + c.Next() +} + +func Waf(c *gin.Context) { + if singleton.Conf.RealIPHeader == "" { + c.Next() + return + } + realipAddr := c.GetString(model.CtxKeyRealIPStr) + if realipAddr == "" { + c.Next() + return + } + var w model.WAF + if err := singleton.DB.First(&w, "ip = ?", realipAddr).Error; err != nil { + if err != gorm.ErrRecordNotFound { + ShowBlockPage(c, err) + return + } + } + now := time.Now().Unix() + if w.LastBlockTimestamp+pow(w.Count, 4) > uint64(now) { + log.Println(w.Count, w.LastBlockTimestamp+pow(w.Count, 4)-uint64(now)) + ShowBlockPage(c, errors.New("you are blocked by nezha WAF")) + return + } + c.Next() +} + +func pow(x, y uint64) uint64 { + base := big.NewInt(0).SetUint64(x) + exp := big.NewInt(0).SetUint64(y) + result := big.NewInt(1) + result.Exp(base, exp, nil) + if !result.IsUint64() { + return ^uint64(0) // return max uint64 value on overflow + } + return result.Uint64() +} + +func ShowBlockPage(c *gin.Context, err error) { + c.Writer.WriteHeader(http.StatusForbidden) + c.Header("Content-Type", "text/html; charset=utf-8") + c.Writer.WriteString(strings.Replace(errorPageTemplate, "{error}", err.Error(), 1)) + c.Abort() +} diff --git a/cmd/dashboard/controller/waf/waf.html b/cmd/dashboard/controller/waf/waf.html new file mode 100644 index 0000000..c278295 --- /dev/null +++ b/cmd/dashboard/controller/waf/waf.html @@ -0,0 +1,39 @@ + + + +
+ + +{error}
+nezha WAF
+