Merge pull request #2785 from int2001/rate_limit_api

Introduce (optional) Ratelimiting on API
This commit is contained in:
Joerg (DJ7NT)
2026-01-09 06:48:33 +01:00
committed by GitHub
3 changed files with 270 additions and 10 deletions

View File

@@ -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],
// ];

View File

@@ -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?

View 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;
}
}