VYPR
Critical severityNVD Advisory· Published Jul 5, 2022· Updated Aug 3, 2024

Improper Restriction of Excessive Authentication Attempts in heroiclabs/nakama

CVE-2022-2321

Description

Nakama before 3.13.0 lacked rate limiting on login attempts, enabling brute-force attacks; a fix added IP-based and account-based lockout.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nakama before 3.13.0 lacked rate limiting on login attempts, enabling brute-force attacks; a fix added IP-based and account-based lockout.

Vulnerability

CVE-2022-2321 is an improper restriction of excessive authentication attempts in the Nakama game backend server (versions prior to 3.13.0). The server did not enforce any rate limiting or lockout mechanism on the console login endpoint, allowing an attacker to submit an unlimited number of authentication requests without penalty [1][2].

Exploitation

An attacker can exploit this by repeatedly sending login requests with different credentials, either from a single IP or distributed across multiple IPs. The initial fix introduced a login attempt cache that tracks failed attempts per username and IP address [2]. However, as noted in the pull request discussion, the IP extraction logic used the first element of the X-Forwarded-For header, which is user-controlled, allowing an attacker to spoof their IP and bypass the rate limiter [4]. Additionally, storing arbitrarily long usernames in the cache could lead to memory exhaustion [4].

Impact

Successful brute-force attacks could allow an attacker to guess valid console administrator credentials, gaining unauthorized access to the Nakama console and potentially compromising the entire game backend, including user data, matchmaking, and server configuration [1][3].

Mitigation

The vulnerability is fixed in Nakama version 3.13.0 [2][3]. Users should upgrade immediately. As a workaround, administrators can implement external rate limiting or firewall rules to restrict login attempts, and ensure that reverse proxies strip or validate the X-Forwarded-For header to prevent spoofing [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/heroiclabs/nakama/v3Go
<= 3.12.0

Affected products

2

Patches

1
e2e02fce80ff

Better limit for unsuccessful login attempts on the devconsole. (#878)

https://github.com/heroiclabs/nakamaFernando TakagiJul 5, 2022via ghsa
4 files changed · +220 7
  • main.go+3 1 modified
    @@ -141,6 +141,7 @@ func main() {
     	sessionRegistry := server.NewLocalSessionRegistry(metrics)
     	sessionCache := server.NewLocalSessionCache(config.GetSession().TokenExpirySec)
     	consoleSessionCache := server.NewLocalSessionCache(config.GetConsole().TokenExpirySec)
    +	loginAttemptCache := server.NewLocalLoginAttemptCache()
     	statusRegistry := server.NewStatusRegistry(logger, config, sessionRegistry, jsonpbMarshaler)
     	tracker := server.StartLocalTracker(logger, config, sessionRegistry, statusRegistry, metrics, jsonpbMarshaler)
     	router := server.NewLocalMessageRouter(sessionRegistry, tracker, jsonpbMarshaler)
    @@ -166,7 +167,7 @@ func main() {
     	statusHandler := server.NewLocalStatusHandler(logger, sessionRegistry, matchRegistry, tracker, metrics, config.GetName())
     
     	apiServer := server.StartApiServer(logger, startupLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, leaderboardCache, leaderboardRankCache, sessionRegistry, sessionCache, statusRegistry, matchRegistry, matchmaker, tracker, router, streamManager, metrics, pipeline, runtime)
    -	consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie)
    +	consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, loginAttemptCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie)
     
     	gaenabled := len(os.Getenv("NAKAMA_TELEMETRY")) < 1
     	const gacode = "UA-89792135-1"
    @@ -232,6 +233,7 @@ func main() {
     	sessionCache.Stop()
     	sessionRegistry.Stop()
     	metrics.Stop(logger)
    +	loginAttemptCache.Stop()
     
     	if gaenabled {
     		_ = ga.SendSessionStop(telemetryClient, gacode, cookie)
    
  • server/console_authenticate.go+40 5 modified
    @@ -20,17 +20,17 @@ import (
     	"database/sql"
     	"errors"
     	"fmt"
    -	"github.com/gofrs/uuid"
    -	"google.golang.org/protobuf/types/known/emptypb"
     	"time"
     
    +	"github.com/gofrs/uuid"
     	jwt "github.com/golang-jwt/jwt/v4"
     	"github.com/heroiclabs/nakama/v3/console"
     	"github.com/jackc/pgtype"
     	"go.uber.org/zap"
     	"golang.org/x/crypto/bcrypt"
     	"google.golang.org/grpc/codes"
     	"google.golang.org/grpc/status"
    +	"google.golang.org/protobuf/types/known/emptypb"
     )
     
     type ConsoleTokenClaims struct {
    @@ -71,6 +71,11 @@ func parseConsoleToken(hmacSecretByte []byte, tokenString string) (id, username,
     }
     
     func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.AuthenticateRequest) (*console.ConsoleSession, error) {
    +	ip, _ := extractClientAddressFromContext(s.logger, ctx)
    +	if !s.loginAttemptCache.Allow(in.Username, ip) {
    +		return nil, status.Error(codes.ResourceExhausted, "Try again later.")
    +	}
    +
     	role := console.UserRole_USER_ROLE_UNKNOWN
     	var uname string
     	var email string
    @@ -81,10 +86,20 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica
     			role = console.UserRole_USER_ROLE_ADMIN
     			uname = in.Username
     			id = uuid.Nil
    +		} else {
    +			if lockout, until := s.loginAttemptCache.Add(s.config.GetConsole().Username, ip); lockout != LockoutTypeNone {
    +				switch lockout {
    +				case LockoutTypeAccount:
    +					s.logger.Info(fmt.Sprintf("Console admin account locked until %v.", until))
    +				case LockoutTypeIp:
    +					s.logger.Info(fmt.Sprintf("Console admin IP locked until %v.", until))
    +				}
    +			}
    +			return nil, status.Error(codes.Unauthenticated, "Invalid credentials.")
     		}
     	default:
     		var err error
    -		id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password)
    +		id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password, ip)
     		if err != nil {
     			return nil, err
     		}
    @@ -94,7 +109,10 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica
     		return nil, status.Error(codes.Unauthenticated, "Invalid credentials.")
     	}
     
    +	s.loginAttemptCache.Reset(uname)
    +
     	exp := time.Now().UTC().Add(time.Duration(s.config.GetConsole().TokenExpirySec) * time.Second).Unix()
    +
     	token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ConsoleTokenClaims{
     		ExpiresAt: exp,
     		ID:        id.String(),
    @@ -132,19 +150,28 @@ func (s *ConsoleServer) AuthenticateLogout(ctx context.Context, in *console.Auth
     	return &emptypb.Empty{}, nil
     }
     
    -func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) {
    +func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password, ip string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) {
     	role = console.UserRole_USER_ROLE_UNKNOWN
     	query := "SELECT id, username, email, role, password, disable_time FROM console_user WHERE username = $1 OR email = $1"
     	var dbPassword []byte
     	var dbDisableTime pgtype.Timestamptz
     	err = s.db.QueryRowContext(ctx, query, unameOrEmail).Scan(&id, &uname, &email, &role, &dbPassword, &dbDisableTime)
     	if err != nil {
     		if err == sql.ErrNoRows {
    -			err = nil
    +			if lockout, until := s.loginAttemptCache.Add("", ip); lockout == LockoutTypeIp {
    +				s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until))
    +			}
    +			err = status.Error(codes.Unauthenticated, "Invalid credentials.")
     		}
     		return
     	}
     
    +	// Check lockout again as the login attempt may have been through email.
    +	if !s.loginAttemptCache.Allow(uname, ip) {
    +		err = status.Error(codes.ResourceExhausted, "Try again later.")
    +		return
    +	}
    +
     	// Check if it's disabled.
     	if dbDisableTime.Status == pgtype.Present && dbDisableTime.Time.Unix() != 0 {
     		s.logger.Info("Console user account is disabled.", zap.String("username", unameOrEmail))
    @@ -155,6 +182,14 @@ func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, pas
     	// Check password
     	err = bcrypt.CompareHashAndPassword(dbPassword, []byte(password))
     	if err != nil {
    +		if lockout, until := s.loginAttemptCache.Add(uname, ip); lockout != LockoutTypeNone {
    +			switch lockout {
    +			case LockoutTypeAccount:
    +				s.logger.Info(fmt.Sprintf("Console user account locked until %v.", until))
    +			case LockoutTypeIp:
    +				s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until))
    +			}
    +		}
     		err = status.Error(codes.Unauthenticated, "Invalid credentials.")
     		return
     	}
    
  • server/console.go+3 1 modified
    @@ -138,6 +138,7 @@ type ConsoleServer struct {
     	StreamManager        StreamManager
     	sessionCache         SessionCache
     	consoleSessionCache  SessionCache
    +	loginAttemptCache    LoginAttemptCache
     	statusRegistry       *StatusRegistry
     	matchRegistry        MatchRegistry
     	statusHandler        StatusHandler
    @@ -155,7 +156,7 @@ type ConsoleServer struct {
     	httpClient           *http.Client
     }
     
    -func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer {
    +func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, loginAttemptCache LoginAttemptCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer {
     	var gatewayContextTimeoutMs string
     	if config.GetConsole().IdleTimeoutMs > 500 {
     		// Ensure the GRPC Gateway timeout is just under the idle timeout (if possible) to ensure it has priority.
    @@ -182,6 +183,7 @@ func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.D
     		StreamManager:        streamManager,
     		sessionCache:         sessionCache,
     		consoleSessionCache:  consoleSessionCache,
    +		loginAttemptCache:    loginAttemptCache,
     		statusRegistry:       statusRegistry,
     		matchRegistry:        matchRegistry,
     		statusHandler:        statusHandler,
    
  • server/login_attempt_cache.go+174 0 added
    @@ -0,0 +1,174 @@
    +// Copyright 2022 The Nakama Authors
    +//
    +// Licensed under the Apache License, Version 2.0 (the "License");
    +// you may not use this file except in compliance with the License.
    +// You may obtain a copy of the License at
    +//
    +// http://www.apache.org/licenses/LICENSE-2.0
    +//
    +// Unless required by applicable law or agreed to in writing, software
    +// distributed under the License is distributed on an "AS IS" BASIS,
    +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +// See the License for the specific language governing permissions and
    +// limitations under the License.
    +
    +package server
    +
    +import (
    +	"context"
    +	"sync"
    +	"time"
    +)
    +
    +type LockoutType uint8
    +
    +const (
    +	LockoutTypeNone LockoutType = iota
    +	LockoutTypeAccount
    +	LockoutTypeIp
    +)
    +
    +const (
    +	maxAttemptsAccount   = 5
    +	lockoutPeriodAccount = time.Minute * 1
    +
    +	maxAttemptsIp   = 10
    +	lockoutPeriodIp = time.Minute * 10
    +)
    +
    +type LoginAttemptCache interface {
    +	Stop()
    +	// Allow checks whether account or IP is locked out or should be allowed to attempt to authenticate.
    +	Allow(account, ip string) bool
    +	// Add a failed attempt and return current lockout status.
    +	Add(account, ip string) (LockoutType, time.Time)
    +	// Reset account attempts on successful login.
    +	Reset(account string)
    +}
    +
    +type lockoutStatus struct {
    +	lockedUntil time.Time
    +	attempts    []time.Time
    +}
    +
    +func (ls *lockoutStatus) trim(now time.Time, retentionPeriod time.Duration) bool {
    +	if ls.lockedUntil.Before(now) {
    +		ls.lockedUntil = time.Time{}
    +	}
    +	for i := len(ls.attempts) - 1; i >= 0; i-- {
    +		if now.Sub(ls.attempts[i]) >= retentionPeriod {
    +			ls.attempts = ls.attempts[i+1:]
    +			break
    +		}
    +	}
    +
    +	return ls.lockedUntil.IsZero() && len(ls.attempts) == 0
    +}
    +
    +type LocalLoginAttemptCache struct {
    +	sync.RWMutex
    +	ctx         context.Context
    +	ctxCancelFn context.CancelFunc
    +
    +	accountCache map[string]*lockoutStatus
    +	ipCache      map[string]*lockoutStatus
    +}
    +
    +func NewLocalLoginAttemptCache() LoginAttemptCache {
    +	ctx, ctxCancelFn := context.WithCancel(context.Background())
    +
    +	c := &LocalLoginAttemptCache{
    +		accountCache: make(map[string]*lockoutStatus),
    +		ipCache:      make(map[string]*lockoutStatus),
    +
    +		ctx:         ctx,
    +		ctxCancelFn: ctxCancelFn,
    +	}
    +
    +	go func() {
    +		ticker := time.NewTicker(10 * time.Minute)
    +		for {
    +			select {
    +			case <-c.ctx.Done():
    +				ticker.Stop()
    +				return
    +			case t := <-ticker.C:
    +				now := t.UTC()
    +				c.Lock()
    +				for account, status := range c.accountCache {
    +					if status.trim(now, lockoutPeriodAccount) {
    +						delete(c.accountCache, account)
    +					}
    +				}
    +				for ip, status := range c.ipCache {
    +					if status.trim(now, lockoutPeriodIp) {
    +						delete(c.ipCache, ip)
    +					}
    +				}
    +				c.Unlock()
    +			}
    +		}
    +	}()
    +
    +	return c
    +}
    +
    +func (c *LocalLoginAttemptCache) Stop() {
    +	c.ctxCancelFn()
    +}
    +
    +func (c *LocalLoginAttemptCache) Allow(account, ip string) bool {
    +	now := time.Now().UTC()
    +	c.RLock()
    +	defer c.RUnlock()
    +	if status, found := c.accountCache[account]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) {
    +		return false
    +	}
    +	if status, found := c.ipCache[ip]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) {
    +		return false
    +	}
    +	return true
    +}
    +
    +func (c *LocalLoginAttemptCache) Reset(account string) {
    +	c.Lock()
    +	delete(c.accountCache, account)
    +	c.Unlock()
    +}
    +
    +func (c *LocalLoginAttemptCache) Add(account, ip string) (LockoutType, time.Time) {
    +	now := time.Now().UTC()
    +	var lockoutType LockoutType
    +	var lockedUntil time.Time
    +	c.Lock()
    +	defer c.Unlock()
    +	if account != "" {
    +		status, found := c.accountCache[account]
    +		if !found {
    +			status = &lockoutStatus{}
    +			c.accountCache[account] = status
    +		}
    +		status.attempts = append(status.attempts, now)
    +		_ = status.trim(now, lockoutPeriodAccount)
    +		if len(status.attempts) >= maxAttemptsAccount {
    +			status.lockedUntil = now.Add(lockoutPeriodAccount)
    +			lockedUntil = status.lockedUntil
    +			lockoutType = LockoutTypeAccount
    +		}
    +	}
    +	if ip != "" {
    +		status, found := c.ipCache[ip]
    +		if !found {
    +			status = &lockoutStatus{}
    +			c.ipCache[ip] = status
    +		}
    +		status.attempts = append(status.attempts, now)
    +		_ = status.trim(now, lockoutPeriodIp)
    +		if len(status.attempts) >= maxAttemptsIp {
    +			status.lockedUntil = now.Add(lockoutPeriodIp)
    +			lockedUntil = status.lockedUntil
    +			lockoutType = LockoutTypeIp
    +		}
    +	}
    +	return lockoutType, lockedUntil
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.