From 77f6dfaada6993d1b03e32b7e325b579d850dad2 Mon Sep 17 00:00:00 2001 From: Miraty Date: Sat, 17 Sep 2022 00:49:07 +0200 Subject: [PATCH] Token bucket rate limiting --- db/migrations/002-add-token-bucket.sql | 29 ++++++++++++++ db/schema.sql | 14 +++++-- fn/auth.php | 55 ++++++++++++++++++++++++++ pages.php | 5 +++ pages/auth/register.php | 16 +++++--- pages/ht/add-http-dns.php | 2 + pages/ht/add-http-onion.php | 2 + pages/ns/zone-add.php | 2 + pages/reg/register.php | 2 + router.php | 21 +++++++--- 10 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 db/migrations/002-add-token-bucket.sql diff --git a/db/migrations/002-add-token-bucket.sql b/db/migrations/002-add-token-bucket.sql new file mode 100644 index 0000000..bceb12b --- /dev/null +++ b/db/migrations/002-add-token-bucket.sql @@ -0,0 +1,29 @@ +BEGIN TRANSACTION; + +-- Add user-relative bucket +ALTER TABLE "users" ADD COLUMN "bucket_tokens" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "users" ADD COLUMN "bucket_last_update" INTEGER NOT NULL DEFAULT 0; + +-- Remove "DEFAULT 0" from user-relative bucket +CREATE TABLE "users_temp" ( + "id" INTEGER NOT NULL UNIQUE, + "username" TEXT NOT NULL UNIQUE, + "password" TEXT NOT NULL, + "registration_date" TEXT NOT NULL, + "bucket_tokens" INTEGER NOT NULL, + "bucket_last_update" INTEGER NOT NULL, + PRIMARY KEY("id" AUTOINCREMENT) +); +INSERT INTO "users_temp" SELECT "id","username","password","registration_date","bucket_tokens","bucket_last_update" FROM "users"; +DROP TABLE "users"; +ALTER TABLE "users_temp" RENAME TO "users"; + +-- Add instance-wide bucket +CREATE TABLE IF NOT EXISTS "params" ( + "name" TEXT NOT NULL UNIQUE, + "value" TEXT NOT NULL +); +INSERT INTO "params"("name", "value") VALUES("instance_bucket_tokens", "0"); +INSERT INTO "params"("name", "value") VALUES("instance_bucket_last_update", "0"); + +COMMIT; diff --git a/db/schema.sql b/db/schema.sql index 467c76e..9ac12e6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,9 +1,13 @@ BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS "params" ( + "name" TEXT NOT NULL UNIQUE, + "value" TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS "registry" ( "id" INTEGER NOT NULL UNIQUE, "domain" TEXT NOT NULL UNIQUE, "username" TEXT NOT NULL, - "last_renewal" INTEGER, + "last_renewal" TEXT NOT NULL, PRIMARY KEY("id" AUTOINCREMENT) ); CREATE TABLE IF NOT EXISTS "zones" ( @@ -16,7 +20,9 @@ CREATE TABLE IF NOT EXISTS "users" ( "id" INTEGER NOT NULL UNIQUE, "username" TEXT NOT NULL UNIQUE, "password" TEXT NOT NULL, - "registration_date" INTEGER NOT NULL, + "registration_date" TEXT NOT NULL, + "bucket_tokens" INTEGER NOT NULL, + "bucket_last_update" INTEGER NOT NULL, PRIMARY KEY("id" AUTOINCREMENT) ); CREATE TABLE IF NOT EXISTS "sites" ( @@ -26,7 +32,9 @@ CREATE TABLE IF NOT EXISTS "sites" ( "domain" TEXT NOT NULL UNIQUE, "domain_type" TEXT NOT NULL, "protocol" TEXT NOT NULL, - "creation_date" INTEGER NOT NULL, + "creation_date" TEXT NOT NULL, PRIMARY KEY("id" AUTOINCREMENT) ); +INSERT INTO "params"("name", "value") VALUES("instance_bucket_tokens", "0"); +INSERT INTO "params"("name", "value") VALUES("instance_bucket_last_update", "0"); COMMIT; diff --git a/fn/auth.php b/fn/auth.php index 2856b6c..06e47dd 100644 --- a/fn/auth.php +++ b/fn/auth.php @@ -50,3 +50,58 @@ function changePassword($username, $password) { $stmt->execute(); } + +function rateLimit() { + if (PAGE_METADATA['tokens_account_cost'] ?? 0 > 0) + rateLimitAccount(PAGE_METADATA['tokens_account_cost']); + + if (PAGE_METADATA['tokens_instance_cost'] ?? 0 > 0) + rateLimitInstance(PAGE_METADATA['tokens_instance_cost']); +} + +function rateLimitAccount($requestedTokens) { + // Get + $userData = query('select', 'users', ['username' => $_SESSION['username']]); + $tokens = $userData[0]['bucket_tokens']; + $bucketLastUpdate = $userData[0]['bucket_last_update']; + + // Compute + $tokens = min(86400, $tokens + (time() - $bucketLastUpdate)); + + if ($requestedTokens > $tokens) + output(453, 'Limite d\'actions par compte atteinte. Réessayez plus tard.'); + + $tokens -= $requestedTokens; + + // Update + $db = new PDO('sqlite:' . DB_PATH); + $stmt = $db->prepare("UPDATE users SET bucket_tokens = :bucket_tokens, bucket_last_update = :bucket_last_update WHERE username = :username"); + $stmt->bindValue(':username', $_SESSION['username']); + $stmt->bindValue(':bucket_tokens', $tokens); + $stmt->bindValue(':bucket_last_update', time()); + $stmt->execute(); +} + +function rateLimitInstance($requestedTokens) { + // Get + $tokens = query('select', 'params', ['name' => 'instance_bucket_tokens'], 'value')[0]; + $bucketLastUpdate = query('select', 'params', ['name' => 'instance_bucket_last_update'], 'value')[0]; + + // Compute + $tokens = min(86400, $tokens + (time() - $bucketLastUpdate)); + + if ($requestedTokens > $tokens) + output(453, 'Limite d\'actions globale atteinte. Réessayez plus tard.'); + + $tokens -= $requestedTokens; + + // Update + $db = new PDO('sqlite:' . DB_PATH); + $stmt = $db->prepare("UPDATE params SET value = :bucket_tokens WHERE name = 'instance_bucket_tokens';"); + $stmt->bindValue(':bucket_tokens', $tokens); + $stmt->execute(); + + $stmt = $db->prepare("UPDATE params SET value = :bucket_last_update WHERE name = 'instance_bucket_last_update';"); + $stmt->bindValue(':bucket_last_update', time()); + $stmt->execute(); +} diff --git a/pages.php b/pages.php index 5f7bb53..f00e0f1 100644 --- a/pages.php +++ b/pages.php @@ -16,6 +16,7 @@ define('PAGES', [ 'register' => [ 'title' => 'Créer un compte', 'description' => 'Créer un nouveau compte Niver', + 'tokens_instance_cost' => 7200, ], 'unregister' => [ 'title' => 'Supprimer son compte', @@ -38,6 +39,7 @@ define('PAGES', [ 'register' => [ 'title' => 'Enregistrer un nouveau domaine', 'description' => 'Prendre possession d\'un sous-domaine de ' . CONF['reg']['registry'] . '', + 'tokens_account_cost' => 3600, ], 'unregister' => [ 'title' => 'Effacer un domaine', @@ -68,6 +70,7 @@ define('PAGES', [ 'zone-add' => [ 'title' => 'Ajouter une zone', 'description' => 'Pour qu\'elle soit gérée par le serveur de noms de Niver', + 'tokens_account_cost' => 1800, ], 'zone-del' => [ 'title' => 'Effacer une zone', @@ -130,10 +133,12 @@ define('PAGES', [ 'add-http-onion' => [ 'title' => 'Ajouter un accès HTTP par Onion', 'description' => 'Ajouter un accès HTTP par ' . linkToDocs('tor', 'service Onion') . ' sur un sous-dossier de l\'espace SFTP', + 'tokens_account_cost' => 1800, ], 'add-http-dns' => [ 'title' => 'Ajouter un accès HTTP par DNS+TLS', 'description' => 'Ajouter un accès HTTP par ' . linkToDocs('dns', 'DNS') . ' et ' . linkToDocs('tls', 'TLS') . ' sur un sous-dossier de l\'espace SFTP', + 'tokens_account_cost' => 3600, ], 'del-http-onion' => [ 'title' => 'Retirer un accès HTTP par Onion', diff --git a/pages/auth/register.php b/pages/auth/register.php index 9ecce6f..a63d671 100644 --- a/pages/auth/register.php +++ b/pages/auth/register.php @@ -8,6 +8,16 @@ if (processForm(false)) { if (userExist($_POST['username']) !== false) output(403, 'Ce nom de compte est déjà utilisé.'); + rateLimit(); + + insert('users', [ + 'username' => $_POST['username'], + 'password' => hashPassword($_POST['password']), + 'registration_date' => date("Y-m-d H:i:s"), + 'bucket_tokens' => 0, + 'bucket_last_update' => 0, + ]); + // Setup SFTP directory umask(0002); if (mkdir(CONF['ht']['ht_path'] . "/" . $_POST['username'], 0775) !== true) @@ -25,12 +35,6 @@ if (processForm(false)) { if ($code !== 0) output(500, 'Can\'t create Tor keys directory.'); - insert('users', [ - 'username' => $_POST['username'], - 'password' => hashPassword($_POST['password']), - 'registration_date' => date("Y-m-d H:i:s"), - ]); - $_SESSION['username'] = $_POST['username']; redir(); diff --git a/pages/ht/add-http-dns.php b/pages/ht/add-http-dns.php index 8833bf7..362b9bc 100644 --- a/pages/ht/add-http-dns.php +++ b/pages/ht/add-http-dns.php @@ -26,6 +26,8 @@ if (processForm()) { if (equalArrays([CONF['ht']['ipv4_address']], array_column($remoteARecords, 'ip')) !== true) output(403, 'Ce domaine doit avoir pour unique enregistrement A ' . CONF['ht']['ipv4_address'] . '.'); + rateLimit(); + addSite($_SESSION['username'], $_POST['dir'], $_POST['domain'], "dns", "http"); exec(CONF['ht']['sudo_path'] . " " . CONF['ht']['certbot_path'] . " certonly --quiet" . (CONF['ht']['letsencrypt_use_production'] ? '' : ' --test-cert') . " --key-type rsa --rsa-key-size 3072 --webroot --webroot-path /srv/niver/acme --domain " . $_POST['domain'], $output, $returnCode); diff --git a/pages/ht/add-http-onion.php b/pages/ht/add-http-onion.php index be7ca24..242dac6 100644 --- a/pages/ht/add-http-onion.php +++ b/pages/ht/add-http-onion.php @@ -9,6 +9,8 @@ if (processForm()) { if ($dirsStatuses[$_POST['dir']] !== false) output(403, 'Wrong value for dir.'); + rateLimit(); + // Add Tor config $torConf = "HiddenServiceDir " . CONF['ht']['tor_keys_path'] . "/" . $_SESSION['username'] . "/" . $_POST['dir'] . "/ HiddenServicePort 80 [::1]:" . CONF['ht']['internal_onion_http_port'] . " diff --git a/pages/ns/zone-add.php b/pages/ns/zone-add.php index 503f355..0bf62ee 100644 --- a/pages/ns/zone-add.php +++ b/pages/ns/zone-add.php @@ -14,6 +14,8 @@ if (processForm()) { if (equalArrays(CONF['ns']['servers'], $matches[1]) !== true) output(403, 'Les serveurs ayant autorité dans cette zone indiqués par la zone parente ne sont pas ceux de Niver.'); + rateLimit(); + insert('zones', [ 'zone' => $_POST['domain'], 'username' => $_SESSION['username'], diff --git a/pages/reg/register.php b/pages/reg/register.php index 7b19861..97cd77b 100644 --- a/pages/reg/register.php +++ b/pages/reg/register.php @@ -12,6 +12,8 @@ if (processForm()) { if (in_array($_POST['subdomain'], explode("\n", file_get_contents(CONF['common']['root_path'] . '/pages/reg/reserved.txt')))) output(403, 'Ce domaine n\'est pas disponible à l\'enregistrement. Il est réservé.'); + rateLimit(); + insert('registry', [ 'domain' => $domain, 'username' => $_SESSION['username'], diff --git a/router.php b/router.php index 0ef45f9..b5ad11f 100644 --- a/router.php +++ b/router.php @@ -26,19 +26,28 @@ define("PAGE_LINEAGE", explode('/', PAGE_ADDRESS)); define("SERVICE", dirname(PAGE_ADDRESS)); define("PAGE", basename(PAGE_ADDRESS, '.php')); -function getTitlesLineage($pages, $pageElements) { - if (!isset($pages['index']) OR $pageElements[0] === 'index') - return [$pages[$pageElements[0]]['title'] ?? false]; +$pageMetadata = []; +function getPageInformations($pages, $pageElements) { + global $pageMetadata; + if (!isset($pages['index']) OR $pageElements[0] === 'index') { + return [ + 'titles_lineage' => [$pages[$pageElements[0]]['title'] ?? false], + 'page_metadata' => $pages[$pageElements[0]] ?? NULL + ]; + define("PAGE_METADATA", $pages[$pageElements[0]] ?? []); + } $result = $pages['index']['title']; if (!isset($pageElements[1])) unset($pages['index']); else $pages = $pages[array_shift($pageElements)] ?? false; - $results = getTitlesLineage($pages, $pageElements); - $results[] = $result; + $results = getPageInformations($pages, $pageElements); + $results['titles_lineage'][] = $result; return $results; } -define('TITLES_LINEAGE', array_reverse(getTitlesLineage(PAGES, PAGE_LINEAGE))); +$pageInformations = getPageInformations(PAGES, PAGE_LINEAGE); +define('TITLES_LINEAGE', array_reverse($pageInformations['titles_lineage'])); +define('PAGE_METADATA', $pageInformations['page_metadata']); if (!TITLES_LINEAGE[array_key_last(TITLES_LINEAGE)]) { http_response_code(404);