Commit | Line | Data |
---|---|---|
349b394e CB |
1 | <?php |
2 | /* | |
3 | +--------------------------------------------------------------------+ | |
4 | | CiviCRM version 4.4 | | |
5 | +--------------------------------------------------------------------+ | |
6 | | Copyright CiviCRM LLC (c) 2004-2014 | | |
7 | +--------------------------------------------------------------------+ | |
8 | | This file is a part of CiviCRM. | | |
9 | | | | |
10 | | CiviCRM is free software; you can copy, modify, and distribute it | | |
11 | | under the terms of the GNU Affero General Public License | | |
12 | | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. | | |
13 | | | | |
14 | | CiviCRM is distributed in the hope that it will be useful, but | | |
15 | | WITHOUT ANY WARRANTY; without even the implied warranty of | | |
16 | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. | | |
17 | | See the GNU Affero General Public License for more details. | | |
18 | | | | |
19 | | You should have received a copy of the GNU Affero General Public | | |
20 | | License and the CiviCRM Licensing Exception along | | |
21 | | with this program; if not, contact CiviCRM LLC | | |
22 | | at info[AT]civicrm[DOT]org. If you have questions about the | | |
23 | | GNU Affero General Public License or the licensing of CiviCRM, | | |
24 | | see the CiviCRM license FAQ at http://civicrm.org/licensing | | |
25 | +--------------------------------------------------------------------+ | |
26 | */ | |
27 | ||
28 | /** | |
29 | * | |
30 | * @package CRM | |
31 | * @copyright CiviCRM LLC (c) 2004-2014 | |
32 | * $Id: $ | |
33 | * | |
34 | */ | |
35 | class CRM_Utils_Check_Security { | |
36 | ||
6e663e84 CB |
37 | CONST |
38 | // How often to run checks and notify admins about issues. | |
39 | CHECK_TIMER = 86400; | |
40 | ||
349b394e CB |
41 | /** |
42 | * We only need one instance of this object, so we use the | |
43 | * singleton pattern and cache the instance in this variable | |
44 | * | |
45 | * @var object | |
46 | * @static | |
47 | */ | |
48 | static private $_singleton = NULL; | |
49 | ||
50 | /** | |
51 | * Provide static instance of CRM_Utils_Check_Security. | |
52 | * | |
53 | * @return CRM_Utils_Check_Security | |
54 | */ | |
55 | static function &singleton() { | |
56 | if (!isset(self::$_singleton)) { | |
57 | self::$_singleton = new CRM_Utils_Check_Security(); | |
58 | } | |
59 | return self::$_singleton; | |
60 | } | |
61 | ||
5c58b447 CB |
62 | /** |
63 | * CMS have a different pattern to their default file path and URL. | |
64 | * | |
65 | * @TODO This function might be better shared in CRM_Utils_Check | |
66 | * class, but that class doesn't yet exist. | |
67 | */ | |
a996bf82 | 68 | public function getFilePathMarker() { |
5c58b447 CB |
69 | $config = CRM_Core_Config::singleton(); |
70 | switch ($config->userFramework) { | |
71 | case 'Joomla': | |
72 | return '/media/'; | |
73 | default: | |
74 | return '/files/'; | |
75 | } | |
76 | } | |
77 | ||
9979ff93 TO |
78 | /** |
79 | * Execute "checkAll" | |
80 | */ | |
81 | public function showPeriodicAlerts() { | |
439a9f1b TO |
82 | if (CRM_Core_Permission::check('administer CiviCRM') |
83 | && CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'securityAlert', NULL, TRUE) | |
84 | ) { | |
9979ff93 TO |
85 | $session = CRM_Core_Session::singleton(); |
86 | if ($session->timer('check_' . __CLASS__, self::CHECK_TIMER)) { | |
fea6131e TO |
87 | |
88 | // Best attempt at re-securing folders | |
89 | $config = CRM_Core_Config::singleton(); | |
90 | $config->cleanup(0, FALSE); | |
91 | ||
e7d3e318 | 92 | foreach ($this->checkAll() as $message) { |
a2600a6d | 93 | CRM_Core_Session::setStatus($message->getMessage(), ts('Security Warning')); |
e7d3e318 | 94 | } |
9979ff93 TO |
95 | } |
96 | } | |
97 | } | |
98 | ||
349b394e CB |
99 | /** |
100 | * Run some sanity checks. | |
101 | * | |
102 | * This could become a hook so that CiviCRM can run both built-in | |
103 | * configuration & sanity checks, and modules/extensions can add | |
104 | * their own checks. | |
105 | * | |
106 | * We might even expose the results of these checks on the Wordpress | |
107 | * plugin status page or the Drupal admin/reports/status path. | |
108 | * | |
e7d3e318 | 109 | * @return array of messages |
349b394e CB |
110 | * @see Drupal's hook_requirements() - |
111 | * https://api.drupal.org/api/drupal/modules%21system%21system.api.php/function/hook_requirements | |
112 | */ | |
9979ff93 | 113 | public function checkAll() { |
e7d3e318 TO |
114 | $messages = array_merge( |
115 | CRM_Utils_Check_Security::singleton()->checkLogFileIsNotAccessible(), | |
116 | CRM_Utils_Check_Security::singleton()->checkUploadsAreNotAccessible(), | |
117 | CRM_Utils_Check_Security::singleton()->checkDirectoriesAreNotBrowseable() | |
118 | ); | |
119 | return $messages; | |
349b394e CB |
120 | } |
121 | ||
122 | /** | |
123 | * Check if our logfile is directly accessible. | |
124 | * | |
125 | * Per CiviCRM default the logfile sits in a folder which is | |
126 | * web-accessible, and is protected by a default .htaccess | |
127 | * configuration. If server config causes the .htaccess not to | |
128 | * function as intended, there may be information disclosure. | |
129 | * | |
130 | * The debug log may be jam-packed with sensitive data, we don't | |
131 | * want that. | |
132 | * | |
133 | * Being able to be retrieved directly doesn't mean the logfile | |
134 | * is browseable or visible to search engines; it means it can be | |
135 | * requested directly. | |
136 | * | |
e7d3e318 | 137 | * @return array of messages |
349b394e CB |
138 | * @see CRM-14091 |
139 | */ | |
23d89616 | 140 | public function checkLogFileIsNotAccessible() { |
e7d3e318 TO |
141 | $messages = array(); |
142 | ||
349b394e CB |
143 | $config = CRM_Core_Config::singleton(); |
144 | ||
145 | $log = CRM_Core_Error::createDebugLogger(); | |
146 | $log_filename = $log->_filename; | |
147 | ||
a996bf82 | 148 | $filePathMarker = $this->getFilePathMarker(); |
5c58b447 | 149 | |
349b394e CB |
150 | // Hazard a guess at the URL of the logfile, based on common |
151 | // CiviCRM layouts. | |
5c58b447 CB |
152 | if ($upload_url = explode($filePathMarker, $config->imageUploadURL)) { |
153 | $url[] = $upload_url[0]; | |
154 | if ($log_path = explode($filePathMarker, $log_filename)) { | |
155 | $url[] = $log_path[1]; | |
156 | $log_url = implode($filePathMarker, $url); | |
7d342759 | 157 | $docs_url = $this->createDocUrl('checkLogFileIsNotAccessible'); |
5ee70cb2 CB |
158 | $headers = @get_headers($log_url); |
159 | if (stripos($headers[0], '200')) { | |
5c58b447 CB |
160 | $msg = 'The <a href="%1">CiviCRM debug log</a> should not be downloadable.' |
161 | . '<br />' . | |
162 | '<a href="%2">Read more about this warning</a>'; | |
a2600a6d TO |
163 | $messages[] = new CRM_Utils_Check_Message( |
164 | 'checkLogFileIsNotAccessible', | |
165 | ts($msg, array(1 => $log_url, 2 => $docs_url)) | |
166 | ); | |
349b394e | 167 | } |
5c58b447 | 168 | } |
349b394e | 169 | } |
e7d3e318 TO |
170 | |
171 | return $messages; | |
349b394e CB |
172 | } |
173 | ||
174 | /** | |
175 | * Check if our uploads directory has accessible files. | |
176 | * | |
177 | * We'll test a handful of files randomly. Hazard a guess at the URL | |
178 | * of the uploads dir, based on common CiviCRM layouts. Try and | |
179 | * request the files, and if any are successfully retrieved, warn. | |
180 | * | |
181 | * Being retrievable doesn't mean the files are browseable or visible | |
182 | * to search engines; it only means they can be requested directly. | |
183 | * | |
e7d3e318 | 184 | * @return array of messages |
349b394e | 185 | * @see CRM-14091 |
5c58b447 CB |
186 | * |
187 | * @TODO: Test with WordPress, Joomla. | |
349b394e | 188 | */ |
23d89616 | 189 | public function checkUploadsAreNotAccessible() { |
e7d3e318 TO |
190 | $messages = array(); |
191 | ||
349b394e | 192 | $config = CRM_Core_Config::singleton(); |
a996bf82 | 193 | $filePathMarker = $this->getFilePathMarker(); |
5c58b447 CB |
194 | |
195 | if ($upload_url = explode($filePathMarker, $config->imageUploadURL)) { | |
196 | if ($files = glob($config->uploadDir . '/*')) { | |
4ab6fe5d | 197 | for ($i = 0; $i < 3; $i++) { |
5c58b447 CB |
198 | $f = array_rand($files); |
199 | if ($file_path = explode($filePathMarker, $files[$f])) { | |
200 | $url = implode($filePathMarker, array($upload_url[0], $file_path[1])); | |
5ee70cb2 CB |
201 | $headers = @get_headers($url); |
202 | if (stripos($headers[0], '200')) { | |
5c58b447 CB |
203 | $msg = 'Files in the upload directory should not be downloadable.' |
204 | . '<br />' . | |
7d342759 TO |
205 | '<a href="%1">Read more about this warning</a>'; |
206 | $docs_url = $this->createDocUrl('checkUploadsAreNotAccessible'); | |
a2600a6d TO |
207 | $messages[] = new CRM_Utils_Check_Message( |
208 | 'checkUploadsAreNotAccessible', | |
209 | ts($msg, array(1 => $docs_url)) | |
210 | ); | |
349b394e CB |
211 | } |
212 | } | |
213 | } | |
5c58b447 | 214 | } |
349b394e | 215 | } |
e7d3e318 TO |
216 | |
217 | return $messages; | |
349b394e CB |
218 | } |
219 | ||
220 | /** | |
221 | * Check if our uploads or ConfigAndLog directories have browseable | |
222 | * listings. | |
223 | * | |
224 | * Retrieve a listing of files from the local filesystem, and the | |
225 | * corresponding path via HTTP. Then check and see if the local | |
226 | * files are represented in the HTTP result; if so then warn. This | |
227 | * MAY trigger false positives (if you have files named 'a', 'e' | |
228 | * we'll probably match that). | |
229 | * | |
e7d3e318 | 230 | * @return array of messages |
349b394e | 231 | * @see CRM-14091 |
5c58b447 CB |
232 | * |
233 | * @TODO: Test with WordPress, Joomla. | |
349b394e | 234 | */ |
23d89616 | 235 | public function checkDirectoriesAreNotBrowseable() { |
e7d3e318 | 236 | $messages = array(); |
349b394e | 237 | $config = CRM_Core_Config::singleton(); |
af5201d4 TO |
238 | $publicDirs = array( |
239 | $config->imageUploadDir => $config->imageUploadURL, | |
5c58b447 | 240 | ); |
af5201d4 TO |
241 | |
242 | // Setup index.html files to prevent browsing | |
243 | foreach ($publicDirs as $publicDir => $publicUrl) { | |
244 | CRM_Utils_File::restrictBrowsing($publicDir); | |
245 | } | |
246 | ||
247 | // Test that $publicDir is not browsable | |
248 | foreach ($publicDirs as $publicDir => $publicUrl) { | |
249 | if ($this->isBrowsable($publicDir, $publicUrl)) { | |
250 | $msg = 'Directory <a href="%1">%2</a> should not be browseable via the web.' | |
251 | . '<br />' . | |
252 | '<a href="%3">Read more about this warning</a>'; | |
253 | $docs_url = $this->createDocUrl('checkDirectoriesAreNotBrowseable'); | |
a2600a6d TO |
254 | $messages[] = new CRM_Utils_Check_Message( |
255 | 'checkDirectoriesAreNotBrowseable', | |
256 | ts($msg, array(1 => $publicDir, 2 => $publicDir, 3 => $docs_url)) | |
257 | ); | |
5c58b447 | 258 | } |
349b394e | 259 | } |
e7d3e318 TO |
260 | |
261 | return $messages; | |
349b394e CB |
262 | } |
263 | ||
af5201d4 TO |
264 | /** |
265 | * Determine whether $url is a public, browsable listing for $dir | |
266 | * | |
267 | * @param string $dir local dir path | |
268 | * @param string $url public URL | |
269 | * @return bool | |
270 | */ | |
271 | public function isBrowsable($dir, $url) { | |
1305c22b | 272 | if (empty($dir) || empty($url) || !is_dir($dir)) { |
a8488826 TO |
273 | return FALSE; |
274 | } | |
275 | ||
af5201d4 TO |
276 | $result = FALSE; |
277 | $file = 'delete-this-' . CRM_Utils_String::createRandom(10, CRM_Utils_String::ALPHANUMERIC); | |
278 | ||
a8488826 | 279 | // this could be a new system with no uploads (yet) -- so we'll make a file |
af5201d4 | 280 | file_put_contents("$dir/$file", "delete me"); |
21acec2c | 281 | $content = @file_get_contents("$url"); |
af5201d4 TO |
282 | if (stristr($content, $file)) { |
283 | $result = TRUE; | |
284 | } | |
285 | unlink("$dir/$file"); | |
286 | ||
287 | return $result; | |
288 | } | |
289 | ||
7d342759 TO |
290 | public function createDocUrl($topic) { |
291 | return CRM_Utils_System::getWikiBaseURL() . $topic; | |
292 | } | |
349b394e | 293 | } |