From 2141db3d64841f7579ba789e4faff26e82b90657 Mon Sep 17 00:00:00 2001 From: DL6ER Date: Sat, 23 Sep 2023 12:25:12 +0200 Subject: [PATCH] Add rate-limiting on password login attempts Signed-off-by: DL6ER --- src/api/auth.c | 12 +++++++- src/api/config.c | 2 +- src/api/docs/content/specs/auth.yaml | 8 ++++++ src/api/docs/content/specs/common.yaml | 20 +++++++++++++ src/config/cli.c | 2 +- src/config/password.c | 39 +++++++++++++++++++++----- src/config/password.h | 11 +++++++- test/api/libs/FTLAPI.py | 9 ++++-- test/api/test-rate-limit.py | 24 ++++++++++++++++ 9 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 test/api/test-rate-limit.py diff --git a/src/api/auth.c b/src/api/auth.c index e0a4698b..1cec68b7 100644 --- a/src/api/auth.c +++ b/src/api/auth.c @@ -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); diff --git a/src/api/config.c b/src/api/config.c index cbc70aab..5f9e8b7a 100644 --- a/src/api/config.c +++ b/src/api/config.c @@ -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) { diff --git a/src/api/docs/content/specs/auth.yaml b/src/api/docs/content/specs/auth.yaml index f46645fb..2c314181 100644 --- a/src/api/docs/content/specs/auth.yaml +++ b/src/api/docs/content/specs/auth.yaml @@ -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: diff --git a/src/api/docs/content/specs/common.yaml b/src/api/docs/content/specs/common.yaml index 75129d23..fa82c83c 100644 --- a/src/api/docs/content/specs/common.yaml +++ b/src/api/docs/content/specs/common.yaml @@ -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 diff --git a/src/config/cli.c b/src/config/cli.c index 756aabf5..15f4f19a 100644 --- a/src/config/cli.c +++ b/src/config/cli.c @@ -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) { diff --git a/src/config/password.c b/src/config/password.c index edd6cc10..5b975abf 100644 --- a/src/config/password.c +++ b/src/config/password.c @@ -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; } } diff --git a/src/config/password.h b/src/config/password.h index d571b505..e0a3a5f2 100644 --- a/src/config/password.h +++ b/src/config/password.h @@ -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 diff --git a/test/api/libs/FTLAPI.py b/test/api/libs/FTLAPI.py index 2b2519dc..a71bcec2 100644 --- a/test/api/libs/FTLAPI.py +++ b/test/api/libs/FTLAPI.py @@ -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"] diff --git a/test/api/test-rate-limit.py b/test/api/test-rate-limit.py new file mode 100644 index 00000000..781a2d73 --- /dev/null +++ b/test/api/test-rate-limit.py @@ -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)