Merge pull request #17878 from civicrm/5.28
[civicrm-core.git] / CRM / Utils / Check / Component / Security.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17 class CRM_Utils_Check_Component_Security extends CRM_Utils_Check_Component {
18
19 /**
20 * CMS have a different pattern to their default file path and URL.
21 *
22 * @todo Use Civi::paths instead?
23 */
24 public function getFilePathMarker() {
25 $config = CRM_Core_Config::singleton();
26 switch ($config->userFramework) {
27 case 'Joomla':
28 return '/media/';
29
30 default:
31 return '/files/';
32 }
33 }
34
35 /**
36 * Check if our logfile is directly accessible.
37 *
38 * Per CiviCRM default the logfile sits in a folder which is
39 * web-accessible, and is protected by a default .htaccess
40 * configuration. If server config causes the .htaccess not to
41 * function as intended, there may be information disclosure.
42 *
43 * The debug log may be jam-packed with sensitive data, we don't
44 * want that.
45 *
46 * Being able to be retrieved directly doesn't mean the logfile
47 * is browseable or visible to search engines; it means it can be
48 * requested directly.
49 *
50 * @return array
51 * Array of messages
52 * @see CRM-14091
53 */
54 public function checkLogFileIsNotAccessible() {
55 $messages = [];
56
57 $config = CRM_Core_Config::singleton();
58
59 $log = CRM_Core_Error::createDebugLogger();
60 $log_filename = str_replace('\\', '/', $log->_filename);
61
62 $filePathMarker = $this->getFilePathMarker();
63
64 // Hazard a guess at the URL of the logfile, based on common
65 // CiviCRM layouts.
66 if ($upload_url = explode($filePathMarker, $config->imageUploadURL)) {
67 $url[] = $upload_url[0];
68 if ($log_path = explode($filePathMarker, $log_filename)) {
69 // CRM-17149: check if debug log path includes $filePathMarker
70 if (count($log_path) > 1) {
71 $url[] = $log_path[1];
72 $log_url = implode($filePathMarker, $url);
73 if ($this->fileExists($log_url)) {
74 $docs_url = $this->createDocUrl('the-log-file-should-not-be-accessible');
75 $msg = 'The <a href="%1">CiviCRM debug log</a> should not be downloadable.'
76 . '<br />' .
77 '<a href="%2">Read more about this warning</a>';
78 $messages[] = new CRM_Utils_Check_Message(
79 __FUNCTION__,
80 ts($msg, [1 => $log_url, 2 => $docs_url]),
81 ts('Security Warning'),
82 \Psr\Log\LogLevel::WARNING,
83 'fa-lock'
84 );
85 }
86 }
87 }
88 }
89
90 return $messages;
91 }
92
93 /**
94 * Check if our uploads directory has accessible files.
95 *
96 * We'll test a handful of files randomly. Hazard a guess at the URL
97 * of the uploads dir, based on common CiviCRM layouts. Try and
98 * request the files, and if any are successfully retrieved, warn.
99 *
100 * Being retrievable doesn't mean the files are browseable or visible
101 * to search engines; it only means they can be requested directly.
102 *
103 * @return array
104 * Array of messages
105 * @see CRM-14091
106 *
107 * @todo Test with WordPress, Joomla.
108 */
109 public function checkUploadsAreNotAccessible() {
110 $messages = [];
111
112 $config = CRM_Core_Config::singleton();
113 $privateDirs = [
114 $config->uploadDir,
115 $config->customFileUploadDir,
116 ];
117
118 foreach ($privateDirs as $privateDir) {
119 $heuristicUrl = $this->guessUrl($privateDir);
120 if ($this->isDirAccessible($privateDir, $heuristicUrl)) {
121 $messages[] = new CRM_Utils_Check_Message(
122 __FUNCTION__,
123 ts('Files in the data directory (<a href="%3">%2</a>) should not be downloadable.'
124 . '<br />'
125 . '<a href="%1">Read more about this warning</a>',
126 [
127 1 => $this->createDocUrl('uploads-should-not-be-accessible'),
128 2 => $privateDir,
129 3 => $heuristicUrl,
130 ]),
131 ts('Private Files Readable'),
132 \Psr\Log\LogLevel::WARNING,
133 'fa-lock'
134 );
135 }
136 }
137
138 return $messages;
139 }
140
141 /**
142 * Check if our uploads or ConfigAndLog directories have browseable
143 * listings.
144 *
145 * Retrieve a listing of files from the local filesystem, and the
146 * corresponding path via HTTP. Then check and see if the local
147 * files are represented in the HTTP result; if so then warn. This
148 * MAY trigger false positives (if you have files named 'a', 'e'
149 * we'll probably match that).
150 *
151 * @return array
152 * Array of messages
153 * @see CRM-14091
154 *
155 * @todo Test with WordPress, Joomla.
156 */
157 public function checkDirectoriesAreNotBrowseable() {
158 $messages = [];
159 $config = CRM_Core_Config::singleton();
160 $publicDirs = [
161 $config->imageUploadDir => $config->imageUploadURL,
162 ];
163
164 // Setup index.html files to prevent browsing
165 foreach ($publicDirs as $publicDir => $publicUrl) {
166 CRM_Utils_File::restrictBrowsing($publicDir);
167 }
168
169 // Test that $publicDir is not browsable
170 foreach ($publicDirs as $publicDir => $publicUrl) {
171 if ($this->isBrowsable($publicDir, $publicUrl)) {
172 $msg = 'Directory <a href="%1">%2</a> should not be browseable via the web.'
173 . '<br />' .
174 '<a href="%3">Read more about this warning</a>';
175 $docs_url = $this->createDocUrl('directories-should-not-be-browsable');
176 $messages[] = new CRM_Utils_Check_Message(
177 __FUNCTION__,
178 ts($msg, [1 => $publicDir, 2 => $publicDir, 3 => $docs_url]),
179 ts('Browseable Directories'),
180 \Psr\Log\LogLevel::ERROR,
181 'fa-lock'
182 );
183 }
184 }
185
186 return $messages;
187 }
188
189 /**
190 * Check that some files are not present.
191 *
192 * These files have generally been deleted but Civi source tree but could be
193 * left online if one does a faulty upgrade.
194 *
195 * @return array of messages
196 */
197 public function checkFilesAreNotPresent() {
198 $packages_path = rtrim(\Civi::paths()->getPath('[civicrm.packages]/'), '/' . DIRECTORY_SEPARATOR);
199 $vendor_path = rtrim(\Civi::paths()->getPath('[civicrm.vendor]/'), '/' . DIRECTORY_SEPARATOR);
200
201 $messages = [];
202 $files = [
203 [
204 // CRM-16005, upgraded from Civi <= 4.5.6
205 "{$packages_path}/dompdf/dompdf.php",
206 \Psr\Log\LogLevel::CRITICAL,
207 ],
208 [
209 // CRM-16005, Civi >= 4.5.7
210 "{$packages_path}/vendor/dompdf/dompdf/dompdf.php",
211 \Psr\Log\LogLevel::CRITICAL,
212 ],
213 [
214 // CRM-16005, Civi >= 4.6.0
215 "{$vendor_path}/dompdf/dompdf/dompdf.php",
216 \Psr\Log\LogLevel::CRITICAL,
217 ],
218 [
219 // CIVI-SA-2013-001
220 "{$packages_path}/OpenFlashChart/php-ofc-library/ofc_upload_image.php",
221 \Psr\Log\LogLevel::CRITICAL,
222 ],
223 [
224 "{$packages_path}/html2text/class.html2text.inc",
225 \Psr\Log\LogLevel::CRITICAL,
226 ],
227 ];
228 foreach ($files as $file) {
229 if (file_exists($file[0])) {
230 $messages[] = new CRM_Utils_Check_Message(
231 __FUNCTION__,
232 ts('File \'%1\' presents a security risk and should be deleted.', [1 => $file[0]]),
233 ts('Unsafe Files'),
234 $file[1],
235 'fa-lock'
236 );
237 }
238 }
239 return $messages;
240 }
241
242 /**
243 * Discourage use of remote profile forms.
244 */
245 public function checkRemoteProfile() {
246 $messages = [];
247
248 if (Civi::settings()->get('remote_profile_submissions')) {
249 $messages[] = new CRM_Utils_Check_Message(
250 __FUNCTION__,
251 ts('Warning: External profile support (aka "HTML Snippet" support) is enabled in <a href="%1">system settings</a>. This setting may be prone to abuse. If you must retain it, consider HTTP throttling or other protections.',
252 [1 => CRM_Utils_System::url('civicrm/admin/setting/misc', 'reset=1')]
253 ),
254 ts('Remote Profiles Enabled'),
255 \Psr\Log\LogLevel::WARNING,
256 'fa-lock'
257 );
258 }
259
260 return $messages;
261 }
262
263 /**
264 * Check that the sysadmin has not modified the Cxn
265 * security setup.
266 */
267 public function checkCxnOverrides() {
268 $list = [];
269 if (defined('CIVICRM_CXN_CA') && CIVICRM_CXN_CA !== 'CiviRootCA') {
270 $list[] = 'CIVICRM_CXN_CA';
271 }
272 if (defined('CIVICRM_CXN_APPS_URL') && CIVICRM_CXN_APPS_URL !== \Civi\Cxn\Rpc\Constants::OFFICIAL_APPMETAS_URL) {
273 $list[] = 'CIVICRM_CXN_APPS_URL';
274 }
275
276 $messages = [];
277
278 if (!empty($list)) {
279 $messages[] = new CRM_Utils_Check_Message(
280 __FUNCTION__,
281 ts('The system administrator has disabled security settings (%1). Connections to remote applications are insecure.', [
282 1 => implode(', ', $list),
283 ]),
284 ts('Security Warning'),
285 \Psr\Log\LogLevel::WARNING,
286 'fa-lock'
287 );
288 }
289
290 return $messages;
291 }
292
293 /**
294 * Determine whether $url is a public, browsable listing for $dir
295 *
296 * @param string $dir
297 * Local dir path.
298 * @param string $url
299 * Public URL.
300 * @return bool
301 */
302 public function isBrowsable($dir, $url) {
303 if (empty($dir) || empty($url) || !is_dir($dir)) {
304 return FALSE;
305 }
306
307 $result = FALSE;
308
309 // this could be a new system with no uploads (yet) -- so we'll make a file
310 $file = CRM_Utils_File::createFakeFile($dir);
311
312 if ($file === FALSE) {
313 // Couldn't write the file
314 return FALSE;
315 }
316
317 $content = @file_get_contents("$url");
318 if (stristr($content, $file)) {
319 $result = TRUE;
320 }
321 unlink("$dir/$file");
322
323 return $result;
324 }
325
326 /**
327 * Determine whether $url is a public version of $dir in which files
328 * are remotely accessible.
329 *
330 * @param string $dir
331 * Local dir path.
332 * @param string $url
333 * Public URL.
334 * @return bool
335 */
336 public function isDirAccessible($dir, $url) {
337 $url = rtrim($url, '/');
338 if (empty($dir) || empty($url) || !is_dir($dir)) {
339 return FALSE;
340 }
341
342 $result = FALSE;
343 $file = CRM_Utils_File::createFakeFile($dir, 'delete me');
344
345 if ($file === FALSE) {
346 // Couldn't write the file
347 return FALSE;
348 }
349
350 if ($this->fileExists("$url/$file")) {
351 $content = @file_get_contents("$url/$file");
352 if (preg_match('/delete me/', $content)) {
353 $result = TRUE;
354 }
355 }
356
357 unlink("$dir/$file");
358
359 return $result;
360 }
361
362 /**
363 * @param $topic
364 *
365 * @return string
366 */
367 public function createDocUrl($topic) {
368 return CRM_Utils_System::docURL2('sysadmin/setup/security#' . $topic, TRUE);
369 }
370
371 /**
372 * Make a guess about the URL that corresponds to $targetDir.
373 *
374 * @param string $targetDir
375 * Local path to a directory.
376 * @return string
377 * a guessed URL for $realDir
378 */
379 public function guessUrl($targetDir) {
380 $filePathMarker = $this->getFilePathMarker();
381 $config = CRM_Core_Config::singleton();
382
383 list($heuristicBaseUrl) = explode($filePathMarker, $config->imageUploadURL);
384 list(, $heuristicSuffix) = array_pad(explode($filePathMarker, str_replace('\\', '/', $targetDir)), 2, '');
385 return $heuristicBaseUrl . $filePathMarker . $heuristicSuffix;
386 }
387
388 }