<?php
/**
 * EGroupware XSS protection and other security relevant functions
 *
 * Usually loaded via header.inc.php or api/src/loader/common.php
 *
 * @link http://www.egroupware.org
 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
 * @package api
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @version $Id$
 */

use EGroupware\Api;

/**
 * check $_REQUEST data for XSS, vars containing script tags are moved to $GLOBALS['egw_unset_vars']
 *
 * @internal
 * @param array &$var reference of array to check
 * @param string $name ='' name of the array
 */
function _check_script_tag(&$var,$name='')
{
	static $preg=null;
	//old: '/<\/?[^>]*\b(iframe|script|javascript|on(before)?(abort|blur|change|click|dblclick|error|focus|keydown|keypress|keyup|load|mousedown|mousemove|mouseout|mouseover|mouseup|reset|select|submit|unload))\b[^>]*>/i';
	if (!isset($preg)) $preg =
		// forbidden tags like iframe or script
		'/(<(\s*\/)?\s*(iframe|script|object|embed|math|meta)[^a-z0-9]|'.
		// on* attributes
		'<[^>]*on(before)?(abort|blur|change|click|dblclick|error|focus|keydown|keypress|keyup|load|mouse[^=]+|reset|select|submit|unload|resize|propertychange|page[^=]*|scroll|readystatechange|start|popstate|form[^=]+|input)\s*=|'.
		// ="javascript:*" diverse javascript attribute value
		'<[^>]+(href|src|dynsrc|lowsrc|background|style|poster|action)\s*=\s*("|\')?[^"\']*javascript|'.
		// benavior:url and expression in style attribute
		'<[^>]+style\s*=\s*("|\')[^>]*(behavior\s*:\s*url|expression)\s*\()/i';
	if (is_array($var))
	{
		foreach($var as $key => $val)
		{
			if (is_array($val))
			{
				_check_script_tag($var[$key],$name.'['.$key.']');
			}
			elseif(strpos($val, '<') !== false)	// speedup: ignore everything without <
			{
				if (preg_match($preg,$val))
				{
					// special handling for $_POST[json_data], to decend into it's decoded content, fixing json direct might break json syntax
					if ($name == '_POST' && $key == 'json_data' && ($json_data = json_decode($val, true)))
					{
						_check_script_tag($json_data, $name.'[json_data]');
						$_REQUEST[$key] = $var[$key] = json_encode($json_data);
						continue;
					}
					error_log(__FUNCTION__."(,$name) ${name}[$key] = ".$var[$key]);
					$GLOBALS['egw_unset_vars'][$name.'['.$key.']'] = $var[$key];
					// attempt to clean the thing
					$var[$key] = $val = Api\Html\HtmLawed::purify($val);
					// check if we succeeded, if not drop the var anyway, keep the egw_unset_var in any case
					if (preg_match($preg,$val))
					{
						error_log("*** _check_script_tag($name): unset(${name}[$key]) with value $val***");
						unset($var[$key]);
					}
				}
			}
		}
		// in case some stupid old code expects the array-pointer to be at the start of the array
		reset($var);
	}
}

/* some _check_script_tag tests, should be commented out by default
if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__)	// some tests
{
	if (!defined('EGW_INCLUDE_ROOT'))
	{
		define(EGW_INCLUDE_ROOT, realpath(dirname(__FILE__).'/../..'));
		define(EGW_API_INC, realpath(dirname(__FILE__)));
	}

	$total = $num_failed = 0;
	$patterns = array(
		// pattern => true: should fail, false: should not fail
		'< script >alert(1)< / script >' => true,
		'<span onMouseOver ="alert(1)">blah</span>' => true,
		'<a href=          "JaVascript: alert(1)">Click Me</a>' => true,
		// from https://www.acunetix.com/websitesecurity/cross-site-scripting/
		'<body onload=alert("XSS")>' => true,
		'<body background="javascript:alert("XSS")">' => true,
		'<iframe src=”http://evil.com/xss.html”>' => true,
		'<input type="image" src="javascript:alert(\'XSS\');">' => true,
		'<link rel="stylesheet" href="javascript:alert(\'XSS\');">' => true,
		'<table background="javascript:alert(\'XSS\')">' => true,
		'<td background="javascript:alert(\'XSS\')">' => true,
		'<div style="background-image: url(javascript:alert(\'XSS\'))">' => true,
		'<div style="width: expression(alert(\'XSS\'));">' => true,
		'<object type="text/x-scriptlet" data="http://hacker.com/xss.html">' => true,
		// false positiv tests
		'If 1 < 2, what does that mean for description, if 2 > 1.' => false,
		'If 1 < 2, what does that mean for a script, if 2 > 1.' => false,
		'<div>Script and Javascript: not evil ;-)' => false,
		'<span>style=background-color' => false,
		'<font face="Script MT Bold" size="4"><span style="font-size:16pt;">Hugo Sonstwas</span></font>' => false,
		'<mathias@stylite.de>' => false,
	);
	foreach($patterns as $pattern => $should_fail)
	{
		$test = array($pattern);
		unset($GLOBALS['egw_unset_vars']);
		_check_script_tag($test,'test');
		$failed = isset($GLOBALS['egw_unset_vars']) !== $should_fail;
		++$total;
		if ($failed) $num_failed++;
		echo "<p style='color: ".($failed?'red':'black')."'> ".Api\Html::htmlspecialchars($pattern).' '.
			(isset($GLOBALS['egw_unset_vars'])?'removed':'passed')."</p>";
	}
	$x = 1;
	// urls with attack vectors
	$urls = array(
		// we currently fail 76 of 666 test, thought they seem not to apply to our use case, as we check request data
		'https://gist.github.com/JohannesHoppe/5612274' => file(
			'https://gist.githubusercontent.com/JohannesHoppe/5612274/raw/60016bccbfe894dcd61a6be658a4469e403527de/666_lines_of_XSS_vectors.html'),
		// we currently fail 44 of 140 tests, thought they seem not to apply to our use case, as we check request data
		'https://html5sec.org/' => call_user_func(function() {
			$payloads = $items = null;
			if (!($items_js = file_get_contents('https://html5sec.org/items.js')) ||
				!preg_match_all("|^\s+'data'\s+:\s+'(.*)',$|m", $items_js, $items, PREG_PATTERN_ORDER) ||
				!($payload_js = file_get_contents('https://html5sec.org/payloads.js')) ||
				!preg_match_all("|^\s+'([^']+)'\s+:\s+'(.*)',$|m", $payload_js, $payloads, PREG_PATTERN_ORDER))
			{
				return false;
			}
			$replace = array(
				"\\'" => "'",
				'\\\\'=> '\\,',
				'\r'  => "\r",
				'\n'  => "\n",
			);
			foreach($payloads[1] as $n => $from) {
				$replace['%'.$from.'%'] = $payloads[2][$n];
			}
			return array_map(function($item) use ($replace) {
				return strtr($item, $replace);
			}, $items[1]);
		}),
	);
	foreach($urls as $url => $vectors)
	{
		// no all xss attack vectors from http://ha.ckers.org/xssAttacks.xml are relevant here! (needs interpretation)
		if (!$vectors)
		{
			echo "<p style='color:red'>Could NOT download or parse $url with attack vectors!</p>\n";
			continue;
		}
		echo "<p><b>Attacks from <a href='$url' target='_blank'>$url</a> with ".count($vectors)." tests:</b></p>";
		foreach($vectors as $line => $pattern)
		{
			$test = array($pattern);
			unset($GLOBALS['egw_unset_vars']);
			_check_script_tag($test, 'line '.(1+$line));
			$failed = !isset($GLOBALS['egw_unset_vars']);
			++$total;
			if ($failed) $num_failed++;
			echo "<p style='color: ".($failed?'red':'black')."'>".(1+$line).": ".Api\Html::htmlspecialchars($pattern).' '.
				(isset($GLOBALS['egw_unset_vars'])?'removed':'passed')."</p>";
		}
	}
	die("<p style='color: ".($num_failed?'red':'black')."'>Tests finished: $num_failed / $total failed</p>");
}*/

foreach(array('_GET','_POST','_REQUEST','HTTP_GET_VARS','HTTP_POST_VARS') as $n => $where)
{
	$pregs = array(
		'order' => '/^[a-zA-Z0-9_,]*$/',
		'sort'  => '/^(ASC|DESC|asc|desc|0|1|2|3|4|5|6|7){0,1}$/',
	);
	foreach(array('order','sort') as $name)
	{
		if (isset($GLOBALS[$where][$name]) && !is_array($GLOBALS[$where][$name]) && !preg_match($pregs[$name],$GLOBALS[$where][$name]))
		{
			$GLOBALS[$where][$name] = '';
		}
	}
	// do the check for script-tags only for _GET and _POST or if we found something in _GET and _POST
	// speeds up the execusion a bit
	if (isset($GLOBALS[$where]) && is_array($GLOBALS[$where]) && ($n < 2 || isset($GLOBALS['egw_unset_vars'])))
	{
		_check_script_tag($GLOBALS[$where],$where);
	}
}
//if (is_array($GLOBALS['egw_unset_vars'])) { echo "egw_unset_vars=<pre>".htmlspecialchars(print_r($GLOBALS['egw_unset_vars'],true))."</pre>"; exit; }

// $GLOBALS[egw_info][flags][currentapp] and die  if it contains something nasty or unexpected
if (isset($GLOBALS['egw_info']) && isset($GLOBALS['egw_info']['flags']) &&
	isset($GLOBALS['egw_info']['flags']['currentapp']) && !preg_match('/^[A-Za-z0-9_-]+$/',$GLOBALS['egw_info']['flags']['currentapp']))
{
	error_log(__FILE__.': '.__LINE__.' Invalid $GLOBALS[egw_info][flags][currentapp]='.array2string($GLOBALS['egw_info']['flags']['currentapp']).', $_SERVER[REQUEST_URI]='.array2string($_SERVER[REQUEST_URI]));
	die('Invalid $GLOBALS[egw_info][flags][currentapp]!');
}

// neutralises register_globals On, which is not used by eGW
// some code from the hardend php project: http://www.hardened-php.net/articles/PHPUG-PHP-Sicherheit-Parametermanipulationen.pdf
if (ini_get('register_globals'))
{
	function unregister_globals()
	{
		// protect against GLOBALS overwrite or setting egw_info
		if (isset($_REQUEST['GLOBALS']) || isset($_FILES['GLOBALS']) || isset($_REQUEST['egw_info']) || isset($_FILES['egw_info']))
		{
			die('GLOBALS overwrite detected!!!');
		}
		// unregister all globals
		$noUnset = array('GLOBALS','_GET','_POST','_COOKIE','_SERVER','_ENV','_FILES','xajax');
		foreach(array_unique(array_merge(
			array_keys($_GET),array_keys($_POST),array_keys($_COOKIE),array_keys($_SERVER),array_keys($_ENV),array_keys($_FILES),
			isset($_SESSION) && is_array($_SESSION) ? array_keys($_SESSION) : array())) as $k)
		{
			if (!in_array($k,$noUnset) && isset($GLOBALS[$k]))
			{
				unset($GLOBALS[$k]);
			}
		}
	}
	unregister_globals();
}

/**
 * Unserialize a php serialized string, but only if it contains NO objects 'O:\d:"' or 'C:\d:"' pattern
 *
 * Should be used for all external content, to guard against exploidts.
 *
 * PHP 7.0+ can be told not to instanciate any classes (and calling eg. it's destructor).
 * In fact it instanciates it as __PHP_Incomplete_Class without any methods and therefore disarming threads.
 *
 * @param string $str
 * @return mixed
 */
function php_safe_unserialize($str)
{
	if (PHP_VERSION >= 7)
	{
		return unserialize($str, array('allowed_classes' => false));
	}
	if ((strpos($str, 'O:') !== false || strpos($str, 'C:') !== false) &&
		preg_match('/(^|;|{)[OC]:\d+:"/', $str))
	{
		error_log(__METHOD__."('$str') contains objects --> return NULL");
		return null;	// null, not false, to not trigger behavior of returning string itself to app code
	}
	return unserialize($str);
}

/* some test for object safe unserialisation
if (isset($_SERVER['SCRIPT_FILENAME']) && $_SERVER['SCRIPT_FILENAME'] == __FILE__)	// some tests
{
	if (php_sapi_name() !== 'cli') echo "<pre>\n";
	foreach(array(
		// things unsafe to unserialize
		"O:34:\"Horde_Kolab_Server_Decorator_Clean\":2:{s:43:\"\x00Horde_Kolab_Server_Decorator_Clean\x00_server\";" => false,
		"O:20:\"Horde_Prefs_Identity\":2:{s:9:\"\x00*\x00_prefs\";O:11:\"Horde_Prefs\":2:{s:8:\"\x00*\x00_opts\";a:1:{s:12:\"sizecallback\";" => false,
		"a:2:{i:0;O:12:\"Horde_Config\":1:{s:13:\"\x00*\x00_oldConfig\";s:#{php_injection.length}:\"#{php_injection}\";}i:1;s:13:\"readXMLConfig\";}}" => false,
		'a:6:{i:0;i:0;i:1;d:2;i:2;s:4:"ABCD";i:3;r:3;i:4;O:8:"my_Class":2:{s:1:"a";r:6;s:1:"b";N;};i:5;C:16:"SplObjectStorage":14:{x:i:0;m:a:0:{}}' => false,
		serialize(new stdClass()) => false,
		serialize(array(new stdClass(), new SplObjectStorage())) => false,
		// string content, safe to unserialize
		serialize('O:8:"stdClass"') => true,
		serialize('C:16:"SplObjectStorage"') => true,
		serialize(array('a' => 'O:8:"stdClass"', 'b' => 'C:16:"SplObjectStorage"')) => true,
		// false positive: failing our php<7 regular expression, because it has correct delimiter (^|;|{) in front of pattern :-(
		serialize('O:8:"stdClass";C:16:"SplObjectStorage"') => true,
	) as $str => $result)
	{
		if ((bool)($r=php_safe_unserialize($str)) !== $result)
		{
			if (!$result)
			{
				if (PHP_VERSION >= 7)
				{
					if (preg_match_all('/([^ ]+) Object\(/', array2string($r), $matches))
					{
						foreach($matches[1] as $class)
						{
							if (!preg_match('/^__PHP_Incomplete_Class(#\d+)?$/', $class))
							{
								echo "FAILED: $str\n";
								continue 2;
							}
						}
					}
					echo "passed: ".array2string($str)." = ".array2string($r)."\n";
				}
				else
				{
					echo "FAILED: $str\n";
				}
			}
			else
			{
				echo "false positive: $str\n";
			}
		}
		else
		{
			echo "passed: $str\n";
		}
		//echo "result=".array2string($result).", php_save_unserialize('".htmlspecialchars($str)."') = ".array2string(php_safe_unserialize($str))." --> ".array2string((bool)php_safe_unserialize($str))."\n";
	}
}*/

/**
 * Unserialize a json or php serialized array
 *
 * Used to migrate from PHP serialized database values to json-encoded ones.
 *
 * @param string $str string with serialized array
 * @param boolean $allow_not_serialized =false true: return $str as is, if it is no serialized array
 * @return array|str|false false if content can not be unserialized (not null like json_decode!)
 */
function json_php_unserialize($str, $allow_not_serialized=false)
{
	if ((in_array($str[0], array('a', 'i', 's', 'b', 'O', 'C')) && $str[1] == ':' || $str === 'N;') &&
		($arr = php_safe_unserialize($str)) !== false || $str === 'b:0;')
	{
		return $arr;
	}
	if (!$allow_not_serialized || $str[0] == '[' || $str[0] == '{' || $str[0] == '"' || $str === 'null' || ($val = json_decode($str, true)) !== null)
	{
		// json_decode return null, if it cant decode the content
		if (isset($val) || ($val = json_decode($str, true)) !== null || $str === 'null')
		{
			return $val;
		}
		return false;
	}
	return $str;
}
