diff --git a/application/config/config.sample.php b/application/config/config.sample.php index 098833e3f..d638c45d4 100644 --- a/application/config/config.sample.php +++ b/application/config/config.sample.php @@ -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], +// ]; diff --git a/application/controllers/Api.php b/application/controllers/Api.php index e0e941648..d8b5c7c5d 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -28,6 +28,26 @@ 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']) { + $this->rate_limit->send_limit_exceeded_response($result['retry_after']); + return false; + } + + return true; + } function edit($key) { $this->load->model('user_model'); @@ -37,23 +57,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 +276,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 +750,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 +849,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 +1054,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? diff --git a/application/libraries/Rate_limit.php b/application/libraries/Rate_limit.php new file mode 100644 index 000000000..fef2d6bcf --- /dev/null +++ b/application/libraries/Rate_limit.php @@ -0,0 +1,179 @@ +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) { + log_message("Error","Rate limit exceeded"); + 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; + } +}