1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
/** |
4
|
|
|
* |
5
|
|
|
* This file is part of the Apix Project. |
6
|
|
|
* |
7
|
|
|
* (c) Franck Cassedanne <franck at ouarz.net> |
8
|
|
|
* |
9
|
|
|
* @license http://opensource.org/licenses/BSD-3-Clause New BSD License |
10
|
|
|
* |
11
|
|
|
*/ |
12
|
|
|
|
13
|
|
|
namespace Apix\Plugin; |
14
|
|
|
|
15
|
|
|
use Apix\Service, |
16
|
|
|
Apix\HttpRequest, |
17
|
|
|
Apix\Exception; |
18
|
|
|
|
19
|
|
|
/** |
20
|
|
|
* Apix plugin providing Cross-Origin Resource Sharing |
21
|
|
|
* |
22
|
|
|
* @see http://www.w3.org/TR/cors/ |
23
|
|
|
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS |
24
|
|
|
*/ |
25
|
|
|
class Cors extends PluginAbstractEntity |
26
|
|
|
{ |
27
|
|
|
public static $hook = array('entity', 'early'); |
28
|
|
|
|
29
|
|
|
protected $annotation = 'api_cors'; |
30
|
|
|
|
31
|
|
|
protected $options = array( |
32
|
|
|
'enable' => true, // whether to enable or not |
33
|
|
|
|
34
|
|
|
// -- whitelist (regex) |
35
|
|
|
'scheme' => 'https?', // allows both http and https |
36
|
|
|
'host' => '.*\.info\.com', // the allowed host domain(s) or ip(s) |
37
|
|
|
'port' => '(:[0-9]+)?', // the alowed port(s) |
38
|
|
|
|
39
|
|
|
// -- CORS directives |
40
|
|
|
'allow-origin' => 'origin', // 'origin', '*', 'null', string-list |
|
|
|
|
41
|
|
|
'allow-credentials' => false, // wether to allow Cookies and HTTP Auth |
42
|
|
|
'expose-headers' => null, // comma-delimited HTTP headers exposed |
43
|
|
|
|
44
|
|
|
// -- preflight |
45
|
|
|
'max-age' => 3600, // TTL in seconds for preflight |
46
|
|
|
'allow-methods' => 'GET,POST', // comma-delimited HTTP methods allowed |
47
|
|
|
'allow-headers' => 'x-apix', // comma-delimited HTTP headers allowed |
48
|
|
|
); |
49
|
|
|
|
50
|
|
|
/** |
51
|
|
|
* @{@inheritdoc} |
52
|
|
|
*/ |
53
|
|
|
public function update(\SplSubject $entity) |
|
|
|
|
54
|
|
|
{ |
55
|
|
|
$this->setEntity($entity); |
|
|
|
|
56
|
|
|
|
57
|
|
|
// skip this plugin if it is disable. |
58
|
|
|
if ( !$this->getSubTagBool('enable', $this->options['enable']) ) { |
|
|
|
|
59
|
|
|
return false; |
60
|
|
|
} |
61
|
|
|
|
62
|
|
|
if ( $host = $this->getSubTagString('host', $this->options['host']) ) { |
63
|
|
|
$this->options['host'] = $host; |
64
|
|
|
} |
65
|
|
|
|
66
|
|
|
// Grab the Origin: header. |
67
|
|
|
$http_origin = array_key_exists('HTTP_ORIGIN', $_SERVER) |
68
|
|
|
? $_SERVER['HTTP_ORIGIN'] |
69
|
|
|
: null; |
70
|
|
|
|
71
|
|
|
// If whitelisted then it is a valid CORS request. |
72
|
|
|
if ( |
73
|
|
|
$http_origin |
74
|
|
|
// && $_SERVER['HTTP_HOST'] == $http_origin |
|
|
|
|
75
|
|
|
&& self::isOriginAllowed( |
76
|
|
|
$http_origin, |
77
|
|
|
$this->options['host'], |
78
|
|
|
$this->options['port'], $this->options['scheme'] |
79
|
|
|
) |
80
|
|
|
) { |
81
|
|
|
$response = Service::get('response'); |
82
|
|
|
|
83
|
|
|
// 5.1 Access-Control-Allow-Origin |
84
|
|
|
$http_origin = $this->options['allow-origin'] == 'origin' |
85
|
|
|
? $http_origin |
86
|
|
|
: $this->options['allow-origin']; |
87
|
|
|
$response->setHeader('Access-Control-Allow-Origin', $http_origin); |
88
|
|
|
|
89
|
|
|
// 5.2 Access-Control-Allow-Credentials |
90
|
|
|
// The actual request can include user credentials |
91
|
|
|
// e.g. cookies, XmlHttpRequest.withCredentials=true |
92
|
|
|
if ($this->options['allow-credentials'] === true) { |
93
|
|
|
$response->setHeader('Access-Control-Allow-Credentials', true); |
94
|
|
|
} |
95
|
|
|
|
96
|
|
|
// 5.3 Access-Control-Expose-Headers |
97
|
|
|
// Which response headers are available (besides the generic ones) |
98
|
|
|
if ($this->options['expose-headers']) { |
99
|
|
|
$response->setHeader('Access-Control-Expose-Headers', |
100
|
|
|
$this->options['expose-headers']); |
101
|
|
|
} |
102
|
|
|
|
103
|
|
|
$request = $response->getRequest(); |
104
|
|
|
if ( self::isPreflight($request) ) { |
105
|
|
|
|
106
|
|
|
// 5.4 Access-Control-Max-Age |
107
|
|
|
if ($this->options['max-age'] > 0) { |
108
|
|
|
// Cache the request for the provided amount of seconds |
109
|
|
|
$response->setHeader('Access-Control-Max-Age', |
110
|
|
|
(int) $this->options['max-age']); |
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
// 5.5 Access-Control-Allow-Methods |
114
|
|
|
if ($request->hasHeader('Access-Control-Request-Method') ) { |
115
|
|
|
if (!in_array( |
116
|
|
|
$request->getHeader('Access-Control-Request-Method'), |
117
|
|
|
self::split($this->options['allow-methods']) |
118
|
|
|
)) { |
119
|
|
|
return self::exception(); |
120
|
|
|
} |
121
|
|
|
$response->setHeader( |
122
|
|
|
'Access-Control-Allow-Methods', |
123
|
|
|
$this->options['allow-methods'] |
124
|
|
|
); |
125
|
|
|
} |
126
|
|
|
|
127
|
|
|
// 5.6 Access-Control-Allow-Headers |
128
|
|
|
if ($request->hasHeader('Access-Control-Request-Headers')) { |
129
|
|
|
$req_headers = self::split( |
130
|
|
|
$request->getHeader('Access-Control-Request-Headers') |
131
|
|
|
); |
132
|
|
|
$allowed = self::split($this->options['allow-headers']); |
133
|
|
|
foreach ($req_headers as $req_header) { |
134
|
|
|
if (!in_array($req_header, $allowed)) { |
135
|
|
|
return self::exception(); |
136
|
|
|
} |
137
|
|
|
} |
138
|
|
|
$response->setHeader( |
139
|
|
|
'Access-Control-Allow-Headers', |
140
|
|
|
$this->options['allow-headers'] |
141
|
|
|
); |
142
|
|
|
} |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
return true; |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
// so it must be an invalid CORS request |
149
|
|
|
return self::exception(); |
150
|
|
|
} |
151
|
|
|
|
152
|
|
|
/** |
153
|
|
|
* Throws a \DomainException. |
154
|
|
|
* |
155
|
|
|
* @throw \DomainException |
156
|
|
|
*/ |
157
|
|
|
public static function exception() |
158
|
|
|
{ |
159
|
|
|
throw new \DomainException('Not a valid CORS request.', 403); |
160
|
|
|
} |
161
|
|
|
|
162
|
|
|
/** |
163
|
|
|
* Split and trim the provided string. |
164
|
|
|
* |
165
|
|
|
* @param string $str |
166
|
|
|
* @return array |
167
|
|
|
*/ |
168
|
|
|
public static function split($str) |
169
|
|
|
{ |
170
|
|
|
return array_map('trim', explode(',', $str)); |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
/** |
174
|
|
|
* Checks the provided origin as a CORS requests. |
175
|
|
|
* |
176
|
|
|
* @param string $host The host domain(s) or IP(s) to match. |
177
|
|
|
* @param string $port Default to any port or none provided. |
178
|
|
|
* @param string $scheme Default tp 'http' and 'https'. |
179
|
|
|
* @return boolean |
180
|
|
|
*/ |
181
|
|
|
public static function isOriginAllowed( |
182
|
|
|
$origin, $host, $port='(:[0-9]+)?', $scheme='https?' |
183
|
|
|
) { |
184
|
|
|
$regex = '`^' . $scheme . ':\/\/' . $host . $port . '$`'; |
185
|
|
|
|
186
|
|
|
return (bool) preg_match($regex, $origin); |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
/** |
190
|
|
|
* Checks for a preflighted request. |
191
|
|
|
* |
192
|
|
|
* @return boolean |
193
|
|
|
*/ |
194
|
|
|
public static function isPreflight(HttpRequest $request) |
195
|
|
|
{ |
196
|
|
|
$method = $request->getMethod(); |
197
|
|
|
|
198
|
|
|
return |
199
|
|
|
// uses methods other than... |
200
|
|
|
!in_array($method, array('GET', 'HEAD', 'POST')) |
201
|
|
|
|
202
|
|
|
// if POST is used with a Content-Type other than... |
203
|
|
|
or ( $method == 'POST' |
|
|
|
|
204
|
|
|
and !in_array( |
|
|
|
|
205
|
|
|
$request->getHeader('CONTENT_TYPE'), |
206
|
|
|
array( |
207
|
|
|
'text/plain', |
208
|
|
|
'multipart/form-data', |
209
|
|
|
'application/x-www-form-urlencoded' |
210
|
|
|
) |
211
|
|
|
) |
212
|
|
|
) |
213
|
|
|
|
214
|
|
|
// if it is set with some custom request headers |
215
|
|
|
or $request->hasHeader('Access-Control-Request-Headers'); |
|
|
|
|
216
|
|
|
} |
217
|
|
|
|
218
|
|
|
} |
219
|
|
|
|
Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.
The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.
This check looks for comments that seem to be mostly valid code and reports them.