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