|
1
|
|
|
<?php |
|
2
|
|
|
|
|
3
|
|
|
/** |
|
4
|
|
|
* CodeIgniter limiter |
|
5
|
|
|
* |
|
6
|
|
|
* @license MIT License |
|
7
|
|
|
* @package Limiter |
|
8
|
|
|
* @author Mechazawa |
|
9
|
|
|
* @version 1.0.0 |
|
10
|
|
|
*/ |
|
11
|
|
|
|
|
12
|
|
|
|
|
13
|
|
|
class Limiter { |
|
14
|
|
|
|
|
15
|
|
|
/** @type CI_Controller */ |
|
16
|
|
|
protected $CI; |
|
17
|
|
|
protected $table = 'rate_limit'; |
|
18
|
|
|
protected $base_limit = 0; // infinite |
|
19
|
|
|
protected $whitelist = array('127.0.0.1'); |
|
20
|
|
|
protected $header_show = TRUE; |
|
21
|
|
|
protected $checksum_algorithm = 'md4'; |
|
22
|
|
|
protected $header_prefix = 'X-RateLimit-'; |
|
23
|
|
|
protected $flush_on_abort = FALSE; |
|
24
|
|
|
|
|
25
|
|
|
protected $user_data = array(); |
|
26
|
|
|
protected $user_hash = FALSE; |
|
27
|
|
|
|
|
28
|
|
|
private $_truncated = FALSE; |
|
29
|
|
|
private $_info_cache = array(); |
|
30
|
|
|
|
|
31
|
|
|
private $_sql_truncate = 'DELETE FROM `RATE_TABLE` WHERE `start` < (NOW() - INTERVAL 1 HOUR)'; |
|
32
|
|
|
private $_sql_info = 'SELECT `count`, `start`, (`start` + INTERVAL (1 - TIMESTAMPDIFF(HOUR, UTC_TIMESTAMP(), NOW())) HOUR) \'reset_epoch\' FROM `RATE_TABLE` WHERE `client` = ? AND `target` = ?'; |
|
33
|
|
|
private $_sql_update = 'INSERT INTO `RATE_TABLE` (`client`, `target`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `count` = `count` + 1'; |
|
34
|
|
|
private $_config_fields = array( |
|
35
|
|
|
'table', 'base_limit', 'checksum_algorithm', |
|
36
|
|
|
'header', 'header_prefix', 'flush_on_abort' |
|
37
|
|
|
); |
|
38
|
|
|
|
|
39
|
|
|
public function __construct($config = array()) { |
|
40
|
|
|
$this->CI = &get_instance(); |
|
41
|
|
|
|
|
42
|
|
|
if(!is_array($config)) { |
|
43
|
|
|
$config = array(); |
|
44
|
|
|
} |
|
45
|
|
|
|
|
46
|
|
|
foreach($this->_config_fields as $field) { |
|
47
|
|
|
if(array_key_exists($field, $config)) { |
|
48
|
|
|
$this->{$field} = $config[$field]; |
|
49
|
|
|
} |
|
50
|
|
|
} |
|
51
|
|
|
|
|
52
|
|
|
$sql = array('truncate', 'info', 'update'); |
|
53
|
|
|
foreach($sql as $s) { |
|
54
|
|
|
$this->{"_sql_$s"} = str_replace('RATE_TABLE', $this->table, $this->{"_sql_$s"}); |
|
55
|
|
|
} |
|
56
|
|
|
|
|
57
|
|
|
$this->add_user_data($this->CI->input->ip_address()); |
|
58
|
|
|
|
|
59
|
|
|
log_message('debug', 'Limiter Class Initialized'); |
|
60
|
|
|
} |
|
61
|
|
|
|
|
62
|
|
|
/** |
|
63
|
|
|
* Rate limits the amount of requests that can be sent by a client. |
|
64
|
|
|
* |
|
65
|
|
|
* @param string $target |
|
66
|
|
|
* @param int $req_per_hour Overrides base_limit setting if set |
|
67
|
|
|
* @param bool $flush_on_abort Overrides flush_on_abort setting if set |
|
68
|
|
|
* @param bool $show_header Overrides header_show setting if set |
|
69
|
|
|
* @return bool Should request be aborted |
|
70
|
|
|
*/ |
|
71
|
|
|
public function limit($target = '_global', $req_per_hour = null, $flush_on_abort = null, $show_header = null) { |
|
72
|
|
|
$req_per_hour = $req_per_hour !== null ? $req_per_hour : $this->base_limit; |
|
73
|
|
|
$flush_on_abort = $flush_on_abort !== null ? $flush_on_abort : $this->flush_on_abort; |
|
74
|
|
|
$show_header = $show_header !== null ? $show_header : $this->header_show; |
|
75
|
|
|
|
|
76
|
|
|
$truncated = $this->_truncate(); |
|
77
|
|
|
if(!$truncated) { |
|
78
|
|
|
log_message('DEBUG', 'WARN: Could not truncate rate limit table'); |
|
79
|
|
|
} |
|
80
|
|
|
|
|
81
|
|
|
if($this->is_whitelisted()) { |
|
82
|
|
|
$req_per_hour = 0; |
|
83
|
|
|
} |
|
84
|
|
|
|
|
85
|
|
|
$abort = FALSE; |
|
86
|
|
|
if($req_per_hour > 0) { |
|
87
|
|
|
$info = $this->get_limit_info($target); |
|
88
|
|
|
|
|
89
|
|
|
if($info === FALSE) { |
|
90
|
|
|
$info = new stdClass(); |
|
91
|
|
|
$info->count = 0; |
|
92
|
|
|
$info->reset_epoch = gmdate('d M Y H:i:s', time() + (60 * 60)); |
|
93
|
|
|
$info->start = date('d M Y H:i:s'); |
|
94
|
|
|
} |
|
95
|
|
|
|
|
96
|
|
|
if($req_per_hour - $info->count > 0) { |
|
97
|
|
|
$data = array('client' => $this->get_hash(), 'target' => $target); |
|
98
|
|
|
$this->CI->db->query($this->_sql_update, $data); |
|
99
|
|
|
$info->count++; |
|
100
|
|
|
} else { |
|
101
|
|
|
$abort = TRUE; |
|
102
|
|
|
} |
|
103
|
|
|
|
|
104
|
|
|
if($show_header === TRUE) { |
|
105
|
|
|
$headers = array( |
|
106
|
|
|
'Limit' => $req_per_hour, |
|
107
|
|
|
'Remaining' => $req_per_hour - $info->count, |
|
108
|
|
|
'Reset' => strtotime($info->reset_epoch), |
|
109
|
|
|
); |
|
110
|
|
|
|
|
111
|
|
|
foreach(array_keys($headers) as $h) { |
|
112
|
|
|
$this->CI->output->set_header("$this->header_prefix$h: $headers[$h]"); |
|
113
|
|
|
} |
|
114
|
|
|
} |
|
115
|
|
|
|
|
116
|
|
|
$this->_info_cache[$target] = $info; |
|
117
|
|
|
|
|
118
|
|
|
|
|
119
|
|
|
if($abort) { |
|
120
|
|
|
$retry_seconds = strtotime($info->reset_epoch) - strtotime(gmdate('d M Y H:i:s')); |
|
121
|
|
|
$this->CI->output->set_header("Retry-After: $retry_seconds"); |
|
122
|
|
|
$this->CI->output->set_status_header(503, 'Rate limit reached'); |
|
123
|
|
|
|
|
124
|
|
|
if($flush_on_abort) { |
|
125
|
|
|
$this->CI->output->_display(); |
|
126
|
|
|
exit; |
|
|
|
|
|
|
127
|
|
|
} |
|
128
|
|
|
} |
|
129
|
|
|
} |
|
130
|
|
|
|
|
131
|
|
|
return $abort; |
|
132
|
|
|
} |
|
133
|
|
|
|
|
134
|
|
|
/** |
|
135
|
|
|
* Forget the client ever visited $target. |
|
136
|
|
|
* |
|
137
|
|
|
* @param string $target |
|
138
|
|
|
*/ |
|
139
|
|
|
public function reset_rate($target = '_global') { |
|
140
|
|
|
$this->CI->db->delete($this->table, array( |
|
|
|
|
|
|
141
|
|
|
'client' => $this->get_hash(), |
|
142
|
|
|
'target' => $target |
|
143
|
|
|
)); |
|
144
|
|
|
} |
|
145
|
|
|
|
|
146
|
|
|
/** |
|
147
|
|
|
* Forgets all rate limits attached to the client. |
|
148
|
|
|
*/ |
|
149
|
|
|
public function forget_client() { |
|
150
|
|
|
$this->CI->db->delete($this->table, array('client' => $this->get_hash())); |
|
|
|
|
|
|
151
|
|
|
} |
|
152
|
|
|
|
|
153
|
|
|
/** |
|
154
|
|
|
* Used to obtain the client hash. |
|
155
|
|
|
* |
|
156
|
|
|
* Returns false if hash generation failed |
|
157
|
|
|
* @return string |
|
158
|
|
|
*/ |
|
159
|
|
|
public function get_hash() { |
|
160
|
|
|
if($this->user_hash === FALSE) { |
|
161
|
|
|
$this->user_hash = $this->_generate_hash(); |
|
|
|
|
|
|
162
|
|
|
} |
|
163
|
|
|
return $this->user_hash; |
|
164
|
|
|
} |
|
165
|
|
|
|
|
166
|
|
|
/** |
|
167
|
|
|
* Adds entropy to the client hash. Make |
|
168
|
|
|
* sure that this is some sort of static |
|
169
|
|
|
* data such as a username/id. |
|
170
|
|
|
* |
|
171
|
|
|
* @param string $data Any seeding data |
|
172
|
|
|
*/ |
|
173
|
|
|
public function add_user_data($data) { |
|
174
|
|
|
array_push($this->user_data, (string) $data); |
|
175
|
|
|
|
|
176
|
|
|
if(count($this->_info_cache) !== 0) { |
|
177
|
|
|
log_message('DEBUG', 'WARN: Emptying info cache due to user data changing'); |
|
178
|
|
|
$this->_info_cache = array(); // Empty cache |
|
179
|
|
|
} |
|
180
|
|
|
|
|
181
|
|
|
if($this->user_hash !== FALSE) { |
|
182
|
|
|
log_message('DEBUG', 'WARN: Adding user data after hash was generated'); |
|
183
|
|
|
$this->user_hash = $this->_generate_hash(); |
|
|
|
|
|
|
184
|
|
|
} |
|
185
|
|
|
} |
|
186
|
|
|
|
|
187
|
|
|
/** |
|
188
|
|
|
* Get target rate info |
|
189
|
|
|
* |
|
190
|
|
|
* @param string $target |
|
191
|
|
|
* @return stdClass|false Info object, returns false if no info is present |
|
192
|
|
|
*/ |
|
193
|
|
|
public function get_limit_info($target = '_global') { |
|
194
|
|
|
if(!array_key_exists($target, $this->_info_cache)) { |
|
195
|
|
|
$data = array('client' => $this->get_hash(), 'target' => $target); |
|
196
|
|
|
$info = $this->CI->db->query($this->_sql_info, $data)->row(); |
|
197
|
|
|
|
|
198
|
|
|
$this->_info_cache[$target] = $info; |
|
199
|
|
|
} else { |
|
200
|
|
|
$info = $this->_info_cache[$target]; |
|
201
|
|
|
} |
|
202
|
|
|
|
|
203
|
|
|
$valid_data = isset($info->count); |
|
204
|
|
|
return $valid_data ? $info : FALSE; |
|
205
|
|
|
} |
|
206
|
|
|
|
|
207
|
|
|
/** |
|
208
|
|
|
* @param $target |
|
209
|
|
|
* @return integer Amount of attempts |
|
210
|
|
|
*/ |
|
211
|
|
|
public function get_attempts($target) { |
|
212
|
|
|
return $this->get_limit_info($target)->count; |
|
213
|
|
|
} |
|
214
|
|
|
|
|
215
|
|
|
/** |
|
216
|
|
|
* @param null $ip If null current IP |
|
217
|
|
|
* @return bool Is whitelisted |
|
218
|
|
|
*/ |
|
219
|
|
|
public function is_whitelisted($ip = null) { |
|
220
|
|
|
$ip = $ip ?: $this->CI->input->ip_address(); |
|
221
|
|
|
return in_array($ip, $this->whitelist); |
|
222
|
|
|
} |
|
223
|
|
|
|
|
224
|
|
|
private function _truncate() { |
|
225
|
|
|
if(!$this->_truncated) { |
|
226
|
|
|
$this->_truncated = $this->CI->db->query($this->_sql_truncate); |
|
227
|
|
|
} |
|
228
|
|
|
return $this->_truncated; |
|
229
|
|
|
} |
|
230
|
|
|
|
|
231
|
|
|
private function _generate_hash() { |
|
232
|
|
|
return hash($this->checksum_algorithm, join('%', $this->user_data)); |
|
233
|
|
|
} |
|
234
|
|
|
} |
|
235
|
|
|
|
An exit expression should only be used in rare cases. For example, if you write a short command line script.
In most cases however, using an
exitexpression makes the code untestable and often causes incompatibilities with other libraries. Thus, unless you are absolutely sure it is required here, we recommend to refactor your code to avoid its usage.