mirror of
https://github.com/wavelog/wavelog.git
synced 2026-03-22 10:24:14 +00:00
Merge pull request #2785 from int2001/rate_limit_api
Introduce (optional) Ratelimiting on API
This commit is contained in:
@@ -854,3 +854,49 @@ $config['enable_dxcluster_file_cache_worked'] = false;
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
$config['internal_tools'] = false;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Rate limiting for API endpoints using sliding window algorithm.
|
||||
| Rate limiting is only enabled if api_rate_limits is defined (not null/empty).
|
||||
|
|
||||
| Format: Array of endpoint-specific limits
|
||||
| - Endpoint name: the API function name (e.g., 'private_lookup', 'lookup')
|
||||
| - requests: maximum number of requests allowed
|
||||
| - window: time window in seconds
|
||||
|
|
||||
| Example configuration:
|
||||
|
|
||||
| $config['api_rate_limits'] = [
|
||||
| 'private_lookup' => ['requests' => 60, 'window' => 60], // 60 requests per minute
|
||||
| 'lookup' => ['requests' => 60, 'window' => 60], // 60 requests per minute
|
||||
| 'qso' => ['requests' => 10, 'window' => 60], // 10 requests per minute
|
||||
| 'default' => ['requests' => 30, 'window' => 60], // Default for all other endpoints
|
||||
| ];
|
||||
|
|
||||
| Set to null or leave commented to disable rate limiting entirely:
|
||||
| $config['api_rate_limits'] = null;
|
||||
|
|
||||
| The 'default' key is optional and applies to any API endpoint not explicitly
|
||||
| listed. If no default is provided, endpoints without specific limits have no
|
||||
| rate limiting applied.
|
||||
|
|
||||
| Rate limiting tracks requests by:
|
||||
| - API key (if provided)
|
||||
| - Session user ID (if authenticated via session)
|
||||
| - IP address (fallback)
|
||||
|
|
||||
*/
|
||||
|
||||
// Example configuration (uncomment to enable):
|
||||
// $config['api_rate_limits'] = [
|
||||
// 'private_lookup' => ['requests' => 60, 'window' => 60],
|
||||
// 'lookup' => ['requests' => 60, 'window' => 60],
|
||||
// 'qso' => ['requests' => 10, 'window' => 60],
|
||||
// 'radio' => ['requests' => 60, 'window' => 60],
|
||||
// 'statistics' => ['requests' => 30, 'window' => 60],
|
||||
// 'default' => ['requests' => 30, 'window' => 60],
|
||||
// ];
|
||||
|
||||
@@ -28,6 +28,27 @@ class API extends CI_Controller {
|
||||
redirect('api');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for current endpoint
|
||||
* Only enforced if api_rate_limits config is set
|
||||
*
|
||||
* returns True if request is allowed, false if rate limited
|
||||
*/
|
||||
protected function check_rate_limit($endpoint, $identifier = null) {
|
||||
if (!$this->load->is_loaded('rate_limit')) {
|
||||
$this->load->library('rate_limit');
|
||||
}
|
||||
|
||||
$result = $this->rate_limit->check($endpoint, $identifier);
|
||||
|
||||
if (!$result['allowed']) {
|
||||
log_message("Debug","Rate limit for endpoint ".$endpoint." and ID: ".($identifier ?? '')." exceeded");
|
||||
$this->rate_limit->send_limit_exceeded_response($result['retry_after']);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function edit($key) {
|
||||
$this->load->model('user_model');
|
||||
@@ -37,23 +58,20 @@ class API extends CI_Controller {
|
||||
|
||||
$this->load->helper(array('form', 'url'));
|
||||
|
||||
$this->load->library('form_validation');
|
||||
$this->load->library('form_validation');
|
||||
|
||||
$this->form_validation->set_rules('api_desc', __("API Description"), 'required');
|
||||
$this->form_validation->set_rules('api_key', __("API Key is required. Do not change this field"), 'required');
|
||||
$this->form_validation->set_rules('api_desc', __("API Description"), 'required');
|
||||
$this->form_validation->set_rules('api_key', __("API Key is required. Do not change this field"), 'required');
|
||||
|
||||
$data['api_info'] = $this->api_model->key_description($key);
|
||||
$data['api_info'] = $this->api_model->key_description($key);
|
||||
|
||||
if ($this->form_validation->run() == FALSE)
|
||||
{
|
||||
$data['page_title'] = __("Edit API Description");
|
||||
if ($this->form_validation->run() == FALSE) {
|
||||
$data['page_title'] = __("Edit API Description");
|
||||
|
||||
$this->load->view('interface_assets/header', $data);
|
||||
$this->load->view('api/description');
|
||||
$this->load->view('interface_assets/footer');
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
// Success!
|
||||
|
||||
$this->api_model->update_key_description($this->input->post('api_key'), $this->input->post('api_desc'));
|
||||
@@ -259,6 +277,11 @@ class API extends CI_Controller {
|
||||
echo json_encode(['status' => 'failed', 'reason' => "wrong JSON"]);
|
||||
die();
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
$identifier = isset($obj['key']) ? $obj['key'] : null;
|
||||
$this->check_rate_limit('qso', $identifier);
|
||||
|
||||
$raw='';
|
||||
|
||||
if(!isset($obj['key']) || $this->api_model->authorize($obj['key']) == 0) {
|
||||
@@ -728,6 +751,10 @@ class API extends CI_Controller {
|
||||
// Decode JSON and store
|
||||
$obj = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// Check rate limit
|
||||
$identifier = isset($obj['key']) ? $obj['key'] : null;
|
||||
$this->check_rate_limit('radio', $identifier);
|
||||
|
||||
if(!isset($obj['key']) || $this->api_model->authorize($obj['key']) == 0) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'failed', 'reason' => "missing api key"]);
|
||||
@@ -823,6 +850,10 @@ class API extends CI_Controller {
|
||||
function private_lookup() {
|
||||
// Lookup Callsign and dxcc for further informations. UseCase: e.g. external Application which checks calls like FlexRadio-Overlay
|
||||
$raw_input = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// Check rate limit
|
||||
$identifier = isset($raw_input['key']) ? $raw_input['key'] : null;
|
||||
$this->check_rate_limit('private_lookup', $identifier);
|
||||
$user_id='';
|
||||
$this->load->model('user_model');
|
||||
if (!( $this->user_model->authorize($this->config->item('auth_mode') ))) { // User not authorized?
|
||||
@@ -1024,6 +1055,11 @@ class API extends CI_Controller {
|
||||
function lookup() {
|
||||
// This API provides NO information about previous QSOs. It just derivates DXCC, Lat, Long. It is used by the DXClusterAPI
|
||||
$raw_input = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// Check rate limit
|
||||
$identifier = isset($raw_input['key']) ? $raw_input['key'] : null;
|
||||
$this->check_rate_limit('lookup', $identifier);
|
||||
|
||||
$user_id = '';
|
||||
$this->load->model('user_model');
|
||||
if (!( $this->user_model->authorize($this->config->item('auth_mode') ))) { // User not authorized?
|
||||
|
||||
178
application/libraries/Rate_limit.php
Normal file
178
application/libraries/Rate_limit.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
if (!defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
/**
|
||||
* Rate Limiting Library
|
||||
*
|
||||
* Implements sliding window rate limiting for API endpoints
|
||||
*/
|
||||
class Rate_limit {
|
||||
|
||||
protected $CI;
|
||||
protected $cache;
|
||||
protected $rate_limits;
|
||||
|
||||
public function __construct() {
|
||||
$this->CI =& get_instance();
|
||||
$this->CI->load->driver('cache', ['adapter' => 'file']);
|
||||
|
||||
// Load rate limit config - if not set or empty, rate limiting is disabled
|
||||
$this->rate_limits = $this->CI->config->item('api_rate_limits');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rate limiting is enabled
|
||||
*
|
||||
* @return bool True if rate limiting is enabled
|
||||
*/
|
||||
public function is_enabled() {
|
||||
return !empty($this->rate_limits) && is_array($this->rate_limits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and enforce rate limit for an endpoint
|
||||
*
|
||||
* @param string $endpoint The API endpoint name
|
||||
* @param string $identifier Unique identifier (API key, user ID, or IP)
|
||||
* @return array Array with 'allowed' (bool) and 'retry_after' (int|null)
|
||||
*/
|
||||
public function check($endpoint, $identifier = null) {
|
||||
// If rate limiting is disabled, always allow
|
||||
if (!$this->is_enabled()) {
|
||||
return ['allowed' => true, 'retry_after' => null];
|
||||
}
|
||||
|
||||
// Get the limit for this endpoint
|
||||
$limit = $this->get_limit($endpoint);
|
||||
|
||||
// If no limit configured for this endpoint, allow
|
||||
if ($limit === null) {
|
||||
return ['allowed' => true, 'retry_after' => null];
|
||||
}
|
||||
|
||||
// Generate identifier if not provided
|
||||
if ($identifier === null) {
|
||||
$identifier = $this->generate_identifier();
|
||||
}
|
||||
|
||||
$max_requests = $limit['requests'];
|
||||
$window_seconds = $limit['window'];
|
||||
|
||||
return $this->sliding_window_check($endpoint, $identifier, $max_requests, $window_seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding window rate limit check
|
||||
*
|
||||
* @param string $endpoint The API endpoint name
|
||||
* @param string $identifier Unique identifier for the requester
|
||||
* @param int $max_requests Maximum requests allowed
|
||||
* @param int $window_seconds Time window in seconds
|
||||
* @return array Array with 'allowed' (bool) and 'retry_after' (int|null)
|
||||
*/
|
||||
protected function sliding_window_check($endpoint, $identifier, $max_requests, $window_seconds) {
|
||||
$cache_key = 'rate_limit_' . md5($endpoint . '_' . $identifier);
|
||||
$now = time();
|
||||
|
||||
// Get existing request timestamps from cache
|
||||
$request_timestamps = $this->CI->cache->get($cache_key);
|
||||
|
||||
if ($request_timestamps === false) {
|
||||
$request_timestamps = [];
|
||||
}
|
||||
|
||||
// Filter out timestamps that are outside the time window
|
||||
$window_start = $now - $window_seconds;
|
||||
$request_timestamps = array_filter($request_timestamps, function($timestamp) use ($window_start) {
|
||||
return $timestamp > $window_start;
|
||||
});
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count($request_timestamps) >= $max_requests) {
|
||||
// Sort timestamps to find the oldest one
|
||||
sort($request_timestamps);
|
||||
$oldest_request = $request_timestamps[0];
|
||||
$retry_after = ($oldest_request + $window_seconds) - $now;
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'retry_after' => max(1, $retry_after)
|
||||
];
|
||||
}
|
||||
|
||||
// Add current request timestamp
|
||||
$request_timestamps[] = $now;
|
||||
|
||||
// Save back to cache with TTL equal to the window size
|
||||
$this->CI->cache->save($cache_key, $request_timestamps, $window_seconds);
|
||||
|
||||
return [
|
||||
'allowed' => true,
|
||||
'retry_after' => null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit configuration for an endpoint
|
||||
*
|
||||
* @param string $endpoint The API endpoint name
|
||||
* @return array|null Array with 'requests' and 'window', or null if not configured
|
||||
*/
|
||||
protected function get_limit($endpoint) {
|
||||
if (!is_array($this->rate_limits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for endpoint-specific limit
|
||||
if (isset($this->rate_limits[$endpoint])) {
|
||||
return $this->rate_limits[$endpoint];
|
||||
}
|
||||
|
||||
// Check for default limit
|
||||
if (isset($this->rate_limits['default'])) {
|
||||
return $this->rate_limits['default'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate identifier for rate limiting
|
||||
* Uses API key from request, session user ID, or IP address
|
||||
*
|
||||
* @return string Unique identifier
|
||||
*/
|
||||
protected function generate_identifier() {
|
||||
$raw_input = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
// Try API key first
|
||||
if (!empty($raw_input['key'])) {
|
||||
return 'api_key_' . $raw_input['key'];
|
||||
}
|
||||
|
||||
// Try session user ID
|
||||
if (!empty($this->CI->session->userdata('user_id'))) {
|
||||
return 'user_' . $this->CI->session->userdata('user_id');
|
||||
}
|
||||
|
||||
// Fallback to IP address
|
||||
return 'ip_' . $this->CI->input->ip_address();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send rate limit exceeded response
|
||||
*
|
||||
* @param int $retry_after Seconds until retry is allowed
|
||||
*/
|
||||
public function send_limit_exceeded_response($retry_after) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retry_after);
|
||||
echo json_encode([
|
||||
'status' => 'failed',
|
||||
'reason' => 'Rate limit exceeded. Try again in ' . $retry_after . ' seconds.',
|
||||
'retry_after' => $retry_after
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user