]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #623 from ArthurHoaro/security/reverse-proxy-ban
authorArthur <arthur@hoa.ro>
Wed, 12 Oct 2016 12:48:57 +0000 (14:48 +0200)
committerGitHub <noreply@github.com>
Wed, 12 Oct 2016 12:48:57 +0000 (14:48 +0200)
Add trusted IPs in config and try to ban forwarded IP on failed login

1  2 
application/HttpUtils.php
index.php

index 27a39d3df223be0f457e8f3966a5b1bd7db7aefd,354d261c4a43b7f0b0b55e5a1acf703b038fbf77..e705cfd6030cb0da7ff5e90bde930433bcecbbe0
@@@ -1,7 -1,6 +1,7 @@@
  <?php
  /**
   * GET an HTTP URL to retrieve its content
 + * Uses the cURL library or a fallback method 
   *
   * @param string $url      URL to get (http://...)
   * @param int    $timeout  network timeout (in seconds)
   *      echo 'There was an error: '.htmlspecialchars($headers[0]);
   *  }
   *
 - * @see http://php.net/manual/en/function.file-get-contents.php
 - * @see http://php.net/manual/en/function.stream-context-create.php
 - * @see http://php.net/manual/en/function.get-headers.php
 + * @see https://secure.php.net/manual/en/ref.curl.php
 + * @see https://secure.php.net/manual/en/functions.anonymous.php
 + * @see https://secure.php.net/manual/en/function.preg-split.php
 + * @see https://secure.php.net/manual/en/function.explode.php
 + * @see http://stackoverflow.com/q/17641073
 + * @see http://stackoverflow.com/q/9183178
 + * @see http://stackoverflow.com/q/1462720
   */
  function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
  {
      $urlObj = new Url($url);
      $cleanUrl = $urlObj->idnToAscii();
  
 -    if (! filter_var($cleanUrl, FILTER_VALIDATE_URL) || ! $urlObj->isHttp()) {
 +    if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
          return array(array(0 => 'Invalid HTTP Url'), false);
      }
  
 +    $userAgent =
 +        'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)'
 +        . ' Gecko/20100101 Firefox/45.0';
 +    $acceptLanguage =
 +        substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3';
 +    $maxRedirs = 3;
 +
 +    if (!function_exists('curl_init')) {
 +        return get_http_response_fallback(
 +            $cleanUrl,
 +            $timeout,
 +            $maxBytes,
 +            $userAgent,
 +            $acceptLanguage,
 +            $maxRedirs
 +        );
 +    }
 +
 +    $ch = curl_init($cleanUrl);
 +    if ($ch === false) {
 +        return array(array(0 => 'curl_init() error'), false);
 +    }
 +
 +    // General cURL settings
 +    curl_setopt($ch, CURLOPT_AUTOREFERER,       true);
 +    curl_setopt($ch, CURLOPT_FOLLOWLOCATION,    true);
 +    curl_setopt($ch, CURLOPT_HEADER,            true);
 +    curl_setopt(
 +        $ch,
 +        CURLOPT_HTTPHEADER,
 +        array('Accept-Language: ' . $acceptLanguage)
 +    );
 +    curl_setopt($ch, CURLOPT_MAXREDIRS,         $maxRedirs);
 +    curl_setopt($ch, CURLOPT_RETURNTRANSFER,    true);
 +    curl_setopt($ch, CURLOPT_TIMEOUT,           $timeout);
 +    curl_setopt($ch, CURLOPT_USERAGENT,         $userAgent);
 +
 +    // Max download size management
 +    curl_setopt($ch, CURLOPT_BUFFERSIZE,        1024);
 +    curl_setopt($ch, CURLOPT_NOPROGRESS,        false);
 +    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
 +        function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
 +        {
 +            if (version_compare(phpversion(), '5.5', '<')) {
 +                // PHP version lower than 5.5
 +                // Callback has 4 arguments
 +                $downloaded = $arg1;
 +            } else {
 +                // Callback has 5 arguments
 +                $downloaded = $arg2;
 +            }
 +            // Non-zero return stops downloading
 +            return ($downloaded > $maxBytes) ? 1 : 0;
 +        }
 +    );
 +
 +    $response = curl_exec($ch);
 +    $errorNo = curl_errno($ch);
 +    $errorStr = curl_error($ch);
 +    $headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
 +    curl_close($ch);
 +
 +    if ($response === false) {
 +        if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) {
 +            /*
 +             * Workaround to match fallback method behaviour
 +             * Removing this would require updating
 +             * GetHttpUrlTest::testGetInvalidRemoteUrl()
 +             */
 +            return array(false, false);
 +        }
 +        return array(array(0 => 'curl_exec() error: ' . $errorStr), false);
 +    }
 +
 +    // Formatting output like the fallback method
 +    $rawHeaders = substr($response, 0, $headSize);
 +
 +    // Keep only headers from latest redirection
 +    $rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders));
 +    $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
 +
 +    $content = substr($response, $headSize);
 +    $headers = array();
 +    foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
 +        if (empty($line) or ctype_space($line)) {
 +            continue;
 +        }
 +        $splitLine = explode(': ', $line, 2);
 +        if (count($splitLine) > 1) {
 +            $key = $splitLine[0];
 +            $value = $splitLine[1];
 +            if (array_key_exists($key, $headers)) {
 +                if (!is_array($headers[$key])) {
 +                    $headers[$key] = array(0 => $headers[$key]);
 +                }
 +                $headers[$key][] = $value;
 +            } else {
 +                $headers[$key] = $value;
 +            }
 +        } else {
 +            $headers[] = $splitLine[0];
 +        }
 +    }
 +
 +    return array($headers, $content);
 +}
 +
 +/**
 + * GET an HTTP URL to retrieve its content (fallback method)
 + *
 + * @param string $cleanUrl       URL to get (http://... valid and in ASCII form)
 + * @param int    $timeout        network timeout (in seconds)
 + * @param int    $maxBytes       maximum downloaded bytes
 + * @param string $userAgent      "User-Agent" header
 + * @param string $acceptLanguage "Accept-Language" header
 + * @param int    $maxRedr        maximum amount of redirections followed
 + *
 + * @return array HTTP response headers, downloaded content
 + *
 + * Output format:
 + *  [0] = associative array containing HTTP response headers
 + *  [1] = URL content (downloaded data)
 + *
 + * @see http://php.net/manual/en/function.file-get-contents.php
 + * @see http://php.net/manual/en/function.stream-context-create.php
 + * @see http://php.net/manual/en/function.get-headers.php
 + */
 +function get_http_response_fallback(
 +    $cleanUrl,
 +    $timeout,
 +    $maxBytes,
 +    $userAgent,
 +    $acceptLanguage,
 +    $maxRedr
 +) {
      $options = array(
          'http' => array(
              'method' => 'GET',
              'timeout' => $timeout,
 -            'user_agent' => 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)'
 -                         .' Gecko/20100101 Firefox/45.0',
 -            'accept_language' => substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3',
 +            'user_agent' => $userAgent,
 +            'header' => "Accept: */*\r\n"
 +                . 'Accept-Language: ' . $acceptLanguage
          )
      );
  
      stream_context_set_default($options);
 -    list($headers, $finalUrl) = get_redirected_headers($cleanUrl);
 +    list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
      if (! $headers || strpos($headers[0], '200 OK') === false) {
          $options['http']['request_fulluri'] = true;
          stream_context_set_default($options);
 -        list($headers, $finalUrl) = get_redirected_headers($cleanUrl);
 +        list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
      }
  
 -    if (! $headers || strpos($headers[0], '200 OK') === false) {
 +    if (! $headers) {
          return array($headers, false);
      }
  
@@@ -355,3 -215,29 +355,29 @@@ function page_url($server
      }
      return index_url($server);
  }
+ /**
+  * Retrieve the initial IP forwarded by the reverse proxy.
+  *
+  * Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php
+  *
+  * @param array $server     $_SERVER array which contains HTTP headers.
+  * @param array $trustedIps List of trusted IP from the configuration.
+  *
+  * @return string|bool The forwarded IP, or false if none could be extracted.
+  */
+ function getIpAddressFromProxy($server, $trustedIps)
+ {
+     $forwardedIpHeader = 'HTTP_X_FORWARDED_FOR';
+     if (empty($server[$forwardedIpHeader])) {
+         return false;
+     }
+     $ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]);
+     $ips = array_diff($ips, $trustedIps);
+     if (empty($ips)) {
+         return false;
+     }
+     return array_pop($ips);
+ }
diff --combined index.php
index f9f248953eb1d09281b548a7c01e465f553bc31e,ab51fa23b21bbc93941822094422f0a4d6c5c353..9f50d15323d2ea40207e14d08d030cfcdbb20025
+++ b/index.php
@@@ -1,6 -1,6 +1,6 @@@
  <?php
  /**
 - * Shaarli v0.7.0 - Shaare your links...
 + * Shaarli v0.8.0 - Shaare your links...
   *
   * The personal, minimalist, super-fast, database free, bookmarking service.
   *
@@@ -25,7 -25,7 +25,7 @@@ if (date_default_timezone_get() == '') 
  /*
   * PHP configuration
   */
 -define('shaarli_version', '0.7.0');
 +define('shaarli_version', '0.8.0');
  
  // http://server.com/x/shaarli --> /shaarli/
  define('WEB_PATH', substr($_SERVER['REQUEST_URI'], 0, 1+strrpos($_SERVER['REQUEST_URI'], '/', 0)));
@@@ -44,20 -44,6 +44,20 @@@ error_reporting(E_ALL^E_WARNING)
  //error_reporting(-1);
  
  
 +// 3rd-party libraries
 +if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
 +    header('Content-Type: text/plain; charset=utf-8');
 +    echo "Error: missing Composer configuration\n\n"
 +        ."If you installed Shaarli through Git or using the development branch,\n"
 +        ."please refer to the installation documentation to install PHP"
 +        ." dependencies using Composer:\n"
 +        ."- https://github.com/shaarli/Shaarli/wiki/Server-requirements\n"
 +        ."- https://github.com/shaarli/Shaarli/wiki/Download-and-Installation";
 +    exit;
 +}
 +require_once 'inc/rain.tpl.class.php';
 +require_once __DIR__ . '/vendor/autoload.php';
 +
  // Shaarli library
  require_once 'application/ApplicationUtils.php';
  require_once 'application/Cache.php';
@@@ -67,7 -53,6 +67,7 @@@ require_once 'application/config/Config
  require_once 'application/FeedBuilder.php';
  require_once 'application/FileUtils.php';
  require_once 'application/HttpUtils.php';
 +require_once 'application/Languages.php';
  require_once 'application/LinkDB.php';
  require_once 'application/LinkFilter.php';
  require_once 'application/LinkUtils.php';
@@@ -79,6 -64,7 +79,6 @@@ require_once 'application/Utils.php'
  require_once 'application/PluginManager.php';
  require_once 'application/Router.php';
  require_once 'application/Updater.php';
 -require_once 'inc/rain.tpl.class.php';
  
  // Ensure the PHP version is supported
  try {
@@@ -332,8 -318,17 +332,17 @@@ include $conf->get('resource.ban_file'
  function ban_loginFailed($conf)
  {
      $ip = $_SERVER['REMOTE_ADDR'];
+     $trusted = $conf->get('security.trusted_proxies', array());
+     if (in_array($ip, $trusted)) {
+         $ip = getIpAddressFromProxy($_SERVER, $trusted);
+         if (!$ip) {
+             return;
+         }
+     }
      $gb = $GLOBALS['IPBANS'];
-     if (!isset($gb['FAILURES'][$ip])) $gb['FAILURES'][$ip]=0;
+     if (! isset($gb['FAILURES'][$ip])) {
+         $gb['FAILURES'][$ip]=0;
+     }
      $gb['FAILURES'][$ip]++;
      if ($gb['FAILURES'][$ip] > ($conf->get('security.ban_after') - 1))
      {
@@@ -797,6 -792,8 +806,6 @@@ function renderPage($conf, $pluginManag
      if ($targetPage == Router::$PAGE_LOGIN)
      {
          if ($conf->get('security.open_shaarli')) { header('Location: ?'); exit; }  // No need to login for open Shaarli
 -        $token=''; if (ban_canLogin($conf)) $token=getToken($conf); // Do not waste token generation if not useful.
 -        $PAGE->assign('token',$token);
          if (isset($_GET['username'])) {
              $PAGE->assign('username', escape($_GET['username']));
          }
          }
          else // show the change password form.
          {
 -            $PAGE->assign('token',getToken($conf));
              $PAGE->renderPage('changepassword');
              exit;
          }
          }
          else // Show the configuration form.
          {
 -            $PAGE->assign('token',getToken($conf));
              $PAGE->assign('title', $conf->get('general.title'));
              $PAGE->assign('redirector', $conf->get('redirector.url'));
              list($timezone_form, $timezone_js) = generateTimeZoneForm($conf->get('general.timezone'));
      if ($targetPage == Router::$PAGE_CHANGETAG)
      {
          if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
 -            $PAGE->assign('token', getToken($conf));
              $PAGE->assign('tags', $LINKSDB->allTags());
              $PAGE->renderPage('changetag');
              exit;
          $data = array(
              'link' => $link,
              'link_is_new' => false,
 -            'token' => getToken($conf),
              'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
              'tags' => $LINKSDB->allTags(),
          );
          $data = array(
              'link' => $link,
              'link_is_new' => $link_is_new,
 -            'token' => getToken($conf), // XSRF protection.
              'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
              'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
              'tags' => $LINKSDB->allTags(),
 -            'default_private_links' => $conf->get('default_private_links', false),
 +            'default_private_links' => $conf->get('privacy.default_private_links', false),
          );
          $pluginManager->executeHooks('render_editlink', $data);
  
          exit;
      }
  
 -    // -------- User is uploading a file for import
 -    if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=upload'))
 -    {
 -        // If file is too big, some form field may be missing.
 -        if (!isset($_POST['token']) || (!isset($_FILES)) || (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size']==0))
 -        {
 -            $returnurl = ( empty($_SERVER['HTTP_REFERER']) ? '?' : $_SERVER['HTTP_REFERER'] );
 -            echo '<script>alert("The file you are trying to upload is probably bigger than what this webserver can accept ('.getMaxFileSize().' bytes). Please upload in smaller chunks.");document.location=\''.escape($returnurl).'\';</script>';
 +    if ($targetPage == Router::$PAGE_IMPORT) {
 +        // Upload a Netscape bookmark dump to import its contents
 +
 +        if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
 +            // Show import dialog
 +            $PAGE->assign('maxfilesize', getMaxFileSize());
 +            $PAGE->renderPage('import');
              exit;
          }
 -        if (!tokenOk($_POST['token'])) die('Wrong token.');
 -        importFile($LINKSDB);
 -        exit;
 -    }
  
 -    // -------- Show upload/import dialog:
 -    if ($targetPage == Router::$PAGE_IMPORT)
 -    {
 -        $PAGE->assign('token',getToken($conf));
 -        $PAGE->assign('maxfilesize',getMaxFileSize());
 -        $PAGE->renderPage('import');
 +        // Import bookmarks from an uploaded file
 +        if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) {
 +            // The file is too big or some form field may be missing.
 +            echo '<script>alert("The file you are trying to upload is probably'
 +                .' bigger than what this webserver can accept ('
 +                .getMaxFileSize().' bytes).'
 +                .' Please upload in smaller chunks.");document.location=\'?do='
 +                .Router::$PAGE_IMPORT .'\';</script>';
 +            exit;
 +        }
 +        if (! tokenOk($_POST['token'])) {
 +            die('Wrong token.');
 +        }
 +        $status = NetscapeBookmarkUtils::import(
 +            $_POST,
 +            $_FILES,
 +            $LINKSDB,
 +            $conf->get('resource.page_cache')
 +        );
 +        echo '<script>alert("'.$status.'");document.location=\'?do='
 +             .Router::$PAGE_IMPORT .'\';</script>';
          exit;
      }
  
      exit;
  }
  
 -/**
 - * Process the import file form.
 - *
 - * @param LinkDB        $LINKSDB Loaded LinkDB instance.
 - * @param ConfigManager $conf    Configuration Manager instance.
 - */
 -function importFile($LINKSDB, $conf)
 -{
 -    if (!isLoggedIn()) { die('Not allowed.'); }
 -
 -    $filename=$_FILES['filetoupload']['name'];
 -    $filesize=$_FILES['filetoupload']['size'];
 -    $data=file_get_contents($_FILES['filetoupload']['tmp_name']);
 -    $private = (empty($_POST['private']) ? 0 : 1); // Should the links be imported as private?
 -    $overwrite = !empty($_POST['overwrite']) ; // Should the imported links overwrite existing ones?
 -    $import_count=0;
 -
 -    // Sniff file type:
 -    $type='unknown';
 -    if (startsWith($data,'<!DOCTYPE NETSCAPE-Bookmark-file-1>')) $type='netscape'; // Netscape bookmark file (aka Firefox).
 -
 -    // Then import the bookmarks.
 -    if ($type=='netscape')
 -    {
 -        // This is a standard Netscape-style bookmark file.
 -        // This format is supported by all browsers (except IE, of course), also Delicious, Diigo and others.
 -        foreach(explode('<DT>',$data) as $html) // explode is very fast
 -        {
 -            $link = array('linkdate'=>'','title'=>'','url'=>'','description'=>'','tags'=>'','private'=>0);
 -            $d = explode('<DD>',$html);
 -            if (startsWith($d[0], '<A '))
 -            {
 -                $link['description'] = (isset($d[1]) ? html_entity_decode(trim($d[1]),ENT_QUOTES,'UTF-8') : '');  // Get description (optional)
 -                preg_match('!<A .*?>(.*?)</A>!i',$d[0],$matches); $link['title'] = (isset($matches[1]) ? trim($matches[1]) : '');  // Get title
 -                $link['title'] = html_entity_decode($link['title'],ENT_QUOTES,'UTF-8');
 -                preg_match_all('! ([A-Z_]+)=\"(.*?)"!i',$html,$matches,PREG_SET_ORDER);  // Get all other attributes
 -                $raw_add_date=0;
 -                foreach($matches as $m)
 -                {
 -                    $attr=$m[1]; $value=$m[2];
 -                    if ($attr=='HREF') $link['url']=html_entity_decode($value,ENT_QUOTES,'UTF-8');
 -                    elseif ($attr=='ADD_DATE')
 -                    {
 -                        $raw_add_date=intval($value);
 -                        if ($raw_add_date>30000000000) $raw_add_date/=1000;   //If larger than year 2920, then was likely stored in milliseconds instead of seconds
 -                    }
 -                    elseif ($attr=='PRIVATE') $link['private']=($value=='0'?0:1);
 -                    elseif ($attr=='TAGS') $link['tags']=html_entity_decode(str_replace(',',' ',$value),ENT_QUOTES,'UTF-8');
 -                }
 -                if ($link['url']!='')
 -                {
 -                    if ($private==1) $link['private']=1;
 -                    $dblink = $LINKSDB->getLinkFromUrl($link['url']); // See if the link is already in database.
 -                    if ($dblink==false)
 -                    {  // Link not in database, let's import it...
 -                       if (empty($raw_add_date)) $raw_add_date=time(); // In case of shitty bookmark file with no ADD_DATE
 -
 -                       // Make sure date/time is not already used by another link.
 -                       // (Some bookmark files have several different links with the same ADD_DATE)
 -                       // We increment date by 1 second until we find a date which is not used in DB.
 -                       // (so that links that have the same date/time are more or less kept grouped by date, but do not conflict.)
 -                       while (!empty($LINKSDB[date('Ymd_His',$raw_add_date)])) { $raw_add_date++; }// Yes, I know it's ugly.
 -                       $link['linkdate']=date('Ymd_His',$raw_add_date);
 -                       $LINKSDB[$link['linkdate']] = $link;
 -                       $import_count++;
 -                    }
 -                    else // Link already present in database.
 -                    {
 -                        if ($overwrite)
 -                        {   // If overwrite is required, we import link data, except date/time.
 -                            $link['linkdate']=$dblink['linkdate'];
 -                            $LINKSDB[$link['linkdate']] = $link;
 -                            $import_count++;
 -                        }
 -                    }
 -
 -                }
 -            }
 -        }
 -        $LINKSDB->savedb($conf->get('resource.page_cache'));
 -
 -        echo '<script>alert("File '.json_encode($filename).' ('.$filesize.' bytes) was successfully processed: '.$import_count.' links imported.");document.location=\'?\';</script>';
 -    }
 -    else
 -    {
 -        echo '<script>alert("File '.json_encode($filename).' ('.$filesize.' bytes) has an unknown file format. Nothing was imported.");document.location=\'?\';</script>';
 -    }
 -}
 -
  /**
   * Template for the list of links (<div id="linklist">)
   * This function fills all the necessary fields in the $PAGE for the template 'linklist.html'
@@@ -1662,6 -1743,7 +1671,6 @@@ function buildLinkList($PAGE,$LINKSDB, 
          'search_term' => $searchterm,
          'search_tags' => $searchtags,
          'redirector' => $conf->get('redirector.url'),  // Optional redirector URL.
 -        'token' => $token,
          'links' => $linkDisp,
          'tags' => $LINKSDB->allTags(),
      );