diff --git a/Makefile b/Makefile index 4d6cd4b9..864031f0 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ test: python3 ./gencapdefs.py | diff - ${capdef_file} cd irc && go test . && go vet . cd irc/caps && go test . && go vet . + cd irc/connection_limits && go test . && go vet . cd irc/history && go test . && go vet . cd irc/isupport && go test . && go vet . cd irc/modes && go test . && go vet . diff --git a/irc/client.go b/irc/client.go index 849d69ea..b48176e8 100644 --- a/irc/client.go +++ b/irc/client.go @@ -19,6 +19,7 @@ import ( "github.com/goshuirc/irc-go/ircmsg" ident "github.com/oragono/go-ident" "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/connection_limits" "github.com/oragono/oragono/irc/history" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/sno" @@ -73,6 +74,7 @@ type Client struct { isDestroyed bool isQuitting bool languages []string + loginThrottle connection_limits.GenericThrottle maxlenTags uint32 maxlenRest uint32 nick string @@ -126,14 +128,18 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) { fullLineLenLimit := config.Limits.LineLen.Tags + config.Limits.LineLen.Rest socket := NewSocket(conn, fullLineLenLimit*2, config.Server.MaxSendQBytes) client := &Client{ - atime: now, - authorized: server.Password() == nil, - capabilities: caps.NewSet(), - capState: caps.NoneState, - capVersion: caps.Cap301, - channels: make(ChannelSet), - ctime: now, - flags: modes.NewModeSet(), + atime: now, + authorized: server.Password() == nil, + capabilities: caps.NewSet(), + capState: caps.NoneState, + capVersion: caps.Cap301, + channels: make(ChannelSet), + ctime: now, + flags: modes.NewModeSet(), + loginThrottle: connection_limits.GenericThrottle{ + Duration: config.Accounts.LoginThrottling.Duration, + Limit: config.Accounts.LoginThrottling.MaxAttempts, + }, server: server, socket: socket, accountName: "*", diff --git a/irc/config.go b/irc/config.go index dcf30b7b..8003eb5b 100644 --- a/irc/config.go +++ b/irc/config.go @@ -54,10 +54,15 @@ func (conf *TLSListenConfig) Config() (*tls.Config, error) { type AccountConfig struct { Registration AccountRegistrationConfig - AuthenticationEnabled bool `yaml:"authentication-enabled"` - SkipServerPassword bool `yaml:"skip-server-password"` - NickReservation NickReservationConfig `yaml:"nick-reservation"` - VHosts VHostConfig + AuthenticationEnabled bool `yaml:"authentication-enabled"` + LoginThrottling struct { + Enabled bool + Duration time.Duration + MaxAttempts int `yaml:"max-attempts"` + } `yaml:"login-throttling"` + SkipServerPassword bool `yaml:"skip-server-password"` + NickReservation NickReservationConfig `yaml:"nick-reservation"` + VHosts VHostConfig } // AccountRegistrationConfig controls account registration. @@ -558,6 +563,10 @@ func LoadConfig(filename string) (config *Config, err error) { config.Accounts.VHosts.ValidRegexp = defaultValidVhostRegex } + if !config.Accounts.LoginThrottling.Enabled { + config.Accounts.LoginThrottling.MaxAttempts = 0 // limit of 0 means disabled + } + maxSendQBytes, err := bytefmt.ToBytes(config.Server.MaxSendQString) if err != nil { return nil, fmt.Errorf("Could not parse maximum SendQ size (make sure it only contains whole numbers): %s", err.Error()) diff --git a/irc/connection_limits/throttler.go b/irc/connection_limits/throttler.go index aa29d241..505f08a5 100644 --- a/irc/connection_limits/throttler.go +++ b/irc/connection_limits/throttler.go @@ -26,8 +26,45 @@ type ThrottlerConfig struct { // ThrottleDetails holds the connection-throttling details for a subnet/IP. type ThrottleDetails struct { - Start time.Time - ClientCount int + Start time.Time + Count int +} + +// GenericThrottle allows enforcing limits of the form +// "at most X events per time window of duration Y" +type GenericThrottle struct { + ThrottleDetails // variable state: what events have been seen + // these are constant after creation: + Duration time.Duration // window length to consider + Limit int // number of events allowed per window +} + +// Touch checks whether an additional event is allowed: +// it either denies it (by returning false) or allows it (by returning true) +// and records it +func (g *GenericThrottle) Touch() (throttled bool, remainingTime time.Duration) { + return g.touch(time.Now()) +} + +func (g *GenericThrottle) touch(now time.Time) (throttled bool, remainingTime time.Duration) { + if g.Limit == 0 { + return // limit of 0 disables throttling + } + + elapsed := now.Sub(g.Start) + if elapsed > g.Duration { + // reset window, record the operation + g.Start = now + g.Count = 1 + return false, 0 + } else if g.Count >= g.Limit { + // we are throttled + return true, g.Start.Add(g.Duration).Sub(now) + } else { + // we are not throttled, record the operation + g.Count += 1 + return false, 0 + } } // Throttler manages automated client connection throttling. @@ -102,21 +139,21 @@ func (ct *Throttler) AddClient(addr net.IP) error { ct.maskAddr(addr) addrString := addr.String() - details, exists := ct.population[addrString] - if !exists || details.Start.Add(ct.duration).Before(time.Now()) { - details = ThrottleDetails{ - Start: time.Now(), - } + details := ct.population[addrString] // retrieve mutable throttle state from the map + // add in constant state to process the limiting operation + g := GenericThrottle{ + ThrottleDetails: details, + Duration: ct.duration, + Limit: ct.subnetLimit, } + throttled, _ := g.Touch() // actually check the limit + ct.population[addrString] = g.ThrottleDetails // store modified mutable state - if details.ClientCount+1 > ct.subnetLimit { + if throttled { return errTooManyClients + } else { + return nil } - - details.ClientCount++ - ct.population[addrString] = details - - return nil } func (ct *Throttler) BanDuration() time.Duration { diff --git a/irc/connection_limits/throttler_test.go b/irc/connection_limits/throttler_test.go new file mode 100644 index 00000000..b7862375 --- /dev/null +++ b/irc/connection_limits/throttler_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2018 Shivaram Lingamneni +// released under the MIT license + +package connection_limits + +import ( + "net" + "reflect" + "testing" + "time" +) + +func assertEqual(supplied, expected interface{}, t *testing.T) { + if !reflect.DeepEqual(supplied, expected) { + t.Errorf("expected %v but got %v", expected, supplied) + } +} + +func TestGenericThrottle(t *testing.T) { + minute, _ := time.ParseDuration("1m") + second, _ := time.ParseDuration("1s") + zero, _ := time.ParseDuration("0s") + + throttler := GenericThrottle{ + Duration: minute, + Limit: 2, + } + + now := time.Now() + throttled, remaining := throttler.touch(now) + assertEqual(throttled, false, t) + assertEqual(remaining, zero, t) + + now = now.Add(second) + throttled, remaining = throttler.touch(now) + assertEqual(throttled, false, t) + assertEqual(remaining, zero, t) + + now = now.Add(second) + throttled, remaining = throttler.touch(now) + assertEqual(throttled, true, t) + assertEqual(remaining, 58*second, t) + + now = now.Add(minute) + throttled, remaining = throttler.touch(now) + assertEqual(throttled, false, t) + assertEqual(remaining, zero, t) +} + +func TestGenericThrottleDisabled(t *testing.T) { + minute, _ := time.ParseDuration("1m") + throttler := GenericThrottle{ + Duration: minute, + Limit: 0, + } + + for i := 0; i < 1024; i += 1 { + throttled, _ := throttler.Touch() + if throttled { + t.Error("disabled throttler should not throttle") + } + } +} + +func TestConnectionThrottle(t *testing.T) { + minute, _ := time.ParseDuration("1m") + maxConnections := 3 + config := ThrottlerConfig{ + Enabled: true, + CidrLenIPv4: 32, + CidrLenIPv6: 64, + ConnectionsPerCidr: maxConnections, + Duration: minute, + } + throttler := NewThrottler() + throttler.ApplyConfig(config) + + addr := net.ParseIP("8.8.8.8") + + for i := 0; i < maxConnections; i += 1 { + err := throttler.AddClient(addr) + assertEqual(err, nil, t) + } + err := throttler.AddClient(addr) + assertEqual(err, errTooManyClients, t) +} diff --git a/irc/handlers.go b/irc/handlers.go index 0537382f..8ef2d45b 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -83,9 +83,10 @@ func parseCallback(spec string, config *AccountConfig) (callbackNamespace string // ACC REGISTER [callback_namespace:] [cred_type] : func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + nick := client.Nick() // clients can't reg new accounts if they're already logged in if client.LoggedIntoAccount() { - rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, client.nick, "*", client.t("You're already logged into an account")) + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, nick, "*", client.t("You're already logged into an account")) return false } @@ -94,12 +95,12 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r casefoldedAccount, err := CasefoldName(account) // probably don't need explicit check for "*" here... but let's do it anyway just to make sure if err != nil || msg.Params[1] == "*" { - rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, client.nick, account, client.t("Account name is not valid")) + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, nick, account, client.t("Account name is not valid")) return false } if len(msg.Params) < 4 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, client.t("Not enough parameters")) + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, nick, msg.Command, client.t("Not enough parameters")) return false } @@ -107,7 +108,7 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r callbackNamespace, callbackValue := parseCallback(callbackSpec, server.AccountConfig()) if callbackNamespace == "" { - rb.Add(nil, server.name, ERR_REG_INVALID_CALLBACK, client.nick, account, callbackSpec, client.t("Callback namespace is not supported")) + rb.Add(nil, server.name, ERR_REG_INVALID_CALLBACK, nick, account, callbackSpec, client.t("Callback namespace is not supported")) return false } @@ -131,12 +132,12 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r } } if credentialType == "certfp" && client.certfp == "" { - rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, client.nick, credentialType, callbackNamespace, client.t("You are not using a TLS certificate")) + rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, nick, credentialType, callbackNamespace, client.t("You are not using a TLS certificate")) return false } if !credentialValid { - rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, client.nick, credentialType, callbackNamespace, client.t("Credential type is not supported")) + rb.Add(nil, server.name, ERR_REG_INVALID_CRED_TYPE, nick, credentialType, callbackNamespace, client.t("Credential type is not supported")) return false } @@ -146,6 +147,13 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r } else if credentialType == "passphrase" { passphrase = credentialValue } + + throttled, remainingTime := client.loginThrottle.Touch() + if throttled { + rb.Add(nil, server.name, ERR_REG_UNSPECIFIED_ERROR, nick, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime)) + return false + } + err = server.accounts.Register(client, account, callbackNamespace, callbackValue, passphrase, certfp) if err != nil { msg := "Unknown" @@ -161,7 +169,7 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r if err == errAccountAlreadyRegistered || err == errAccountCreation || err == errCertfpAlreadyExists { msg = err.Error() } - rb.Add(nil, server.name, code, client.nick, "ACC", "REGISTER", client.t(msg)) + rb.Add(nil, server.name, code, nick, "ACC", "REGISTER", client.t(msg)) return false } @@ -175,7 +183,7 @@ func accRegisterHandler(server *Server, client *Client, msg ircmsg.IrcMessage, r } else { messageTemplate := client.t("Account created, pending verification; verification code has been sent to %s:%s") message := fmt.Sprintf(messageTemplate, callbackNamespace, callbackValue) - rb.Add(nil, server.name, RPL_REG_VERIFICATION_REQUIRED, client.nick, casefoldedAccount, message) + rb.Add(nil, server.name, RPL_REG_VERIFICATION_REQUIRED, nick, casefoldedAccount, message) } return false @@ -336,6 +344,8 @@ func authPlainHandler(server *Server, client *Client, mechanism string, value [] var accountKey, authzid string + nick := client.Nick() + if len(splitValue) == 3 { accountKey = string(splitValue[0]) authzid = string(splitValue[1]) @@ -343,11 +353,17 @@ func authPlainHandler(server *Server, client *Client, mechanism string, value [] if accountKey == "" { accountKey = authzid } else if accountKey != authzid { - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: authcid and authzid should be the same")) + rb.Add(nil, server.name, ERR_SASLFAIL, nick, client.t("SASL authentication failed: authcid and authzid should be the same")) return false } } else { - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, client.t("SASL authentication failed: Invalid auth blob")) + rb.Add(nil, server.name, ERR_SASLFAIL, nick, client.t("SASL authentication failed: Invalid auth blob")) + return false + } + + throttled, remainingTime := client.loginThrottle.Touch() + if throttled { + rb.Add(nil, server.name, ERR_SASLFAIL, nick, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime)) return false } @@ -355,7 +371,7 @@ func authPlainHandler(server *Server, client *Client, mechanism string, value [] err := server.accounts.AuthenticateByPassphrase(client, accountKey, password) if err != nil { msg := authErrorToMessage(server, err) - rb.Add(nil, server.name, ERR_SASLFAIL, client.nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg))) + rb.Add(nil, server.name, ERR_SASLFAIL, nick, fmt.Sprintf("%s: %s", client.t("SASL authentication failed"), client.t(msg))) return false } diff --git a/irc/nickserv.go b/irc/nickserv.go index 37973e90..c5a85617 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -200,6 +200,15 @@ func nsGroupHandler(server *Server, client *Client, command, params string, rb * } } +func nsLoginThrottleCheck(client *Client, rb *ResponseBuffer) (success bool) { + throttled, remainingTime := client.loginThrottle.Touch() + if throttled { + nsNotice(rb, fmt.Sprintf(client.t("Please wait at least %v and try again"), remainingTime)) + return false + } + return true +} + func nsIdentifyHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { loginSuccessful := false @@ -207,6 +216,9 @@ func nsIdentifyHandler(server *Server, client *Client, command, params string, r // try passphrase if username != "" && passphrase != "" { + if !nsLoginThrottleCheck(client, rb) { + return + } err := server.accounts.AuthenticateByPassphrase(client, username, passphrase) loginSuccessful = (err == nil) } @@ -407,10 +419,15 @@ func nsPasswdHandler(server *Server, client *Client, command, params string, rb var newPassword string var errorMessage string + hasPrivs := client.HasRoleCapabs("accreg") + if !hasPrivs && !nsLoginThrottleCheck(client, rb) { + return + } + fields := strings.Fields(params) switch len(fields) { case 2: - if !client.HasRoleCapabs("accreg") { + if !hasPrivs { errorMessage = "Insufficient privileges" } else { target, newPassword = fields[0], fields[1] diff --git a/oragono.yaml b/oragono.yaml index 03ce36d4..0ff89d78 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -179,6 +179,17 @@ accounts: # is account authentication enabled? authentication-enabled: true + # throttle account login attempts (to prevent either password guessing, or DoS + # attacks on the server aimed at forcing repeated expensive bcrypt computations) + login-throttling: + enabled: true + + # window + duration: 1m + + # number of attempts allowed within the window + max-attempts: 3 + # some clients (notably Pidgin and Hexchat) offer only a single password field, # which makes it impossible to specify a separate server password (for the PASS # command) and SASL password. if this option is set to true, a client that