mirror of
https://github.com/ergochat/ergo.git
synced 2024-11-22 11:59:40 +01:00
add support for login throttling
This commit is contained in:
parent
3cd3601a30
commit
f94f737b31
1
Makefile
1
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 .
|
||||
|
@ -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
|
||||
@ -134,6 +136,10 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) {
|
||||
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: "*",
|
||||
|
@ -55,6 +55,11 @@ func (conf *TLSListenConfig) Config() (*tls.Config, error) {
|
||||
type AccountConfig struct {
|
||||
Registration AccountRegistrationConfig
|
||||
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
|
||||
@ -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())
|
||||
|
@ -27,7 +27,44 @@ type ThrottlerConfig struct {
|
||||
// ThrottleDetails holds the connection-throttling details for a subnet/IP.
|
||||
type ThrottleDetails struct {
|
||||
Start time.Time
|
||||
ClientCount int
|
||||
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
|
||||
}
|
||||
|
||||
details.ClientCount++
|
||||
ct.population[addrString] = details
|
||||
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ct *Throttler) BanDuration() time.Duration {
|
||||
|
86
irc/connection_limits/throttler_test.go
Normal file
86
irc/connection_limits/throttler_test.go
Normal file
@ -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)
|
||||
}
|
@ -83,9 +83,10 @@ func parseCallback(spec string, config *AccountConfig) (callbackNamespace string
|
||||
|
||||
// ACC REGISTER <accountname> [callback_namespace:]<callback> [cred_type] :<credential>
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -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]
|
||||
|
11
oragono.yaml
11
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
|
||||
|
Loading…
Reference in New Issue
Block a user