Translated using Weblate (Russian)
[phpmyadmin.git] / src / Core.php
blob8536c44ece1de7dcb9631d05c5e5133442215a07
1 <?php
3 declare(strict_types=1);
5 namespace PhpMyAdmin;
7 use PhpMyAdmin\Exceptions\MissingExtensionException;
8 use PhpMyAdmin\Http\ServerRequest;
9 use Symfony\Component\Config\FileLocator;
10 use Symfony\Component\DependencyInjection\ContainerBuilder;
11 use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
13 use function __;
14 use function array_keys;
15 use function array_pop;
16 use function array_walk_recursive;
17 use function chr;
18 use function count;
19 use function explode;
20 use function filter_var;
21 use function function_exists;
22 use function getenv;
23 use function gmdate;
24 use function hash_equals;
25 use function hash_hmac;
26 use function header;
27 use function header_remove;
28 use function htmlspecialchars;
29 use function http_build_query;
30 use function in_array;
31 use function intval;
32 use function is_array;
33 use function is_scalar;
34 use function is_string;
35 use function json_decode;
36 use function mb_strpos;
37 use function mb_substr;
38 use function parse_str;
39 use function parse_url;
40 use function preg_match;
41 use function preg_replace;
42 use function sprintf;
43 use function str_replace;
44 use function strlen;
45 use function strpos;
46 use function strtolower;
47 use function substr;
48 use function unserialize;
49 use function urldecode;
51 use const DATE_RFC1123;
52 use const E_USER_WARNING;
53 use const FILTER_VALIDATE_IP;
55 /**
56 * Core functions used all over the scripts.
58 class Core
60 public static ContainerBuilder|null $containerBuilder = null;
62 /**
63 * Removes insecure parts in a path; used before include() or
64 * require() when a part of the path comes from an insecure source
65 * like a cookie or form.
67 * @param string $path The path to check
69 public static function securePath(string $path): string
71 // change .. to .
72 return (string) preg_replace('@\.\.*@', '.', $path);
75 /**
76 * Returns a link to the PHP documentation
78 * @param string $target anchor in documentation
80 * @return string the URL
82 public static function getPHPDocLink(string $target): string
84 /* List of PHP documentation translations */
85 $phpDocLanguages = ['pt_BR', 'zh_CN', 'fr', 'de', 'ja', 'ru', 'es', 'tr'];
87 $lang = 'en';
88 if (isset($GLOBALS['lang']) && in_array($GLOBALS['lang'], $phpDocLanguages)) {
89 $lang = $GLOBALS['lang'] === 'zh_CN' ? 'zh' : $GLOBALS['lang'];
92 return self::linkURL('https://www.php.net/manual/' . $lang . '/' . $target);
95 /**
96 * Warn or fail on missing extension.
98 * @param string $extension Extension name
99 * @param bool $fatal Whether the error is fatal.
100 * @param string $extra Extra string to append to message.
102 public static function warnMissingExtension(
103 string $extension,
104 bool $fatal = false,
105 string $extra = '',
106 ): void {
107 $message = 'The %s extension is missing. Please check your PHP configuration.';
109 /* Gettext does not have to be loaded yet here */
110 if (function_exists('__')) {
111 $message = __('The %s extension is missing. Please check your PHP configuration.');
114 $doclink = self::getPHPDocLink('book.' . $extension . '.php');
115 $message = sprintf($message, '[a@' . $doclink . '@Documentation][em]' . $extension . '[/em][/a]');
116 if ($extra != '') {
117 $message .= ' ' . $extra;
120 if ($fatal) {
121 throw new MissingExtensionException(Sanitize::sanitizeMessage($message));
124 ErrorHandler::getInstance()->addError($message, E_USER_WARNING, '', 0, false);
128 * Converts numbers like 10M into bytes
129 * Used with permission from Moodle (https://moodle.org) by Martin Dougiamas
130 * (renamed with PMA prefix to avoid double definition when embedded
131 * in Moodle)
133 * @param string|int $size size (Default = 0)
135 public static function getRealSize(string|int $size = 0): int
137 if (! $size) {
138 return 0;
141 $binaryprefixes = [
142 'T' => 1099511627776,
143 't' => 1099511627776,
144 'G' => 1073741824,
145 'g' => 1073741824,
146 'M' => 1048576,
147 'm' => 1048576,
148 'K' => 1024,
149 'k' => 1024,
152 if (preg_match('/^([0-9]+)([KMGT])/i', (string) $size, $matches)) {
153 return (int) ($matches[1] * $binaryprefixes[$matches[2]]);
156 return (int) $size;
160 * Checks given $page against given $allowList and returns true if valid
161 * it optionally ignores query parameters in $page (script.php?ignored)
163 * @param string $page page to check
164 * @param mixed[] $allowList allow list to check page against
165 * @param bool $include whether the page is going to be included
167 public static function checkPageValidity(string $page, array $allowList = [], bool $include = false): bool
169 if ($allowList === []) {
170 $allowList = ['index.php'];
173 if ($page === '') {
174 return false;
177 if (in_array($page, $allowList)) {
178 return true;
181 if ($include) {
182 return false;
185 $newPage = mb_substr(
186 $page,
188 (int) mb_strpos($page . '?', '?'),
190 if (in_array($newPage, $allowList)) {
191 return true;
194 $newPage = urldecode($page);
195 $newPage = mb_substr(
196 $newPage,
198 (int) mb_strpos($newPage . '?', '?'),
201 return in_array($newPage, $allowList);
205 * Tries to find the value for the given environment variable name
207 * Searches in $_SERVER, $_ENV then tries getenv() and apache_getenv() in this order.
209 * @psalm-param non-empty-string $variableName
211 public static function getEnv(string $variableName): string
213 $value = $_SERVER[$variableName] ?? $_ENV[$variableName] ?? getenv($variableName);
214 if (is_scalar($value) && (string) $value !== '') {
215 return (string) $value;
218 if (function_exists('apache_getenv')) {
219 return (string) apache_getenv($variableName, true); // @codeCoverageIgnore
222 return '';
226 * Returns application/json headers. This includes no caching.
228 * @return array<string, string>
230 public static function headerJSON(): array
232 // No caching
233 $headers = self::getNoCacheHeaders();
235 // Media type
236 $headers['Content-Type'] = 'application/json; charset=UTF-8';
239 * Disable content sniffing in browser.
240 * This is needed in case we include HTML in JSON, browser might assume it's html to display.
242 $headers['X-Content-Type-Options'] = 'nosniff';
244 return $headers;
247 /** @return array<string, string> */
248 public static function getNoCacheHeaders(): array
250 $headers = [];
251 $date = gmdate(DATE_RFC1123);
253 // rfc2616 - Section 14.21
254 $headers['Expires'] = $date;
256 // HTTP/1.1
257 $headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, pre-check=0, post-check=0, max-age=0';
259 // HTTP/1.0
260 $headers['Pragma'] = 'no-cache';
262 // test case: exporting a database into a .gz file with Safari
263 // would produce files not having the current time
264 // (added this header for Safari but should not harm other browsers)
265 $headers['Last-Modified'] = $date;
267 return $headers;
271 * Sends header indicating file download.
273 * @param string $filename Filename to include in headers if empty,
274 * none Content-Disposition header will be sent.
275 * @param string $mimetype MIME type to include in headers.
276 * @param int $length Length of content (optional)
277 * @param bool $noCache Whether to include no-caching headers.
279 public static function downloadHeader(
280 string $filename,
281 string $mimetype,
282 int $length = 0,
283 bool $noCache = true,
284 ): void {
285 $headers = [];
287 if ($noCache) {
288 $headers = self::getNoCacheHeaders();
291 /* Replace all possibly dangerous chars in filename */
292 $filename = Sanitize::sanitizeFilename($filename);
293 if ($filename !== '') {
294 $headers['Content-Description'] = 'File Transfer';
295 $headers['Content-Disposition'] = 'attachment; filename="' . $filename . '"';
298 $headers['Content-Type'] = $mimetype;
300 // The default output in PMA uses gzip,
301 // so if we want to output uncompressed file, we should reset the encoding.
302 // See PHP bug https://github.com/php/php-src/issues/8218
303 header_remove('Content-Encoding');
305 $headers['Content-Transfer-Encoding'] = 'binary';
307 if ($length > 0) {
308 $headers['Content-Length'] = (string) $length;
311 foreach ($headers as $name => $value) {
312 header(sprintf('%s: %s', $name, $value));
317 * Returns value of an element in $array given by $path.
318 * $path is a string describing position of an element in an associative array,
319 * eg. Servers/1/host refers to $array[Servers][1][host]
321 * @param string $path path in the array
322 * @param mixed[] $array the array
323 * @param mixed $default default value
325 * @return mixed[]|mixed|null array element or $default
327 public static function arrayRead(string $path, array $array, mixed $default = null): mixed
329 $keys = explode('/', $path);
330 $value =& $array;
331 foreach ($keys as $key) {
332 if (! isset($value[$key])) {
333 return $default;
336 $value =& $value[$key];
339 return $value;
343 * Stores value in an array
345 * @param string $path path in the array
346 * @param mixed[] $array the array
347 * @param mixed $value value to store
349 public static function arrayWrite(string $path, array &$array, mixed $value): void
351 $keys = explode('/', $path);
352 $lastKey = array_pop($keys);
353 $a =& $array;
354 foreach ($keys as $key) {
355 if (! isset($a[$key])) {
356 $a[$key] = [];
359 $a =& $a[$key];
362 $a[$lastKey] = $value;
366 * Removes value from an array
368 * @param string $path path in the array
369 * @param mixed[] $array the array
371 public static function arrayRemove(string $path, array &$array): void
373 $keys = explode('/', $path);
374 $keysLast = array_pop($keys);
375 $path = [];
376 $depth = 0;
378 $path[0] =& $array;
379 $found = true;
380 // go as deep as required or possible
381 foreach ($keys as $key) {
382 if (! isset($path[$depth][$key])) {
383 $found = false;
384 break;
387 $depth++;
388 $path[$depth] =& $path[$depth - 1][$key];
391 // if element found, remove it
392 if ($found) {
393 unset($path[$depth][$keysLast]);
394 $depth--;
397 // remove empty nested arrays
398 /** @infection-ignore-all */
399 for (; $depth >= 0; $depth--) {
400 if (isset($path[$depth + 1]) && count($path[$depth + 1]) !== 0) {
401 break;
404 unset($path[$depth][$keys[$depth]]);
409 * Returns link to (possibly) external site using defined redirector.
411 * @param string $url URL where to go.
413 * @return string URL for a link.
415 public static function linkURL(string $url): string
417 if (! preg_match('#^https?://#', $url)) {
418 return $url;
421 $params = [];
422 $params['url'] = $url;
424 $url = Url::getCommon($params);
425 //strip off token and such sensitive information. Just keep url.
426 $arr = parse_url($url);
428 if (! is_array($arr)) {
429 $arr = [];
432 parse_str($arr['query'] ?? '', $vars);
433 $query = http_build_query(['url' => $vars['url']]);
435 if (Config::getInstance()->get('is_setup')) {
436 return '../index.php?route=/url&' . $query;
439 return 'index.php?route=/url&' . $query;
443 * Checks whether domain of URL is an allowed domain or not.
444 * Use only for URLs of external sites.
446 * @param string $url URL of external site.
448 public static function isAllowedDomain(string $url): bool
450 $parsedUrl = parse_url($url);
451 if (
452 ! is_array($parsedUrl)
453 || ! isset($parsedUrl['host'])
454 || isset($parsedUrl['user'])
455 || isset($parsedUrl['pass'])
456 || isset($parsedUrl['port'])
458 return false;
461 $domainAllowList = [
462 /* Include current domain */
463 $_SERVER['SERVER_NAME'],
464 /* phpMyAdmin domains */
465 'wiki.phpmyadmin.net',
466 'www.phpmyadmin.net',
467 'phpmyadmin.net',
468 'demo.phpmyadmin.net',
469 'docs.phpmyadmin.net',
470 /* mysql.com domains */
471 'dev.mysql.com',
472 'bugs.mysql.com',
473 /* mariadb domains */
474 'mariadb.org',
475 'mariadb.com',
476 /* php.net domains */
477 'php.net',
478 'www.php.net',
479 /* Github domains*/
480 'github.com',
481 'www.github.com',
482 /* Percona domains */
483 'www.percona.com',
484 /* CVE domain */
485 'www.cve.org',
486 /* Following are doubtful ones. */
487 'mysqldatabaseadministration.blogspot.com',
490 return in_array($parsedUrl['host'], $domainAllowList, true);
494 * Replace some html-unfriendly stuff
496 * @param string $buffer String to process
498 * @return string Escaped and cleaned up text suitable for html
500 public static function mimeDefaultFunction(string $buffer): string
502 $buffer = htmlspecialchars($buffer);
503 $buffer = str_replace(' ', ' &nbsp;', $buffer);
505 return (string) preg_replace("@((\015\012)|(\015)|(\012))@", '<br>' . "\n", $buffer);
509 * Displays SQL query before executing.
511 * @param mixed[]|string $queryData Array containing queries or query itself
513 public static function previewSQL(array|string $queryData): void
515 $retval = '<div class="preview_sql">';
516 if ($queryData === '' || $queryData === []) {
517 $retval .= __('No change');
518 } elseif (is_array($queryData)) {
519 foreach ($queryData as $query) {
520 $retval .= Html\Generator::formatSql($query);
522 } else {
523 $retval .= Html\Generator::formatSql($queryData);
526 $retval .= '</div>';
527 $response = ResponseRenderer::getInstance();
528 $response->addJSON('sql_data', $retval);
532 * recursively check if variable is empty
534 * @param mixed $value the variable
536 public static function emptyRecursive(mixed $value): bool
538 if (is_array($value)) {
539 $empty = true;
540 array_walk_recursive(
541 $value,
542 /** @param mixed $item */
543 static function ($item) use (&$empty): void {
544 $empty = $empty && empty($item);
548 return $empty;
551 return empty($value);
555 * Creates some globals from $_POST variables matching a pattern
557 * @param mixed[] $postPatterns The patterns to search for
559 public static function setPostAsGlobal(array $postPatterns): void
561 foreach (array_keys($_POST) as $postKey) {
562 foreach ($postPatterns as $onePostPattern) {
563 if (! preg_match($onePostPattern, $postKey)) {
564 continue;
567 $GLOBALS[$postKey] = $_POST[$postKey];
573 * Gets the "true" IP address of the current user
575 * @return string|bool the ip of the user
577 public static function getIp(): string|bool
579 /* Get the address of user */
580 if (empty($_SERVER['REMOTE_ADDR'])) {
581 /* We do not know remote IP */
582 return false;
585 $directIp = $_SERVER['REMOTE_ADDR'];
587 /* Do we trust this IP as a proxy? If yes we will use it's header. */
588 $config = Config::getInstance();
589 if (! isset($config->settings['TrustedProxies'][$directIp])) {
590 /* Return true IP */
591 return $directIp;
595 * Parse header in form:
596 * X-Forwarded-For: client, proxy1, proxy2
598 // Get header content
599 $value = self::getEnv($config->settings['TrustedProxies'][$directIp]);
600 // Grab first element what is client adddress
601 $value = explode(',', $value)[0];
602 // checks that the header contains only one IP address,
603 $isIp = filter_var($value, FILTER_VALIDATE_IP);
605 if ($isIp !== false) {
606 // True IP behind a proxy
607 return $value;
610 // We could not parse header
611 return false;
615 * Sanitizes MySQL hostname
617 * * strips p: prefix(es)
619 * @param string $name User given hostname
621 public static function sanitizeMySQLHost(string $name): string
623 while (strtolower(substr($name, 0, 2)) === 'p:') {
624 /** @infection-ignore-all */
625 $name = substr($name, 2);
628 return $name;
632 * Sanitizes MySQL username
634 * * strips part behind null byte
636 * @param string $name User given username
638 public static function sanitizeMySQLUser(string $name): string
640 $position = strpos($name, chr(0));
641 if ($position !== false) {
642 return substr($name, 0, $position);
645 return $name;
649 * Safe unserializer wrapper
651 * It does not unserialize data containing objects
653 * @param string $data Data to unserialize
655 public static function safeUnserialize(string $data): mixed
657 /* validate serialized data */
658 $length = strlen($data);
659 $depth = 0;
660 for ($i = 0; $i < $length; $i++) {
661 $value = $data[$i];
663 switch ($value) {
664 case '}':
665 /* end of array */
666 if ($depth <= 0) {
667 return null;
670 $depth--;
671 break;
672 case 's':
673 /* string */
674 // parse sting length
675 $strlen = intval(substr($data, $i + 2));
676 // string start
677 $i = strpos($data, ':', $i + 2);
678 if ($i === false) {
679 return null;
682 // skip string, quotes and ;
683 $i += 2 + $strlen + 1;
684 if ($data[$i] !== ';') {
685 return null;
688 break;
690 case 'b':
691 case 'i':
692 case 'd':
693 /* bool, integer or double */
694 // skip value to separator
695 $i = strpos($data, ';', $i);
696 if ($i === false) {
697 return null;
700 break;
701 case 'a':
702 /* array */
703 // find array start
704 $i = strpos($data, '{', $i);
705 if ($i === false) {
706 return null;
709 // remember nesting
710 $depth++;
711 break;
712 case 'N':
713 /* null */
714 // skip to end
715 $i = strpos($data, ';', $i);
716 if ($i === false) {
717 return null;
720 break;
721 default:
722 /* any other elements are not wanted */
723 return null;
727 // check unterminated arrays
728 if ($depth > 0) {
729 return null;
732 return unserialize($data);
736 * Sign the sql query using hmac using the session token
738 * @param string $sqlQuery The sql query
740 public static function signSqlQuery(string $sqlQuery): string
742 $secret = $_SESSION[' HMAC_secret '] ?? '';
744 return hash_hmac('sha256', $sqlQuery, $secret . Config::getInstance()->settings['blowfish_secret']);
748 * Check that the sql query has a valid hmac signature
750 * @param string $sqlQuery The sql query
751 * @param string $signature The Signature to check
753 public static function checkSqlQuerySignature(string $sqlQuery, string $signature): bool
755 $secret = $_SESSION[' HMAC_secret '] ?? '';
756 $hmac = hash_hmac('sha256', $sqlQuery, $secret . Config::getInstance()->settings['blowfish_secret']);
758 return hash_equals($hmac, $signature);
761 public static function getContainerBuilder(): ContainerBuilder
763 if (self::$containerBuilder !== null) {
764 return self::$containerBuilder;
767 self::$containerBuilder = new ContainerBuilder();
768 $loader = new PhpFileLoader(self::$containerBuilder, new FileLocator(ROOT_PATH . 'app'));
769 $loader->load('services_loader.php');
771 return self::$containerBuilder;
774 public static function populateRequestWithEncryptedQueryParams(ServerRequest $request): ServerRequest
776 $queryParams = $request->getQueryParams();
777 $parsedBody = $request->getParsedBody();
779 unset($_GET['eq'], $_POST['eq'], $_REQUEST['eq']);
781 if (! isset($queryParams['eq']) && (! is_array($parsedBody) || ! isset($parsedBody['eq']))) {
782 return $request;
785 $encryptedQuery = '';
786 if (
787 is_array($parsedBody)
788 && isset($parsedBody['eq'])
789 && is_string($parsedBody['eq'])
790 && $parsedBody['eq'] !== ''
792 $encryptedQuery = $parsedBody['eq'];
793 unset($parsedBody['eq'], $queryParams['eq']);
794 } elseif (isset($queryParams['eq']) && is_string($queryParams['eq']) && $queryParams['eq'] !== '') {
795 $encryptedQuery = $queryParams['eq'];
796 unset($queryParams['eq']);
799 $decryptedQuery = null;
800 if ($encryptedQuery !== '') {
801 $decryptedQuery = Url::decryptQuery($encryptedQuery);
804 if ($decryptedQuery === null) {
805 $request = $request->withQueryParams($queryParams);
806 if (is_array($parsedBody)) {
807 return $request->withParsedBody($parsedBody);
810 return $request;
813 $urlQueryParams = (array) json_decode($decryptedQuery);
814 foreach ($urlQueryParams as $urlQueryParamKey => $urlQueryParamValue) {
815 if (is_array($parsedBody)) {
816 $parsedBody[$urlQueryParamKey] = $urlQueryParamValue;
817 $_POST[$urlQueryParamKey] = $urlQueryParamValue;
818 } else {
819 $queryParams[$urlQueryParamKey] = $urlQueryParamValue;
820 $_GET[$urlQueryParamKey] = $urlQueryParamValue;
823 $_REQUEST[$urlQueryParamKey] = $urlQueryParamValue;
826 $request = $request->withQueryParams($queryParams);
827 if (is_array($parsedBody)) {
828 return $request->withParsedBody($parsedBody);
831 return $request;