Sicurezza e Validazione in PHP: prevenire gli attacchi

Postato in Programmazione PHP
Attenzione! Questo contenuto è vecchioQuesto articolo risale al 2009, quindi i contenuti e le operazioni qui consigliate potrebbero essere diventate obsolete nel corso del tempo.

Il problema della sicurezza è cruciale quando si sviluppa una applicazione in PHP. Se non vengono prese determinate precauzioni, un semplice script php come questo che sto per mostrarvi (una risposta xml, magari chiamata tramite ajax) può trasformarsi in un pericoloso buco per il vostro sito, e rendere possibile più o meno tutto a chi sta tentando di "lavorare" sopra lo script:

<?php
    // Connessione al db e roba varia
    $sql = "SELECT * FROM tabella WHERE id='" . $_GET['id_passato_da_get'] . "'";
    $rs = mysql_query($sql);
    $rw = mysql_fetch_assoc($rs);
    header('Content-Type:text/xml');
    echo '<?xml version="1.0" encoding="ISO-8859-1"?><root><dati>' . $rw['prova'] .'</dati></root>';
    exit();
?>

I problemi? Pessima programmazione: nessuna validazione dei dati, nessun controllo sul funzionamento della query, e in generale mancanza di occhio critico riguardo la sicurezza di uno script simile. Sarebbe aperto ad ogni tipologia di attacco.

Allora, come ovviamo a questo problema? In primis, programmando meglio. In secundis, facendoci aiutare da qualche classe che pulirà un pò i dati che vengono passati tramite GET e POST e che controlli un pò le impostazioni di sicurezza dell'environment (vedere register_globals per farsi un'idea)

Classe "Security PHP Class"

<?php
/**
* Provides static functions to help protect against cross site scripting
* attacks and helps clean up the php environment upon initializing.
*
* Based upon Security library by http://kohanaphp.com/
*
* @license http://opensource.org/licenses/gpl-license.php GNU Public License
*/

class Security
{
    // Instance of the security class.
    protected static $instance;
    protected $magic_quotes_gpc = FALSE;
    
    /**
     * Gets the instance of the Security class.
     *
     * @return object Instance of Security
     */
    public static function instance()
    {
        if(self::$instance === NULL)
        {
            return new Security;
        }
        
        return self::$instance;
    }
    
    
    /**
     * Constructor. Sanitizes global data GET, POST and COOKIE data.
     * Also makes sure those pesty magic quotes and register globals
     * don't bother us. This is protected because it really only needs
     * to be run once.
     *
     * @return void
     */
    protected function __construct()
    {
        if(self::$instance === NULL)
        {
            // Check for magic quotes
            if(get_magic_quotes_runtime())
            {
                // Dear lord!! This is bad and deprected. Sort it out ;)
                set_magic_quotes_runtime(0);
            }
            
            if(get_magic_quotes_gpc())
            {
                // This is also bad and deprected. See http://php.net/magic_quotes for more information.
                $this->magic_quotes_gpc = TRUE;
            }
            
            // Check for register globals and prevent security issues from arising.
            if(ini_get('register_globals'))
            {
                if(isset($_REQUEST['GLOBALS']))
                {
                    // No no no.. just kill the script here and now
                    exit('Illegal attack on global variable.');
                }
                
                // Get rid of REQUEST
                $_REQUEST = array();
                
                // The following globals are standard and shouldn't really be removed
                $preserve = array('GLOBALS', '_REQUEST', '_GET', '_POST', '_FILES', '_COOKIE', '_SERVER', '_ENV', '_SESSION');
                
                // Same effect as disabling register_globals
                foreach($GLOBALS as $key => $value)
                {
                    if( ! in_array($key, $preserve))
                    {
                        global $$key;
                        $$key = NULL;
                        
                        unset($GLOBALS[$key], $$key);
                    }
                }
            }
            
            // Sanitize global data
            
            if(is_array($_POST))
            {
                foreach($_POST as $key => $value)
                {
                    $_POST[$this->clean_input_keys($key)] = $this->clean_input_data($value);
                }
            }
            else
            {
                $_POST = array();
            }
            
            if(is_array($_GET))
            {
                foreach($_GET as $key => $value)
                {
                    $_GET[$this->clean_input_keys($key)] = $this->clean_input_data($value);
                }
            }
            else
            {
                $_GET = array();
            }
            
            if(is_array($_COOKIE))
            {
                foreach($_COOKIE as $key => $value)
                {
                    $_COOKIE[$this->clean_input_keys($key)] = $this->clean_input_data($value);
                }
            }
            else
            {
                $_COOKIE = array();
            }
            
            // Just make REQUEST a merge of POST and GET. Who really wants cookies in it anyway?
            $_REQUEST = array_merge($_GET, $_POST);
            
            self::$instance = $this;
        }
    }
    
    /**
     * Cross site filtering (XSS). Recursive.
     *
     * @param  string Data to be cleaned
     * @return mixed
     */
    public function xss_clean($data)
    {
        // If its empty there is no point cleaning it :\
        if(empty($data))
            return $data;
            
        // Recursive loop for arrays
        if(is_array($data))
        {
            foreach($data as $key => $value)
            {
                $data[$key] = $this->xss_clean($data);
            }
            
            return $data;
        }
        
        // http://svn.bitflux.ch/repos/public/popoon/trunk/classes/externalinput.php
        // +----------------------------------------------------------------------+
        // | Copyright (c) 2001-2006 Bitflux GmbH                                 |
        // +----------------------------------------------------------------------+
        // | Licensed under the Apache License, Version 2.0 (the "License");      |
        // | you may not use this file except in compliance with the License.     |
        // | You may obtain a copy of the License at                              |
        // | http://www.apache.org/licenses/LICENSE-2.0                           |
        // | Unless required by applicable law or agreed to in writing, software  |
        // | distributed under the License is distributed on an "AS IS" BASIS,    |
        // | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or      |
        // | implied. See the License for the specific language governing         |
        // | permissions and limitations under the License.                       |
        // +----------------------------------------------------------------------+
        // | Author: Christian Stocker <chregu@bitflux.ch>                        |
        // +----------------------------------------------------------------------+
        
        // Fix &entity\n;
        $data = str_replace(array('&','<','>'), array('&amp;','&lt;','&gt;'), $data);
        $data = preg_replace('/(&#*\w+)[\x00-\x20]+;/u', '$1;', $data);
        $data = preg_replace('/(&#x*[0-9A-F]+);*/iu', '$1;', $data);
        $data = html_entity_decode($data, ENT_COMPAT, 'UTF-8');

        // Remove any attribute starting with "on" or xmlns
        $data = preg_replace('#(<[^>]+?[\x00-\x20"\'])(?:on|xmlns)[^>]*+>#iu', '$1>', $data);

        // Remove javascript: and vbscript: protocols
        $data = preg_replace('#([a-z]*)[\x00-\x20]*=[\x00-\x20]*([`\'"]*)[\x00-\x20]*j[\x00-\x20]*a[\x00-\x20]*v[\x00-\x20]*a[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#iu', '$1=$2nojavascript...', $data);
        $data = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*v[\x00-\x20]*b[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#iu', '$1=$2novbscript...', $data);
        $data = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*-moz-binding[\x00-\x20]*:#u', '$1=$2nomozbinding...', $data);

        // Only works in IE: <span style="width: expression(alert('Ping!'));"></span>
        $data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?expression[\x00-\x20]*\([^>]*+>#i', '$1>', $data);
        $data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?behaviour[\x00-\x20]*\([^>]*+>#i', '$1>', $data);
        $data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:*[^>]*+>#iu', '$1>', $data);

        // Remove namespaced elements (we do not need them)
        $data = preg_replace('#</*\w+:\w[^>]*+>#i', '', $data);

        do
        {
            // Remove really unwanted tags
            $old_data = $data;
            $data = preg_replace('#</*(?:applet|b(?:ase|gsound|link)|embed|frame(?:set)  ?|i(?:frame|layer)|l(?:ayer|ink)|meta|object|s(?:c  ript|tyle)|title|xml)[^>]*+>#i', '', $data);
        }
        while ($old_data !== $data);
        
        return $data;
    }
    
    /**
     * Enforces W3C specifications to prevent malicious exploitation.
     *
     * @param  string Key to clean
     * @return string
     */
    protected function clean_input_keys($data)
    {
        $chars = PCRE_UNICODE_PROPERTIES ? '\pL' : 'a-zA-Z';
        
        if ( ! preg_match('#^[' . $chars . '0-9:_.-]++$#uD', $data))
        {
            exit('Illegal key characters in global data');
        }
        
        return $data;
    }
    
    /**
     * Escapes data.
     *
     * @param  mixed Data to clean
     * @return mixed
     */
    protected function clean_input_data($data)
    {
        if(is_array($data))
        {
            $new_array = array();
            foreach($data as $key => $value)
            {
                $new_array[$this->clean_input_keys($key)] = $this->clean_input_data($value);
            }
            
            return $new_array;
        }
        
        if($this->magic_quotes_gpc === TRUE)
        {
            // Get rid of those pesky magic quotes!
            $data = stripslashes($data);
        }
        
        $data = $this->xss_clean($data);
        
        return $data;
    }
}
?>

Classe vi validazione Server-Side "PHP Validation Class"

<?php
define("vSTRING", 1);		// any string that doesn't have control characters (ASCII 0 - 31) but spaces are allowed
define("vALPHA", 2);		// only letters a-z and A-Z
define("vDIGIT", 3);		// only numbers 0-9
define("vALNUM", 4);		// letters and numbers
define("vINTEGER", 5);		// only numbers 0-9 and an optional - (minus) sign (in the beginning only)
define("vFILENAME", 6);		// a valid file name (including dots but no slashes and other forbidden characters)
define("vBOOL", 7);			// a boolean (TRUE is either a case-insensitive "true" or "1". Everything else is FALSE)
define("vVARIABLE", 8);		// a valid variable name (letters, digits, underscore)
define("vPASSWORD", 9);		// a valid password (alphanumberic + some other characters but no spaces: ASCII 33 - 126)
define("vURL", 10);			// a valid URL (http connection is used to check if url exists!)
define("vEMAIL", 11);		// a valid email address (only checks for valid format: xxx@xxx.xxx)
define("vTEXT", 12);		// *NEW* like vSTRING, but newline characters are allowed

class Validator
{
	var $placeHolder;
	
	function Validator()
	{
		$this -> placeHolder = 0;
	}

	function get_valid($param, $type, $def)
	{
		$value = $param;
		if(!isset($value)) return $def;
		// strip slashes if they've been added by magic_quotes_gpc
		if(get_magic_quotes_gpc())
			$value = stripslashes($value);
		if($value === "") return $def;

		$is_valid = FALSE;
		switch($type)
		{
			case vSTRING:
				if(preg_match("/^[^\x-\x1F]+$/", $value)) $is_valid = TRUE;
				break;
			case vALPHA:
				if(preg_match("/^[a-z]+$/i", $value)) $is_valid = TRUE;
				break;
			case vDIGIT:
				if(preg_match("/^[0-9]+$/", $value))
				{
					$is_valid = TRUE;
					settype($value,"integer");
				}
				break;
			case vALNUM:
				if(preg_match("/^[a-z0-9]+$/i", $value)) $is_valid = TRUE;
				break;
			case vINTEGER:
				if(preg_match("/^-?[0-9]+$/", $value))
				{
					$is_valid = TRUE;
					settype($value, "integer");
				}
				break;
			case vFILENAME:
				if(preg_match("{^[^\\/\*\?\:\,]+$}", $value)) $is_valid = TRUE;
				break;
			case vBOOL:
				if(preg_match("/^true$|^1$/i", $value))
				{
					$is_valid = TRUE;
					$value = TRUE;
				}
				break;
			case vVARIABLE:
				if(preg_match("/^[a-zA-Z_][a-zA-Z0-9_]*$/i", $value)) $is_valid = TRUE;
				if($value == "_") $is_valid = FALSE;
				break;
			case vPASSWORD:
				if(preg_match("/^[\41-\176]+$/", $value)) $is_valid = TRUE;
				break;
			case vURL:
				$href = $value;
				if(!preg_match("/^[a-z]+:/i", $href))
					$href = "http://".$href;
				if(preg_match("/^http:\/\//", $href))
					if($fp = @fopen($href, "r"))
					{
						$is_valid = TRUE;
						$value = $href;
						@fclose($fp);
					}
				break;
			case vEMAIL:
				if(preg_match("/^[a-z0-9_\.-]+@([a-z0-9]+([\-]+[a-z0-9]+)*\.)+[a-z]{2,7}$/i", $value)) $is_valid = TRUE;
				break;
			case vTEXT:
				if(preg_match("/^([^\x-\x1F]|[\r\n])+$/", $value)) $is_valid = TRUE;
				break;
		}
		
		if(!$is_valid) return false;
		else return true;
	}
}
?>

Non mi dilungherò molto nella spiegazione dell'utilizzo delle due classi, prendiamo sempre come esempio lo script errato che c'era ad inizio post e correggiamolo assieme, integrando queste due classi, e facendo un pò più attenzione :)

<?php
    // Connessione al db e inclusioni delle classi (sicurezza e validazione)
    include 'security.class.php';
    include 'validator.class.php';

    // Faccio partire i primi controlli (veloci, senza error handling)
    $security = Security::instance();
    $__GET = $security -> xss_clean($_GET); // -> assegnato nuovo nome a $_GET
    $v = new Validator();
    if ( !($v -> get_valid($__GET['id_passato_da_get'], vDIGIT, "")) ) exit();
    
    $sql = "SELECT * FROM tabella WHERE id='" . $__GET['id_passato_da_get'] . "'";
    $rs = mysql_query($sql);
    if ( $rs && mysql_num_rows($rs) == 1 ) {
        $rw = mysql_fetch_assoc($rs);
        header('Content-Type:text/xml');
        echo '<?xml version="1.0" encoding="ISO-8859-1"?><root><dati>' . $rw['prova'] .'</dati></root>';
    }
    exit();
?>

In questo esempio abbiamo istanziato la classe di sicurezza, abbiamo pulito la variabile superglobale $_GET per prevenire XSS, e abbiamo fatto una validazione in modo che l'unico parametro accettato sia un numero. Tutto il resto infatti può essere considerato un tentativo per bucare lo script :)