Add rate limit support to API

This allows us to prevent users from hammering the API every few seconds
to check if any of their packages were updated. Real world users check
as often as every 5 or 10 seconds.

Signed-off-by: Florian Pritz <bluewind@xinu.at>
Signed-off-by: Lukas Fleischer <lfleischer@archlinux.org>
This commit is contained in:
Florian Pritz 2018-02-01 11:55:44 +01:00 committed by Lukas Fleischer
parent f51d4c32cd
commit 27654afadb
4 changed files with 111 additions and 0 deletions

View file

@ -36,6 +36,10 @@ enable-maintenance = 1
maintenance-exceptions = 127.0.0.1 maintenance-exceptions = 127.0.0.1
render-comment-cmd = /usr/local/bin/aurweb-rendercomment render-comment-cmd = /usr/local/bin/aurweb-rendercomment
[ratelimit]
request_limit = 4000
window_length = 86400
[notifications] [notifications]
notify-cmd = /usr/local/bin/aurweb-notify notify-cmd = /usr/local/bin/aurweb-notify
sendmail = /usr/bin/sendmail sendmail = /usr/bin/sendmail

View file

@ -399,3 +399,13 @@ CREATE TABLE AcceptedTerms (
FOREIGN KEY (UsersID) REFERENCES Users(ID) ON DELETE CASCADE, FOREIGN KEY (UsersID) REFERENCES Users(ID) ON DELETE CASCADE,
FOREIGN KEY (TermsID) REFERENCES Terms(ID) ON DELETE CASCADE FOREIGN KEY (TermsID) REFERENCES Terms(ID) ON DELETE CASCADE
) ENGINE = InnoDB; ) ENGINE = InnoDB;
-- Rate limits for API
--
CREATE TABLE `ApiRateLimit` (
IP VARCHAR(45) NOT NULL,
Requests INT(11) NOT NULL,
WindowStart BIGINT(20) NOT NULL,
PRIMARY KEY (`ip`)
) ENGINE = InnoDB;
CREATE INDEX ApiRateLimitWindowStart ON ApiRateLimit (WindowStart);

11
upgrading/4.7.0.txt Normal file
View file

@ -0,0 +1,11 @@
1. Add ApiRateLimit table:
---
CREATE TABLE `ApiRateLimit` (
IP VARCHAR(45) NOT NULL,
Requests INT(11) NOT NULL,
WindowStart BIGINT(20) NOT NULL,
PRIMARY KEY (`ip`)
) ENGINE = InnoDB;
CREATE INDEX ApiRateLimitWindowStart ON ApiRateLimit (WindowStart);
---

View file

@ -96,6 +96,11 @@ class AurJSON {
$this->dbh = DB::connect(); $this->dbh = DB::connect();
if ($this->check_ratelimit($_SERVER['REMOTE_ADDR'])) {
header("HTTP/1.1 429 Too Many Requests");
return $this->json_error('Rate limit reached');
}
$type = str_replace('-', '_', $http_data['type']); $type = str_replace('-', '_', $http_data['type']);
if ($type == 'info' && $this->version >= 5) { if ($type == 'info' && $this->version >= 5) {
$type = 'multiinfo'; $type = 'multiinfo';
@ -130,6 +135,87 @@ class AurJSON {
} }
} }
/*
* Check if an IP needs to be rate limited.
*
* @param $ip IP of the current request
*
* @return true if IP needs to be rate limited, false otherwise.
*/
private function check_ratelimit($ip) {
$limit = config_get("ratelimit", "request_limit");
if ($limit == 0) {
return false;
}
$window_length = config_get("ratelimit", "window_length");
$this->update_ratelimit($ip);
$stmt = $this->dbh->prepare("
SELECT Requests FROM ApiRateLimit
WHERE IP = :ip");
$stmt->bindParam(":ip", $ip);
$result = $stmt->execute();
if (!$result) {
return false;
}
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row['Requests'] > $limit) {
return true;
}
return false;
}
/*
* Update a rate limit for an IP by increasing it's requests value by one.
*
* @param $ip IP of the current request
*
* @return void
*/
private function update_ratelimit($ip) {
$window_length = config_get("ratelimit", "window_length");
$db_backend = config_get("database", "backend");
$time = time();
// Clean up old windows
$deletion_time = $time - $window_length;
$stmt = $this->dbh->prepare("
DELETE FROM ApiRateLimit
WHERE WindowStart < :time");
$stmt->bindParam(":time", $deletion_time);
$stmt->execute();
if ($db_backend == "mysql") {
$stmt = $this->dbh->prepare("
INSERT INTO ApiRateLimit
(IP, Requests, WindowStart)
VALUES (:ip, 1, :window_start)
ON DUPLICATE KEY UPDATE Requests=Requests+1");
$stmt->bindParam(":ip", $ip);
$stmt->bindParam(":window_start", $time);
$stmt->execute();
} elseif ($db_backend == "sqlite") {
$stmt = $this->dbh->prepare("
INSERT OR IGNORE INTO ApiRateLimit
(IP, Requests, WindowStart)
VALUES (:ip, 0, :window_start);");
$stmt->bindParam(":ip", $ip);
$stmt->bindParam(":window_start", $time);
$stmt->execute();
$stmt = $this->dbh->prepare("
UPDATE ApiRateLimit
SET Requests = Requests + 1
WHERE IP = :ip");
$stmt->bindParam(":ip", $ip);
$stmt->execute();
} else {
throw new RuntimeException("Unknown database backend");
}
}
/* /*
* Returns a JSON formatted error string. * Returns a JSON formatted error string.
* *