413 lines
13 KiB
PHP
413 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace WebPExpress;
|
||
|
|
|
||
|
|
use \WebPExpress\PathHelper;
|
||
|
|
use \WebPExpress\Sanitize;
|
||
|
|
use \WebPExpress\SanityException;
|
||
|
|
|
||
|
|
class SanityCheck
|
||
|
|
{
|
||
|
|
|
||
|
|
private static function fail($errorMsg, $input)
|
||
|
|
{
|
||
|
|
// sanitize input before calling error_log(), it might be sent to file, mail, syslog etc.
|
||
|
|
//error_log($errorMsg . '. input:' . Sanitize::removeNUL($input) . 'backtrace: ' . print_r(debug_backtrace(), true));
|
||
|
|
error_log($errorMsg . '. input:' . Sanitize::removeNUL($input));
|
||
|
|
|
||
|
|
//error_log(get_magic_quotes_gpc() ? 'on' :'off');
|
||
|
|
throw new SanityException($errorMsg); // . '. Check debug.log for details (and make sure debugging is enabled)'
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
*
|
||
|
|
* @param string $input string to test for NUL char
|
||
|
|
*/
|
||
|
|
public static function mustBeString($input, $errorMsg = 'String expected')
|
||
|
|
{
|
||
|
|
if (gettype($input) !== 'string') {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The NUL character is a demon, because it can be used to bypass other tests
|
||
|
|
* See https://st-g.de/2011/04/doing-filename-checks-securely-in-PHP.
|
||
|
|
*
|
||
|
|
* @param string $input string to test for NUL char
|
||
|
|
*/
|
||
|
|
public static function noNUL($input, $errorMsg = 'NUL character is not allowed')
|
||
|
|
{
|
||
|
|
self::mustBeString($input);
|
||
|
|
if (strpos($input, chr(0)) !== false) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prevent control chararters (#00 - #20).
|
||
|
|
*
|
||
|
|
* This prevents line feed, new line, tab, charater return, tab, ets.
|
||
|
|
* https://www.rapidtables.com/code/text/ascii-table.html
|
||
|
|
*
|
||
|
|
* @param string $input string to test for control characters
|
||
|
|
*/
|
||
|
|
public static function noControlChars($input, $errorMsg = 'Control characters are not allowed')
|
||
|
|
{
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::noNUL($input);
|
||
|
|
if (preg_match('#[\x{0}-\x{1f}]#', $input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
*
|
||
|
|
* @param mixed $input something that may not be empty
|
||
|
|
*/
|
||
|
|
public static function notEmpty($input, $errorMsg = 'Must be non-empty')
|
||
|
|
{
|
||
|
|
if (empty($input)) {
|
||
|
|
self::fail($errorMsg, '');
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
public static function noDirectoryTraversal($input, $errorMsg = 'Directory traversal is not allowed')
|
||
|
|
{
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::noControlChars($input);
|
||
|
|
if (preg_match('#\.\.\/#', $input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function noStreamWrappers($input, $errorMsg = 'Stream wrappers are not allowed')
|
||
|
|
{
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::noControlChars($input);
|
||
|
|
|
||
|
|
// Prevent stream wrappers ("phar://", "php://" and the like)
|
||
|
|
// https://www.php.net/manual/en/wrappers.phar.php
|
||
|
|
if (preg_match('#^\\w+://#', Sanitize::removeNUL($input))) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function pathDirectoryTraversalAllowed($input)
|
||
|
|
{
|
||
|
|
self::notEmpty($input);
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::noControlChars($input);
|
||
|
|
self::noStreamWrappers($input);
|
||
|
|
|
||
|
|
// PS: The following sanitize has no effect, as we have just tested that there are no NUL and
|
||
|
|
// no stream wrappers. It is here to avoid false positives on coderisk.com
|
||
|
|
$input = Sanitize::path($input);
|
||
|
|
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function pathWithoutDirectoryTraversal($input)
|
||
|
|
{
|
||
|
|
self::pathDirectoryTraversalAllowed($input);
|
||
|
|
self::noDirectoryTraversal($input);
|
||
|
|
$input = Sanitize::path($input);
|
||
|
|
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function path($input)
|
||
|
|
{
|
||
|
|
return self::pathWithoutDirectoryTraversal($input);
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Beware: This does not take symlinks into account.
|
||
|
|
* I should make one that does. Until then, you should probably not call this method from outside this class
|
||
|
|
*/
|
||
|
|
private static function pathBeginsWith($input, $beginsWith, $errorMsg = 'Path is outside allowed path')
|
||
|
|
{
|
||
|
|
self::path($input);
|
||
|
|
if (!(strpos($input, $beginsWith) === 0)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function pathBeginsWithSymLinksExpanded($input, $beginsWith, $errorMsg = 'Path is outside allowed path') {
|
||
|
|
$closestExistingFolder = PathHelper::findClosestExistingFolderSymLinksExpanded($input);
|
||
|
|
self::pathBeginsWith($closestExistingFolder, $beginsWith, $errorMsg);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function absPathMicrosoftStyle($input, $errorMsg = 'Not an fully qualified Windows path')
|
||
|
|
{
|
||
|
|
// On microsoft we allow [drive letter]:\
|
||
|
|
if (!preg_match("#^[A-Z]:\\\\|/#", $input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function isOnMicrosoft()
|
||
|
|
{
|
||
|
|
if (isset($_SERVER['SERVER_SOFTWARE'])) {
|
||
|
|
if (strpos(strtolower($_SERVER['SERVER_SOFTWARE']), 'microsoft') !== false) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
switch (PHP_OS) {
|
||
|
|
case "WINNT":
|
||
|
|
case "WIN32":
|
||
|
|
case "INTERIX":
|
||
|
|
case "UWIN":
|
||
|
|
case "UWIN-W7":
|
||
|
|
return true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPath($input, $errorMsg = 'Not an absolute path')
|
||
|
|
{
|
||
|
|
// first make sure there are no nasty things like control chars, phar wrappers, etc.
|
||
|
|
// - and no directory traversal either.
|
||
|
|
self::path($input);
|
||
|
|
|
||
|
|
// For non-windows, we require that an absolute path begins with "/"
|
||
|
|
// On windows, we also accept that a path starts with a drive letter, ie "C:\"
|
||
|
|
if ((strpos($input, '/') !== 0)) {
|
||
|
|
if (self::isOnMicrosoft()) {
|
||
|
|
self::absPathMicrosoftStyle($input);
|
||
|
|
} else {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
public static function absPathInOneOfTheseRoots()
|
||
|
|
{
|
||
|
|
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Look if filepath is within a dir path.
|
||
|
|
* Also tries expanding symlinks
|
||
|
|
*
|
||
|
|
* @param string $filePath Path to file. It may be non-existing.
|
||
|
|
* @param string $dirPath Path to dir. It must exist in order for symlinks to be expanded.
|
||
|
|
*/
|
||
|
|
private static function isFilePathWithinExistingDirPath($filePath, $dirPath)
|
||
|
|
{
|
||
|
|
// sanity-check input. It must be a valid absolute filepath. It is allowed to be non-existing
|
||
|
|
self::absPath($filePath);
|
||
|
|
|
||
|
|
// sanity-check dir and that it exists.
|
||
|
|
self::absPathExistsAndIsDir($dirPath);
|
||
|
|
|
||
|
|
return PathHelper::isFilePathWithinDirPath($filePath, $dirPath);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Look if filepath is within multiple dir paths.
|
||
|
|
* Also tries expanding symlinks
|
||
|
|
*
|
||
|
|
* @param string $input Path to file. It may be non-existing.
|
||
|
|
* @param array $roots Allowed root dirs. Note that they must exist in order for symlinks to be expanded.
|
||
|
|
*/
|
||
|
|
public static function filePathWithinOneOfTheseRoots($input, $roots, $errorMsg = 'The path is outside allowed roots.')
|
||
|
|
{
|
||
|
|
self::absPath($input);
|
||
|
|
|
||
|
|
foreach ($roots as $root) {
|
||
|
|
if (self::isFilePathWithinExistingDirPath($input, $root)) {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
public static function sourcePath($input, $errorMsg = 'The source path is outside allowed roots. It is only allowed to convert images that resides in: home dir, content path, upload dir and plugin dir.')
|
||
|
|
{
|
||
|
|
$validPaths = [
|
||
|
|
Paths::getHomeDirAbs(),
|
||
|
|
Paths::getIndexDirAbs(),
|
||
|
|
Paths::getContentDirAbs(),
|
||
|
|
Paths::getUploadDirAbs(),
|
||
|
|
Paths::getPluginDirAbs()
|
||
|
|
];
|
||
|
|
return self::filePathWithinOneOfTheseRoots($input, $validPaths, $errorMsg);
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function destinationPath($input, $errorMsg = 'The destination path is outside allowed roots. The webps may only be stored in the upload folder and in the folder that WebP Express stores converted images in')
|
||
|
|
{
|
||
|
|
self::absPath($input);
|
||
|
|
|
||
|
|
// Webp Express only store converted images in upload folder and in its "webp-images" folder
|
||
|
|
// Check that destination path is within one of these.
|
||
|
|
$validPaths = [
|
||
|
|
'/var/www/webp-express-tests/we1'
|
||
|
|
//Paths::getUploadDirAbs(),
|
||
|
|
//Paths::getWebPExpressContentDirRel() . '/webp-images'
|
||
|
|
];
|
||
|
|
return self::filePathWithinOneOfTheseRoots($input, $validPaths, $errorMsg);
|
||
|
|
}*/
|
||
|
|
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test that path is an absolute path and it is in document root.
|
||
|
|
*
|
||
|
|
* If DOCUMENT_ROOT is not available, then only the absPath check will be done.
|
||
|
|
*
|
||
|
|
* TODO: Instead of this method, we shoud check
|
||
|
|
*
|
||
|
|
*
|
||
|
|
* It is acceptable if the absolute path does not exist
|
||
|
|
*/
|
||
|
|
public static function absPathIsInDocRoot($input, $errorMsg = 'Path is outside document root')
|
||
|
|
{
|
||
|
|
self::absPath($input);
|
||
|
|
|
||
|
|
if (!isset($_SERVER["DOCUMENT_ROOT"])) {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
if ($_SERVER["DOCUMENT_ROOT"] == '') {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
$docRoot = self::absPath($_SERVER["DOCUMENT_ROOT"]);
|
||
|
|
$docRoot = rtrim($docRoot, '/');
|
||
|
|
|
||
|
|
try {
|
||
|
|
$docRoot = self::absPathExistsAndIsDir($docRoot);
|
||
|
|
} catch (SanityException $e) {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use realpath to expand symbolic links and check if it exists
|
||
|
|
$docRootSymLinksExpanded = @realpath($docRoot);
|
||
|
|
if ($docRootSymLinksExpanded === false) {
|
||
|
|
// probably outside open basedir restriction.
|
||
|
|
//$errorMsg = 'Cannot resolve document root';
|
||
|
|
//self::fail($errorMsg, $input);
|
||
|
|
|
||
|
|
// Cannot resolve document root, so cannot test if in document root
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
// See if $filePath begins with the realpath of the $docRoot + '/'. If it does, we are done and OK!
|
||
|
|
// (pull #429)
|
||
|
|
if (strpos($input, $docRootSymLinksExpanded . '/') === 0) {
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
$docRootSymLinksExpanded = rtrim($docRootSymLinksExpanded, '\\/');
|
||
|
|
$docRootSymLinksExpanded = self::absPathExists($docRootSymLinksExpanded, 'Document root does not exist!');
|
||
|
|
$docRootSymLinksExpanded = self::absPathExistsAndIsDir($docRootSymLinksExpanded, 'Document root is not a directory!');
|
||
|
|
|
||
|
|
$directorySeparator = self::isOnMicrosoft() ? '\\' : '/';
|
||
|
|
$errorMsg = 'Path is outside resolved document root (' . $docRootSymLinksExpanded . ')';
|
||
|
|
self::pathBeginsWithSymLinksExpanded($input, $docRootSymLinksExpanded . $directorySeparator, $errorMsg);
|
||
|
|
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPathExists($input, $errorMsg = 'Path does not exist or it is outside restricted basedir')
|
||
|
|
{
|
||
|
|
self::absPath($input);
|
||
|
|
if (@!file_exists($input)) {
|
||
|
|
// TODO: We might be able to detect if the problem is that the path does not exist or if the problem
|
||
|
|
// is that it is outside restricted basedir.
|
||
|
|
// ie by creating an error handler or inspecting the php ini "open_basedir" setting
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPathExistsAndIsDir(
|
||
|
|
$input,
|
||
|
|
$errorMsg = 'Path points to a file (it should point to a directory)'
|
||
|
|
) {
|
||
|
|
self::absPathExists($input, 'Directory does not exist or is outside restricted basedir');
|
||
|
|
if (!is_dir($input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPathExistsAndIsFile(
|
||
|
|
$input,
|
||
|
|
$errorMsg = 'Path points to a directory (it should not do that)'
|
||
|
|
) {
|
||
|
|
self::absPathExists($input, 'File does not exist or is outside restricted basedir');
|
||
|
|
if (@is_dir($input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPathExistsAndIsFileInDocRoot($input)
|
||
|
|
{
|
||
|
|
self::absPathExistsAndIsFile($input);
|
||
|
|
self::absPathIsInDocRoot($input);
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function absPathExistsAndIsNotDir(
|
||
|
|
$input,
|
||
|
|
$errorMsg = 'Path points to a directory (it should point to a file)'
|
||
|
|
) {
|
||
|
|
self::absPathExistsAndIsFile($input, $errorMsg);
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
public static function pregMatch($pattern, $input, $errorMsg = 'Does not match expected pattern')
|
||
|
|
{
|
||
|
|
self::noNUL($input);
|
||
|
|
self::mustBeString($input);
|
||
|
|
if (!preg_match($pattern, $input)) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function isJSONArray($input, $errorMsg = 'Not a JSON array')
|
||
|
|
{
|
||
|
|
self::noNUL($input);
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::notEmpty($input);
|
||
|
|
if ((strpos($input, '[') !== 0) || (!is_array(json_decode($input)))) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function isJSONObject($input, $errorMsg = 'Not a JSON object')
|
||
|
|
{
|
||
|
|
self::noNUL($input);
|
||
|
|
self::mustBeString($input);
|
||
|
|
self::notEmpty($input);
|
||
|
|
if ((strpos($input, '{') !== 0) || (!is_object(json_decode($input)))) {
|
||
|
|
self::fail($errorMsg, $input);
|
||
|
|
}
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|