1<?php
2/**
3 * Server config checks management
4 */
5
6declare(strict_types=1);
7
8namespace PhpMyAdmin\Config;
9
10use PhpMyAdmin\Core;
11use PhpMyAdmin\Sanitize;
12use PhpMyAdmin\Setup\Index as SetupIndex;
13use PhpMyAdmin\Url;
14use PhpMyAdmin\Util;
15use function count;
16use function function_exists;
17use function htmlspecialchars;
18use function implode;
19use function ini_get;
20use function preg_match;
21use function sprintf;
22use function strlen;
23
24/**
25 * Performs various compatibility, security and consistency checks on current config
26 *
27 * Outputs results to message list, must be called between SetupIndex::messagesBegin()
28 * and SetupIndex::messagesEnd()
29 */
30class ServerConfigChecks
31{
32    /** @var ConfigFile configurations being checked */
33    protected $cfg;
34
35    /**
36     * @param ConfigFile $cfg Configuration
37     */
38    public function __construct(ConfigFile $cfg)
39    {
40        $this->cfg = $cfg;
41    }
42
43    /**
44     * Perform config checks
45     *
46     * @return void
47     */
48    public function performConfigChecks()
49    {
50        $blowfishSecret = $this->cfg->get('blowfish_secret');
51        $blowfishSecretSet = false;
52        $cookieAuthUsed = false;
53
54        [$cookieAuthUsed, $blowfishSecret, $blowfishSecretSet]
55            = $this->performConfigChecksServers(
56                $cookieAuthUsed,
57                $blowfishSecret,
58                $blowfishSecretSet
59            );
60
61        $this->performConfigChecksCookieAuthUsed(
62            $cookieAuthUsed,
63            $blowfishSecretSet,
64            $blowfishSecret
65        );
66
67        // $cfg['AllowArbitraryServer']
68        // should be disabled
69        if ($this->cfg->getValue('AllowArbitraryServer')) {
70            $sAllowArbitraryServerWarn = sprintf(
71                __(
72                    'This %soption%s should be disabled as it allows attackers to '
73                    . 'bruteforce login to any MySQL server. If you feel this is necessary, '
74                    . 'use %srestrict login to MySQL server%s or %strusted proxies list%s. '
75                    . 'However, IP-based protection with trusted proxies list may not be '
76                    . 'reliable if your IP belongs to an ISP where thousands of users, '
77                    . 'including you, are connected to.'
78                ),
79                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
80                '[/a]',
81                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
82                '[/a]',
83                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
84                '[/a]'
85            );
86            SetupIndex::messagesSet(
87                'notice',
88                'AllowArbitraryServer',
89                Descriptions::get('AllowArbitraryServer'),
90                Sanitize::sanitizeMessage($sAllowArbitraryServerWarn)
91            );
92        }
93
94        $this->performConfigChecksLoginCookie();
95
96        $sDirectoryNotice = __(
97            'This value should be double checked to ensure that this directory is '
98            . 'neither world accessible nor readable or writable by other users on '
99            . 'your server.'
100        );
101
102        // $cfg['SaveDir']
103        // should not be world-accessible
104        if ($this->cfg->getValue('SaveDir') != '') {
105            SetupIndex::messagesSet(
106                'notice',
107                'SaveDir',
108                Descriptions::get('SaveDir'),
109                Sanitize::sanitizeMessage($sDirectoryNotice)
110            );
111        }
112
113        // $cfg['TempDir']
114        // should not be world-accessible
115        if ($this->cfg->getValue('TempDir') != '') {
116            SetupIndex::messagesSet(
117                'notice',
118                'TempDir',
119                Descriptions::get('TempDir'),
120                Sanitize::sanitizeMessage($sDirectoryNotice)
121            );
122        }
123
124        $this->performConfigChecksZips();
125    }
126
127    /**
128     * Check config of servers
129     *
130     * @param bool   $cookieAuthUsed    Cookie auth is used
131     * @param string $blowfishSecret    Blowfish secret
132     * @param bool   $blowfishSecretSet Blowfish secret set
133     *
134     * @return array
135     */
136    protected function performConfigChecksServers(
137        $cookieAuthUsed,
138        $blowfishSecret,
139        $blowfishSecretSet
140    ) {
141        $serverCnt = $this->cfg->getServerCount();
142        for ($i = 1; $i <= $serverCnt; $i++) {
143            $cookieAuthServer
144                = ($this->cfg->getValue('Servers/' . $i . '/auth_type') === 'cookie');
145            $cookieAuthUsed |= $cookieAuthServer;
146            $serverName = $this->performConfigChecksServersGetServerName(
147                $this->cfg->getServerName($i),
148                $i
149            );
150            $serverName = htmlspecialchars($serverName);
151
152            [$blowfishSecret, $blowfishSecretSet]
153                = $this->performConfigChecksServersSetBlowfishSecret(
154                    $blowfishSecret,
155                    $cookieAuthServer,
156                    $blowfishSecretSet
157                );
158
159            // $cfg['Servers'][$i]['ssl']
160            // should be enabled if possible
161            if (! $this->cfg->getValue('Servers/' . $i . '/ssl')) {
162                $title = Descriptions::get('Servers/1/ssl') . ' (' . $serverName . ')';
163                SetupIndex::messagesSet(
164                    'notice',
165                    'Servers/' . $i . '/ssl',
166                    $title,
167                    __(
168                        'You should use SSL connections if your database server '
169                        . 'supports it.'
170                    )
171                );
172            }
173            $sSecurityInfoMsg = Sanitize::sanitizeMessage(sprintf(
174                __(
175                    'If you feel this is necessary, use additional protection settings - '
176                    . '%1$shost authentication%2$s settings and %3$strusted proxies list%4$s. '
177                    . 'However, IP-based protection may not be reliable if your IP belongs '
178                    . 'to an ISP where thousands of users, including you, are connected to.'
179                ),
180                '[a@' . Url::getCommon(['page' => 'servers', 'mode' => 'edit', 'id' => $i]) . '#tab_Server_config]',
181                '[/a]',
182                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
183                '[/a]'
184            ));
185
186            // $cfg['Servers'][$i]['auth_type']
187            // warn about full user credentials if 'auth_type' is 'config'
188            if ($this->cfg->getValue('Servers/' . $i . '/auth_type') === 'config'
189                && $this->cfg->getValue('Servers/' . $i . '/user') != ''
190                && $this->cfg->getValue('Servers/' . $i . '/password') != ''
191            ) {
192                $title = Descriptions::get('Servers/1/auth_type')
193                    . ' (' . $serverName . ')';
194                SetupIndex::messagesSet(
195                    'notice',
196                    'Servers/' . $i . '/auth_type',
197                    $title,
198                    Sanitize::sanitizeMessage(sprintf(
199                        __(
200                            'You set the [kbd]config[/kbd] authentication type and included '
201                            . 'username and password for auto-login, which is not a desirable '
202                            . 'option for live hosts. Anyone who knows or guesses your phpMyAdmin '
203                            . 'URL can directly access your phpMyAdmin panel. Set %1$sauthentication '
204                            . 'type%2$s to [kbd]cookie[/kbd] or [kbd]http[/kbd].'
205                        ),
206                        '[a@' . Url::getCommon(['page' => 'servers', 'mode' => 'edit', 'id' => $i]) . '#tab_Server]',
207                        '[/a]'
208                    ))
209                    . ' ' . $sSecurityInfoMsg
210                );
211            }
212
213            // $cfg['Servers'][$i]['AllowRoot']
214            // $cfg['Servers'][$i]['AllowNoPassword']
215            // serious security flaw
216            if (! $this->cfg->getValue('Servers/' . $i . '/AllowRoot')
217                || ! $this->cfg->getValue('Servers/' . $i . '/AllowNoPassword')
218            ) {
219                continue;
220            }
221
222            $title = Descriptions::get('Servers/1/AllowNoPassword')
223                . ' (' . $serverName . ')';
224            SetupIndex::messagesSet(
225                'notice',
226                'Servers/' . $i . '/AllowNoPassword',
227                $title,
228                __('You allow for connecting to the server without a password.')
229                . ' ' . $sSecurityInfoMsg
230            );
231        }
232
233        return [
234            $cookieAuthUsed,
235            $blowfishSecret,
236            $blowfishSecretSet,
237        ];
238    }
239
240    /**
241     * Set blowfish secret
242     *
243     * @param string|null $blowfishSecret    Blowfish secret
244     * @param bool        $cookieAuthServer  Cookie auth is used
245     * @param bool        $blowfishSecretSet Blowfish secret set
246     *
247     * @return array
248     */
249    protected function performConfigChecksServersSetBlowfishSecret(
250        $blowfishSecret,
251        $cookieAuthServer,
252        $blowfishSecretSet
253    ): array {
254        if ($cookieAuthServer && $blowfishSecret === null) {
255            $blowfishSecretSet = true;
256            $this->cfg->set('blowfish_secret', Util::generateRandom(32));
257        }
258
259        return [
260            $blowfishSecret,
261            $blowfishSecretSet,
262        ];
263    }
264
265    /**
266     * Define server name
267     *
268     * @param string $serverName Server name
269     * @param int    $serverId   Server id
270     *
271     * @return string Server name
272     */
273    protected function performConfigChecksServersGetServerName(
274        $serverName,
275        $serverId
276    ) {
277        if ($serverName === 'localhost') {
278            return $serverName . ' [' . $serverId . ']';
279        }
280
281        return $serverName;
282    }
283
284    /**
285     * Perform config checks for zip part.
286     *
287     * @return void
288     */
289    protected function performConfigChecksZips()
290    {
291        $this->performConfigChecksServerGZipdump();
292        $this->performConfigChecksServerBZipdump();
293        $this->performConfigChecksServersZipdump();
294    }
295
296    /**
297     * Perform config checks for zip part.
298     *
299     * @return void
300     */
301    protected function performConfigChecksServersZipdump()
302    {
303        // $cfg['ZipDump']
304        // requires zip_open in import
305        if ($this->cfg->getValue('ZipDump') && ! $this->functionExists('zip_open')) {
306            SetupIndex::messagesSet(
307                'error',
308                'ZipDump_import',
309                Descriptions::get('ZipDump'),
310                Sanitize::sanitizeMessage(sprintf(
311                    __(
312                        '%sZip decompression%s requires functions (%s) which are unavailable '
313                        . 'on this system.'
314                    ),
315                    '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Import_export]',
316                    '[/a]',
317                    'zip_open'
318                ))
319            );
320        }
321
322        // $cfg['ZipDump']
323        // requires gzcompress in export
324        if (! $this->cfg->getValue('ZipDump') || $this->functionExists('gzcompress')) {
325            return;
326        }
327
328        SetupIndex::messagesSet(
329            'error',
330            'ZipDump_export',
331            Descriptions::get('ZipDump'),
332            Sanitize::sanitizeMessage(sprintf(
333                __(
334                    '%sZip compression%s requires functions (%s) which are unavailable on '
335                    . 'this system.'
336                ),
337                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Import_export]',
338                '[/a]',
339                'gzcompress'
340            ))
341        );
342    }
343
344    /**
345     * Check config of servers
346     *
347     * @param bool   $cookieAuthUsed    Cookie auth is used
348     * @param bool   $blowfishSecretSet Blowfish secret set
349     * @param string $blowfishSecret    Blowfish secret
350     *
351     * @return void
352     */
353    protected function performConfigChecksCookieAuthUsed(
354        $cookieAuthUsed,
355        $blowfishSecretSet,
356        $blowfishSecret
357    ) {
358        // $cfg['blowfish_secret']
359        // it's required for 'cookie' authentication
360        if (! $cookieAuthUsed) {
361            return;
362        }
363
364        if ($blowfishSecretSet) {
365            // 'cookie' auth used, blowfish_secret was generated
366            SetupIndex::messagesSet(
367                'notice',
368                'blowfish_secret_created',
369                Descriptions::get('blowfish_secret'),
370                Sanitize::sanitizeMessage(__(
371                    'You didn\'t have blowfish secret set and have enabled '
372                    . '[kbd]cookie[/kbd] authentication, so a key was automatically '
373                    . 'generated for you. It is used to encrypt cookies; you don\'t need to '
374                    . 'remember it.'
375                ))
376            );
377        } else {
378            $blowfishWarnings = [];
379            // check length
380            if (strlen($blowfishSecret) < 32) {
381                // too short key
382                $blowfishWarnings[] = __(
383                    'Key is too short, it should have at least 32 characters.'
384                );
385            }
386            // check used characters
387            $hasDigits = (bool) preg_match('/\d/', $blowfishSecret);
388            $hasChars = (bool) preg_match('/\S/', $blowfishSecret);
389            $hasNonword = (bool) preg_match('/\W/', $blowfishSecret);
390            if (! $hasDigits || ! $hasChars || ! $hasNonword) {
391                $blowfishWarnings[] = Sanitize::sanitizeMessage(
392                    __(
393                        'Key should contain letters, numbers [em]and[/em] '
394                        . 'special characters.'
395                    )
396                );
397            }
398            if (! empty($blowfishWarnings)) {
399                SetupIndex::messagesSet(
400                    'error',
401                    'blowfish_warnings' . count($blowfishWarnings),
402                    Descriptions::get('blowfish_secret'),
403                    implode('<br>', $blowfishWarnings)
404                );
405            }
406        }
407    }
408
409    /**
410     * Check configuration for login cookie
411     *
412     * @return void
413     */
414    protected function performConfigChecksLoginCookie()
415    {
416        // $cfg['LoginCookieValidity']
417        // value greater than session.gc_maxlifetime will cause
418        // random session invalidation after that time
419        $loginCookieValidity = $this->cfg->getValue('LoginCookieValidity');
420        if ($loginCookieValidity > ini_get('session.gc_maxlifetime')
421        ) {
422            SetupIndex::messagesSet(
423                'error',
424                'LoginCookieValidity',
425                Descriptions::get('LoginCookieValidity'),
426                Sanitize::sanitizeMessage(sprintf(
427                    __(
428                        '%1$sLogin cookie validity%2$s greater than %3$ssession.gc_maxlifetime%4$s may '
429                        . 'cause random session invalidation (currently session.gc_maxlifetime '
430                        . 'is %5$d).'
431                    ),
432                    '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
433                    '[/a]',
434                    '[a@' . Core::getPHPDocLink('session.configuration.php#ini.session.gc-maxlifetime') . ']',
435                    '[/a]',
436                    ini_get('session.gc_maxlifetime')
437                ))
438            );
439        }
440
441        // $cfg['LoginCookieValidity']
442        // should be at most 1800 (30 min)
443        if ($loginCookieValidity > 1800) {
444            SetupIndex::messagesSet(
445                'notice',
446                'LoginCookieValidity',
447                Descriptions::get('LoginCookieValidity'),
448                Sanitize::sanitizeMessage(sprintf(
449                    __(
450                        '%sLogin cookie validity%s should be set to 1800 seconds (30 minutes) '
451                        . 'at most. Values larger than 1800 may pose a security risk such as '
452                        . 'impersonation.'
453                    ),
454                    '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
455                    '[/a]'
456                ))
457            );
458        }
459
460        // $cfg['LoginCookieValidity']
461        // $cfg['LoginCookieStore']
462        // LoginCookieValidity must be less or equal to LoginCookieStore
463        if (($this->cfg->getValue('LoginCookieStore') == 0)
464            || ($loginCookieValidity <= $this->cfg->getValue('LoginCookieStore'))
465        ) {
466            return;
467        }
468
469        SetupIndex::messagesSet(
470            'error',
471            'LoginCookieValidity',
472            Descriptions::get('LoginCookieValidity'),
473            Sanitize::sanitizeMessage(sprintf(
474                __(
475                    'If using [kbd]cookie[/kbd] authentication and %sLogin cookie store%s '
476                    . 'is not 0, %sLogin cookie validity%s must be set to a value less or '
477                    . 'equal to it.'
478                ),
479                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
480                '[/a]',
481                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Security]',
482                '[/a]'
483            ))
484        );
485    }
486
487    /**
488     * Check GZipDump configuration
489     *
490     * @return void
491     */
492    protected function performConfigChecksServerBZipdump()
493    {
494        // $cfg['BZipDump']
495        // requires bzip2 functions
496        if (! $this->cfg->getValue('BZipDump')
497            || ($this->functionExists('bzopen') && $this->functionExists('bzcompress'))
498        ) {
499            return;
500        }
501
502        $functions = $this->functionExists('bzopen')
503            ? '' :
504            'bzopen';
505        $functions .= $this->functionExists('bzcompress')
506            ? ''
507            : ($functions ? ', ' : '') . 'bzcompress';
508        SetupIndex::messagesSet(
509            'error',
510            'BZipDump',
511            Descriptions::get('BZipDump'),
512            Sanitize::sanitizeMessage(
513                sprintf(
514                    __(
515                        '%1$sBzip2 compression and decompression%2$s requires functions (%3$s) which '
516                         . 'are unavailable on this system.'
517                    ),
518                    '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Import_export]',
519                    '[/a]',
520                    $functions
521                )
522            )
523        );
524    }
525
526    /**
527     * Check GZipDump configuration
528     *
529     * @return void
530     */
531    protected function performConfigChecksServerGZipdump()
532    {
533        // $cfg['GZipDump']
534        // requires zlib functions
535        if (! $this->cfg->getValue('GZipDump')
536            || ($this->functionExists('gzopen') && $this->functionExists('gzencode'))
537        ) {
538            return;
539        }
540
541        SetupIndex::messagesSet(
542            'error',
543            'GZipDump',
544            Descriptions::get('GZipDump'),
545            Sanitize::sanitizeMessage(sprintf(
546                __(
547                    '%1$sGZip compression and decompression%2$s requires functions (%3$s) which '
548                    . 'are unavailable on this system.'
549                ),
550                '[a@' . Url::getCommon(['page' => 'form', 'formset' => 'Features']) . '#tab_Import_export]',
551                '[/a]',
552                'gzencode'
553            ))
554        );
555    }
556
557    /**
558     * Wrapper around function_exists to allow mock in test
559     *
560     * @param string $name Function name
561     *
562     * @return bool
563     */
564    protected function functionExists($name)
565    {
566        return function_exists($name);
567    }
568}
569