Add rate-limiting on password login attempts

Signed-off-by: DL6ER <dl6er@dl6er.de>
This commit is contained in:
DL6ER 2023-09-23 12:25:12 +02:00
parent de7227347b
commit 2141db3d64
No known key found for this signature in database
GPG Key ID: 00135ACBD90B28DD
9 changed files with 113 additions and 14 deletions

View File

@ -509,7 +509,8 @@ int api_auth(struct ftl_conn *api)
// else: Login attempt
// - Client tries to authenticate using a password, or
// - There no password on this machine
if(empty_password ? true : verify_password(password, config.webserver.api.pwhash.v.s))
const enum password_result result = empty_password ? true : verify_password(password, config.webserver.api.pwhash.v.s, true);
if(result == PASSWORD_CORRECT)
{
// Accepted
@ -604,6 +605,15 @@ int api_auth(struct ftl_conn *api)
log_warn("No free API seats available, not authenticating client");
}
}
else if(result == PASSWORD_RATE_LIMITED)
{
// Rate limited
log_debug(DEBUG_API, "API: Login attempt rate-limited");
return send_json_error(api, 429,
"too_many_requests",
"Too many requests",
NULL);
}
else
{
log_debug(DEBUG_API, "API: Password incorrect: '%s'", password);

View File

@ -287,7 +287,7 @@ static const char *getJSONvalue(struct conf_item *conf_item, cJSON *elem, struct
char *pwhash = strlen(elem->valuestring) > 0 ? create_password(elem->valuestring) : strdup("");
// Verify that the password hash is valid
const bool verfied = verify_password(elem->valuestring, pwhash);
const bool verfied = verify_password(elem->valuestring, pwhash, false) == PASSWORD_CORRECT;
if(!verfied)
{

View File

@ -79,6 +79,14 @@ components:
allOf:
- $ref: 'common.yaml#/components/errors/unauthorized'
- $ref: 'common.yaml#/components/schemas/took'
'429':
description: Too Many Requests
content:
application/json:
schema:
allOf:
- $ref: 'common.yaml#/components/errors/too_many_requests'
- $ref: 'common.yaml#/components/schemas/took'
delete:
summary: Delete session
tags:

View File

@ -53,6 +53,26 @@ components:
nullable: true
description: "No additional data available"
example: null
too_many_requests:
type: object
description: "Too many requests (rate limiting)"
properties:
error:
type: object
properties:
key:
type: string
description: "Machine-readable error type"
example: "too_many_requests"
message:
type: string
description: "Human-readable error message"
example: "Too many requests"
hint:
type: string
nullable: true
description: "No additional data available"
example: null
headers:
Location:
description: Location of created resource

View File

@ -162,7 +162,7 @@ static bool readStringValue(struct conf_item *conf_item, const char *value, stru
char *pwhash = strlen(value) > 0 ? create_password(value) : strdup("");
// Verify that the password hash is valid
const bool verfied = verify_password(value, pwhash);
const bool verfied = verify_password(value, pwhash, false) == PASSWORD_CORRECT;
if(!verfied)
{

View File

@ -14,6 +14,8 @@
#include "password.h"
// genrandom() with fallback
#include "daemon.h"
// sleepms()
#include "timers.h"
// Randomness generator
#include "webserver/x509.h"
@ -305,16 +307,39 @@ char * __attribute__((malloc)) create_password(const char *password)
return balloon_password(password, salt, true);
}
bool verify_password(const char *password, const char* pwhash)
char verify_password(const char *password, const char* pwhash, const bool rate_limiting)
{
// No password supplied
if(password == NULL || password[0] == '\0')
return false;
return PASSWORD_INCORRECT;
// No password set
if(pwhash == NULL || pwhash[0] == '\0')
return true;
return PASSWORD_CORRECT;
// Check if there has already been one login attempt within this second
static time_t last_password_attempt = 0;
static unsigned int num_password_attempts = 0;
if(rate_limiting &&
last_password_attempt > 0 &&
last_password_attempt == time(NULL))
{
// Check if we have reached the maximum number of attempts
if(++num_password_attempts > MAX_PASSWORD_ATTEMPTS_PER_SECOND)
{
// Rate limit reached
sleepms(250);
return PASSWORD_RATE_LIMITED;
}
}
else
{
// Reset counter
num_password_attempts = 1;
last_password_attempt = time(NULL);
}
// Check password hash format
if(pwhash[0] == '$')
{
// Parse PHC string
@ -323,9 +348,9 @@ bool verify_password(const char *password, const char* pwhash)
uint8_t *salt = NULL;
uint8_t *config_hash = NULL;
if(!parse_PHC_string(pwhash, &s_cost, &t_cost, &salt, &config_hash))
return false;
return PASSWORD_INCORRECT;
if(salt == NULL || config_hash == NULL)
return false;
return PASSWORD_INCORRECT;
char *supplied = balloon_password(password, salt, false);
const bool result = memcmp(config_hash, supplied, SHA256_DIGEST_SIZE) == 0;
@ -336,7 +361,7 @@ bool verify_password(const char *password, const char* pwhash)
if(config_hash != NULL)
free(config_hash);
return result;
return result ? PASSWORD_CORRECT : PASSWORD_INCORRECT;
}
else
{
@ -361,7 +386,7 @@ bool verify_password(const char *password, const char* pwhash)
}
}
return result;
return result ? PASSWORD_CORRECT : PASSWORD_INCORRECT;
}
}

View File

@ -16,7 +16,16 @@
void sha256_raw_to_hex(uint8_t *data, char *buffer);
char *create_password(const char *password) __attribute__((malloc));
bool verify_password(const char *password, const char *pwhash);
char verify_password(const char *password, const char *pwhash, const bool rate_limiting);
int run_performance_test(void);
enum password_result {
PASSWORD_INCORRECT = 0,
PASSWORD_CORRECT = 1,
PASSWORD_RATE_LIMITED = -1
} __attribute__((packed));
// The maximum number of password attempts per second
#define MAX_PASSWORD_ATTEMPTS_PER_SECOND 3
#endif //PASSWORD_H

View File

@ -43,9 +43,10 @@ class FTLAPI():
self.verbose = False
# Login to FTL API
self.login(password)
if self.session is None or 'valid' not in self.session or not self.session['valid']:
raise Exception("Could not login to FTL API")
if password is not None:
self.login(password)
if self.session is None or 'valid' not in self.session or not self.session['valid']:
raise Exception("Could not login to FTL API")
def login(self, password: str = None):
# Check if we even need to login
@ -65,6 +66,8 @@ class FTLAPI():
return
response = self.POST("/api/auth", {"password": password})
if "error" in response:
raise Exception("FTL returned error: " + json.dumps(response["error"]))
if 'session' not in response:
raise Exception("FTL returned invalid response item")
self.session = response["session"]

View File

@ -0,0 +1,24 @@
# Script that sends a number of randomly generated passwords to the
# /api/auth endpoint checking that rate limiting is enforced
import random
import string
from libs.FTLAPI import FTLAPI
if __name__ == "__main__":
# Create FTLAPI object
ftl = FTLAPI("http://127.0.0.1:8080")
# Try to login with random passwords
for i in range(0, 100):
pw = "".join(random.choices(string.printable, k=random.randint(1, 64)))
try:
ftl.login(pw)
except Exception as e:
if "too_many_requests" in str(e):
print("Rate-limited on attempt no. " + str(i))
exit(0)
else:
print("Unexpected error: " + str(e))
exit(1)
print("Rate-limiting was not enforced")
exit(1)