From 62803147f0e68a6d2013302c5e2288a916c89066 Mon Sep 17 00:00:00 2001 From: jholdstock Date: Thu, 2 Mar 2023 12:40:44 +0000 Subject: [PATCH] webapi: Rate limit for admin login requests. Only allow 3 requests per second. Return "429 Too Many Requests" when this rate is exceeded. --- go.mod | 1 + go.sum | 2 ++ webapi/admin.go | 8 ++++---- webapi/middleware.go | 39 ++++++++++++++++++++++++++++++++++++- webapi/templates/login.html | 4 ++-- webapi/webapi.go | 14 +++++++++++-- 6 files changed, 59 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b7cc11e..bf219e1 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( golang.org/x/net v0.6.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect + golang.org/x/time v0.3.0 google.golang.org/protobuf v1.28.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index b28a821..393b557 100644 --- a/go.sum +++ b/go.sum @@ -242,6 +242,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/webapi/admin.go b/webapi/admin.go index 5cd4c59..f9c37d4 100644 --- a/webapi/admin.go +++ b/webapi/admin.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2022 The Decred developers +// Copyright (c) 2020-2023 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -200,9 +200,9 @@ func (s *Server) adminLogin(c *gin.Context) { if password != s.cfg.AdminPass { s.log.Warnf("Failed login attempt from %s", c.ClientIP()) c.HTML(http.StatusUnauthorized, "login.html", gin.H{ - "WebApiCache": s.cache.getData(), - "WebApiCfg": s.cfg, - "IncorrectPassword": true, + "WebApiCache": s.cache.getData(), + "WebApiCfg": s.cfg, + "FailedLoginMsg": "Incorrect password", }) return } diff --git a/webapi/middleware.go b/webapi/middleware.go index 3a47a0b..48362d6 100644 --- a/webapi/middleware.go +++ b/webapi/middleware.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2022 The Decred developers +// Copyright (c) 2020-2023 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -10,6 +10,8 @@ import ( "io" "net/http" "strings" + "sync" + "time" "github.com/decred/dcrd/blockchain/stake/v4" "github.com/decred/vspd/rpc" @@ -18,8 +20,43 @@ import ( "github.com/gin-gonic/gin/binding" "github.com/gorilla/sessions" "github.com/jrick/wsrpc/v2" + "golang.org/x/time/rate" ) +// rateLimit middleware limits how many requests each client IP can submit per +// second. If the limit is exceeded the limitExceeded handler will be executed +// and the context will be aborted. +func rateLimit(limit rate.Limit, limitExceeded gin.HandlerFunc) gin.HandlerFunc { + var limitersMtx sync.Mutex + limiters := make(map[string]*rate.Limiter) + + // Clear limiters every hour so they arent accumulating infinitely. + go func() { + for { + <-time.After(time.Hour) + limitersMtx.Lock() + limiters = make(map[string]*rate.Limiter) + limitersMtx.Unlock() + } + }() + + return func(c *gin.Context) { + limitersMtx.Lock() + defer limitersMtx.Unlock() + + // Create a limiter for this IP if one does not exist. + if _, ok := limiters[c.ClientIP()]; !ok { + limiters[c.ClientIP()] = rate.NewLimiter(limit, 1) + } + + // Check if this IP exceeds limit. + if !limiters[c.ClientIP()].Allow() { + limitExceeded(c) + c.Abort() + } + } +} + // withSession middleware adds a gorilla session to the request context for // downstream handlers to make use of. Sessions are used by admin pages to // maintain authentication status. diff --git a/webapi/templates/login.html b/webapi/templates/login.html index 915be69..f008371 100644 --- a/webapi/templates/login.html +++ b/webapi/templates/login.html @@ -4,9 +4,9 @@

Login

- + -

Incorrect password

+

{{ .FailedLoginMsg }}

diff --git a/webapi/webapi.go b/webapi/webapi.go index d267ba1..d1aaf44 100644 --- a/webapi/webapi.go +++ b/webapi/webapi.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2022 The Decred developers +// Copyright (c) 2020-2023 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -249,7 +249,17 @@ func (s *Server) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W login := router.Group("/admin").Use( s.withSession(cookieStore), ) - login.POST("", s.adminLogin) + + // Limit login attempts to 3 per second. + loginRateLmiter := rateLimit(3, func(c *gin.Context) { + s.log.Warnf("Login rate limit exceeded by %s", c.ClientIP()) + c.HTML(http.StatusTooManyRequests, "login.html", gin.H{ + "WebApiCache": s.cache.getData(), + "WebApiCfg": s.cfg, + "FailedLoginMsg": "Rate limit exceeded", + }) + }) + login.POST("", loginRateLmiter, s.adminLogin) admin := router.Group("/admin").Use( s.withWalletClients(wallets), s.withSession(cookieStore), s.requireAdmin,