Merge branch 'master' of https://github.com/civicrm/civicrm-core into CRM-10551
[civicrm-core.git] / CRM / Utils / Check / Security.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.6 |
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
37 /**
38 * CMS have a different pattern to their default file path and URL.
39 *
40 * @TODO This function might be better shared in CRM_Utils_Check
41 * class, but that class doesn't yet exist.
42 */
43 public function getFilePathMarker() {
44 $config = CRM_Core_Config::singleton();
45 switch ($config->userFramework) {
46 case 'Joomla':
47 return '/media/';
48
49 default:
50 return '/files/';
51 }
52 }
53
54 /**
55 * Run some sanity checks.
56 *
57 * @return array<CRM_Utils_Check_Message>
58 */
59 public function checkAll() {
60 $messages = array_merge(
61 $this->checkLogFileIsNotAccessible(),
62 $this->checkUploadsAreNotAccessible(),
63 $this->checkDirectoriesAreNotBrowseable(),
64 $this->checkFilesAreNotPresent()
65 );
66 return $messages;
67 }
68
69 /**
70 * Check if our logfile is directly accessible.
71 *
72 * Per CiviCRM default the logfile sits in a folder which is
73 * web-accessible, and is protected by a default .htaccess
74 * configuration. If server config causes the .htaccess not to
75 * function as intended, there may be information disclosure.
76 *
77 * The debug log may be jam-packed with sensitive data, we don't
78 * want that.
79 *
80 * Being able to be retrieved directly doesn't mean the logfile
81 * is browseable or visible to search engines; it means it can be
82 * requested directly.
83 *
84 * @return array
85 * Array of messages
86 * @see CRM-14091
87 */
88 public function checkLogFileIsNotAccessible() {
89 $messages = array();
90
91 $config = CRM_Core_Config::singleton();
92
93 $log = CRM_Core_Error::createDebugLogger();
94 $log_filename = str_replace('\\', '/', $log->_filename);
95
96 $filePathMarker = $this->getFilePathMarker();
97
98 // Hazard a guess at the URL of the logfile, based on common
99 // CiviCRM layouts.
100 if ($upload_url = explode($filePathMarker, $config->imageUploadURL)) {
101 $url[] = $upload_url[0];
102 if ($log_path = explode($filePathMarker, $log_filename)) {
103 $url[] = $log_path[1];
104 $log_url = implode($filePathMarker, $url);
105 $headers = @get_headers($log_url);
106 if (stripos($headers[0], '200')) {
107 $docs_url = $this->createDocUrl('checkLogFileIsNotAccessible');
108 $msg = 'The <a href="%1">CiviCRM debug log</a> should not be downloadable.'
109 . '<br />' .
110 '<a href="%2">Read more about this warning</a>';
111 $messages[] = new CRM_Utils_Check_Message(
112 'checkLogFileIsNotAccessible',
113 ts($msg, array(1 => $log_url, 2 => $docs_url)),
114 ts('Security Warning')
115 );
116 }
117 }
118 }
119
120 return $messages;
121 }
122
123 /**
124 * Check if our uploads directory has accessible files.
125 *
126 * We'll test a handful of files randomly. Hazard a guess at the URL
127 * of the uploads dir, based on common CiviCRM layouts. Try and
128 * request the files, and if any are successfully retrieved, warn.
129 *
130 * Being retrievable doesn't mean the files are browseable or visible
131 * to search engines; it only means they can be requested directly.
132 *
133 * @return array
134 * Array of messages
135 * @see CRM-14091
136 *
137 * @TODO: Test with WordPress, Joomla.
138 */
139 public function checkUploadsAreNotAccessible() {
140 $messages = array();
141
142 $config = CRM_Core_Config::singleton();
143 $privateDirs = array(
144 $config->uploadDir,
145 $config->customFileUploadDir,
146 );
147
148 foreach ($privateDirs as $privateDir) {
149 $heuristicUrl = $this->guessUrl($privateDir);
150 if ($this->isDirAccessible($privateDir, $heuristicUrl)) {
151 $messages[] = new CRM_Utils_Check_Message(
152 'checkUploadsAreNotAccessible',
153 ts('Files in the data directory (<a href="%3">%2</a>) should not be downloadable.'
154 . '<br />'
155 . '<a href="%1">Read more about this warning</a>',
156 array(
157 1 => $this->createDocUrl('checkUploadsAreNotAccessible'),
158 2 => $privateDir,
159 3 => $heuristicUrl,
160 )),
161 ts('Security Warning')
162 );
163 }
164 }
165
166 return $messages;
167 }
168
169 /**
170 * Check if our uploads or ConfigAndLog directories have browseable
171 * listings.
172 *
173 * Retrieve a listing of files from the local filesystem, and the
174 * corresponding path via HTTP. Then check and see if the local
175 * files are represented in the HTTP result; if so then warn. This
176 * MAY trigger false positives (if you have files named 'a', 'e'
177 * we'll probably match that).
178 *
179 * @return array
180 * Array of messages
181 * @see CRM-14091
182 *
183 * @TODO: Test with WordPress, Joomla.
184 */
185 public function checkDirectoriesAreNotBrowseable() {
186 $messages = array();
187 $config = CRM_Core_Config::singleton();
188 $publicDirs = array(
189 $config->imageUploadDir => $config->imageUploadURL,
190 );
191
192 // Setup index.html files to prevent browsing
193 foreach ($publicDirs as $publicDir => $publicUrl) {
194 CRM_Utils_File::restrictBrowsing($publicDir);
195 }
196
197 // Test that $publicDir is not browsable
198 foreach ($publicDirs as $publicDir => $publicUrl) {
199 if ($this->isBrowsable($publicDir, $publicUrl)) {
200 $msg = 'Directory <a href="%1">%2</a> should not be browseable via the web.'
201 . '<br />' .
202 '<a href="%3">Read more about this warning</a>';
203 $docs_url = $this->createDocUrl('checkDirectoriesAreNotBrowseable');
204 $messages[] = new CRM_Utils_Check_Message(
205 'checkDirectoriesAreNotBrowseable',
206 ts($msg, array(1 => $publicDir, 2 => $publicDir, 3 => $docs_url)),
207 ts('Security Warning')
208 );
209 }
210 }
211
212 return $messages;
213 }
214
215
216 /**
217 * Check that some files are not present.
218 *
219 * These files have generally been deleted but Civi source tree but could be
220 * left online if one does a faulty upgrade.
221 *
222 * @return array of messages
223 */
224 public function checkFilesAreNotPresent() {
225 global $civicrm_root;
226
227 $messages = array();
228 $files = array(
229 "{$civicrm_root}/packages/dompdf/dompdf.php", // CRM-16005, upgraded from Civi <= 4.5.6
230 "{$civicrm_root}/packages/vendor/dompdf/dompdf/dompdf.php", // CRM-16005, Civi >= 4.5.7
231 "{$civicrm_root}/vendor/dompdf/dompdf/dompdf.php", // CRM-16005, Civi >= 4.6.0
232 );
233 foreach ($files as $file) {
234 if (file_exists($file)) {
235 $messages[] = new CRM_Utils_Check_Message(
236 'checkFilesAreNotPresent',
237 ts('File \'%1\' presents a security risk and should be deleted.', array(1 => $file)),
238 ts('Security Warning')
239 );
240 }
241 }
242 return $messages;
243 }
244
245 /**
246 * Determine whether $url is a public, browsable listing for $dir
247 *
248 * @param string $dir
249 * Local dir path.
250 * @param string $url
251 * Public URL.
252 * @return bool
253 */
254 public function isBrowsable($dir, $url) {
255 if (empty($dir) || empty($url) || !is_dir($dir)) {
256 return FALSE;
257 }
258
259 $result = FALSE;
260 $file = 'delete-this-' . CRM_Utils_String::createRandom(10, CRM_Utils_String::ALPHANUMERIC);
261
262 // this could be a new system with no uploads (yet) -- so we'll make a file
263 file_put_contents("$dir/$file", "delete me");
264 $content = @file_get_contents("$url");
265 if (stristr($content, $file)) {
266 $result = TRUE;
267 }
268 unlink("$dir/$file");
269
270 return $result;
271 }
272
273 /**
274 * Determine whether $url is a public version of $dir in which files
275 * are remotely accessible.
276 *
277 * @param string $dir
278 * Local dir path.
279 * @param string $url
280 * Public URL.
281 * @return bool
282 */
283 public function isDirAccessible($dir, $url) {
284 $dir = rtrim($dir, '/');
285 $url = rtrim($url, '/');
286 if (empty($dir) || empty($url) || !is_dir($dir)) {
287 return FALSE;
288 }
289
290 $result = FALSE;
291 $file = 'delete-this-' . CRM_Utils_String::createRandom(10, CRM_Utils_String::ALPHANUMERIC);
292
293 // this could be a new system with no uploads (yet) -- so we'll make a file
294 file_put_contents("$dir/$file", "delete me");
295
296 $headers = @get_headers("$url/$file");
297 if (stripos($headers[0], '200')) {
298 $content = @file_get_contents("$url/$file");
299 if (preg_match('/delete me/', $content)) {
300 $result = TRUE;
301 }
302 }
303
304 unlink("$dir/$file");
305
306 return $result;
307 }
308
309 /**
310 * @param $topic
311 *
312 * @return string
313 */
314 public function createDocUrl($topic) {
315 return CRM_Utils_System::getWikiBaseURL() . $topic;
316 }
317
318 /**
319 * Make a guess about the URL that corresponds to $targetDir.
320 *
321 * @param string $targetDir
322 * Local path to a directory.
323 * @return string
324 * a guessed URL for $realDir
325 */
326 public function guessUrl($targetDir) {
327 $filePathMarker = $this->getFilePathMarker();
328 $config = CRM_Core_Config::singleton();
329
330 list ($heuristicBaseUrl, $ignore) = explode($filePathMarker, $config->imageUploadURL);
331 list ($ignore, $heuristicSuffix) = explode($filePathMarker, str_replace('\\', '/', $targetDir));
332 return $heuristicBaseUrl . $filePathMarker . $heuristicSuffix;
333 }
334
335 }