ourConfigDir = realpath($configDir);
}
else
{
$this->ourConfigDir = e_SYSTEM.eIPHandler::BAN_FILE_DIRECTORY;
}
$this->ourIP = $this->ipEncode($this->getCurrentIP());
$this->makeUserToken();
$ipStatus = $this->checkIP($this->ourIP);
if ($ipStatus != 0)
{
if ($ipStatus < 0)
{ // Blacklisted
$this->logBanItem($ipStatus, 'result --> '.$ipStatus); // only log blacklist
$this->banAction($ipStatus); // This will abort if appropriate
}
elseif ($ipStatus > 0)
{ // Whitelisted - we may want to set a specific indicator
}
}
// Continue here - user not banned (so far)
}
public function setIP($ip)
{
$this->ourIP = $this->ipEncode($ip);
}
public function debug($value)
{
$this->debug = ($value === true) ? true: false;
}
/**
* Add an entry to the banlist log file (which is a simple text file)
* A date/time string is prepended to the line
*
* @param int $reason - numeric reason code, usually in range -10..+10
* @param string $message - additional text as required (length not checked, but should be less than 100 characters or so
*
* @return void
*/
private function logBanItem($reason, $message)
{
if ($tmp = fopen(e_SYSTEM.eIPHandler::BAN_LOG_DIRECTORY.eIPHandler::BAN_FILE_LOG_NAME, 'a'))
{
$logLine = time().' '.$this->ourIP.' '.$reason.' '.$message."\n";
fwrite($tmp,$logLine);
fclose($tmp);
}
}
/**
* Generate relatively unique user token from browser info
* (but don't believe that the browser info is accurate - can readily be spoofed)
*
* This supplements use of the IP address in some places; both to improve user identification, and to help deal with dynamic IP allocations
*
* May be replaced by a 'global' e107 token at some point
*/
private function makeUserToken()
{
$tmpStr = '';
foreach (array('HTTP_USER_AGENT', 'HTTP_ACCEPT', 'HTTP_ACCEPT_CHARSET', 'HTTP_ACCEPT_LANGUAGE', 'HTTP_ACCEPT_ENCODING') as $v)
{
if (isset($_SERVER[$v]))
{
$tmpStr .= $_SERVER[$v];
}
else
{
$tmpStr .= 'dummy'.$v;
}
}
$this->accessID = md5($tmpStr);
}
/**
* Return browser-characteristics token
*/
public function getUserToken()
{
return $this->accessID; // Should always be defined at this point
}
/**
* Check whether an IP address is routable
*
* @param string $ip - IPV4 or IPV6 numeric address.
*
* @return boolean TRUE if routable, FALSE if not
@todo handle IPV6 fully
*/
public function isAddressRoutable($ip)
{
$ignore = array(
'0\..*' , '^127\..*' , // Local loopbacks
'192\.168\..*' , // RFC1918 - Private Network
'172\.(?:1[6789]|2\d|3[01])\..*' , // RFC1918 - Private network
'10\..*' , // RFC1918 - Private Network
'169\.254\..*' , // RFC3330 - Link-local, auto-DHCP
'2(?:2[456789]|[345][0-9])\..*' // Single check for Class D and Class E
);
$pattern = '#^('.implode('|',$ignore).')#';
if(preg_match($pattern,$ip))
{
return false;
}
/* XXX preg_match doesn't accept arrays.
if (preg_match(array(
'#^0\..*#' , '#^127\..*#' , // Local loopbacks
'#^192\.168\..*#' , // RFC1918 - Private Network
'#^172\.(?:1[6789]|2\d|3[01])\..*#' , // RFC1918 - Private network
'#^10\..*#' , // RFC1918 - Private Network
'#^169\.254\..*#' , // RFC3330 - Link-local, auto-DHCP
'#^2(?:2[456789]|[345][0-9])\..*#' // Single check for Class D and Class E
), $ip))
{
return FALSE;
}
*/
if (strpos(':', $ip) === FALSE) return TRUE;
// Must be an IPV6 address here
// @todo need to handle IPV4 addresses in IPV6 format
$ip = strtolower($ip);
if ($ip == 'ff02::1') return FALSE; // link-local all nodes multicast group
if ($ip == 'ff02:0000:0000:0000:0000:0000:0000:0001') return FALSE;
if ($ip == '::1') return FALSE; // localhost
if ($ip == '0000:0000:0000:0000:0000:0000:0000:0001') return FALSE;
if (substr($ip, 0, 5) == 'fc00:') return FALSE; // local addresses
// @todo add:
// ::0 (all zero) - invalid
// ff02::1:ff00:0/104 - Solicited-Node multicast addresses - add?
// 2001:0000::/29 through 2001:01f8::/29 - special purpose addresses
// 2001:db8::/32 - used in documentation
return TRUE;
}
/**
* Get current user's IP address in 'normal' form.
* Likely to be very similar to existing e107::getIP() function
* May log X-FORWARDED-FOR cases - or could generate a special IPV6 address, maybe?
*/
private function getCurrentIP()
{
if(!$this->ourIP)
{
$ip = $_SERVER['REMOTE_ADDR'];
if ($ip4 = getenv('HTTP_X_FORWARDED_FOR'))
{
if (!$this->isAddressRoutable($ip))
{
$ip3 = explode(',', $ip4); // May only be one address; could be several, comma separated, if multiple proxies used
$ip = trim($ip3[sizeof($ip3) - 1]); // If IP address is unroutable, replace with any forwarded_for address
$this->logBanItem(0, 'X_Forward '.$ip4.' --> '.$ip); // Just log for interest ATM
}
}
if($ip == '')
{
$ip = 'x.x.x.x';
}
$this->ourIP = $this->ipEncode($ip); // Normalise for storage
}
return $this->ourIP;
}
/**
* Return the user's IP address, in normal or display-friendly form as requested
*
* @param boolean $forDisplay - TRUE for minimum-length display-friendly format. FALSE for 'normal' form (to be used when storing into DB etc)
*
* @return string IP address
*
* Note: if we define USER_IP (and maybe USER_DISPLAY_IP) constant, this function is strictly unnecessary. But we still need a format conversion routine
*/
public function getIP($forDisplay = FALSE)
{
if ($forDisplay == FALSE) return $this->ourIP;
return $this->ipDecode($this->ourIP);
}
/**
* Takes appropriate action for a blacklisted IP address
*
* @param int $code - integer value < 0 specifying the ban reason.
*
* @return none (may not even return)
*
* Looks up the reason code, and extracts the corresponding text.
* If this text begins with 'http://' or 'https://', assumed to be a link to a web page, and redirects.
* Otherwise displays an error message to the user (if configured) then aborts.
*/
private function banAction($code)
{
$search = '['.$code.']';
$fileName = $this->ourConfigDir.eIPHandler::BAN_FILE_ACTION_NAME.eIPHandler::BAN_FILE_EXTENSION;
if (!is_readable($fileName)) return; // @todo should we just die if no file - we know the IP is in the ban list.
$vals = file($fileName);
if ($vals === FALSE || count($vals) == 0) return;
if (substr($vals[0], 0, 5) != 'ourConfigDir.eIPHandler::BAN_FILE_RETRIGGER_NAME.eIPHandler::BAN_FILE_EXTENSION, 'a'))
{
$logLine = time().' '.$this->matchAddress.' '.$code.' Retrigger: '.$this->ourIP."\n"; // Same format as log entries - can share routines
fwrite($tmp,$logLine);
fclose($tmp);
}
}
$line = trim(substr($line, strlen($search)));
if ((strpos($line, 'http://') === 0) || (strpos($line, 'https://') === 0))
{ // Display a specific web page
if (strpos($line, '?') === FALSE)
{
$line .= '?'.$search; // Add on the ban reason - may be useful in the page
}
e107::redirect($line);
exit();
}
// Otherwise just display any message and die
if($this->debug)
{
print_a("User Banned");
}
echo $line;
die();
}
}
$this->logBanItem($code, 'Unmatched action: '.$search.' - no block implemented');
}
/**
* Get whitelist and blacklist
*
* @return array - each element is an array with elements 'ip', 'action, and 'time_limit'
*
* Note: Intentionally a single call, so the two lists can be split across files as convenient
*
* At present the list is a single file, one entry per line, whitelist entries first. Most precisely defined addresses before larger subnets
*
* Format of each line is:
* IP_address action expiry_time additional_parameters
*
* where action is: >0 = whitelisted, <0 blacklisted, value is 'reason code'
* expiry_time is zero for an indefinite ban, time stamp for a limited ban
* additional_parameters may be required for certain actions in the future
*/
private function getWhiteBlackList()
{
$ret = array();
$fileName = $this->ourConfigDir.eIPHandler::BAN_FILE_IP_NAME.eIPHandler::BAN_FILE_EXTENSION;
if (!is_readable($fileName)) return $ret;
$vals = file($fileName);
if ($vals === FALSE || count($vals) == 0) return $ret;
if (substr($vals[0], 0, 5) != '= 2)
{
$ret[] = array('ip' => $tmp[0], 'action' => $tmp[1], 'time_limit' => intval(varset($tmp[2], 0)));
}
}
}
$this->actionCount = count($ret); // Note how many entries in list
return $ret;
}
/**
* Checks whether IP address is in the whitelist or blacklist.
*
* @param string $addr - IP address in 'normal' form
*
* @return int - >0 = whitelisted, 0 = not listed (= 'OK'), <0 is 'reason code' for ban
*
* note: Could maybe combine this with getWhiteBlackList() for efficiency, but makes it less general
*/
private function checkIP($addr)
{
$now = time();
$checkLists = $this->getWhiteBlackList();
if($this->debug)
{
echo "
Banlist.php
";
print_a($checkLists);
print_a("Now: ".$now. " ".date('r',$now));
}
foreach ($checkLists as $val)
{
if (strpos($addr, $val['ip']) === 0) // See if our address begins with an entry - handles wildcards
{ // Match found
if($this->debug)
{
print_a("Found ".$addr." in file. TimeLimit: ".date('r',$val['time_limit']));
}
if (($val['time_limit'] == 0) || ($val['time_limit'] > $now))
{ // Indefinite ban, or timed ban (not expired) or whitelist entry
if ($val['action']== eIPHandler::BAN_TYPE_LEGACY) return eIPHandler::BAN_TYPE_MANUAL; // Precautionary
$this->matchAddress = $val['ip'];
return $val['action']; // OK to just return - PHP should release the memory used by $checkLists
}
// Time limit expired
$this->clearBan = $val['ip']; // Note what triggered the match - it could be a wildcard (although timed ban unlikely!)
return 0; // Can just return - shouldn't be another entry
}
}
return 0;
}
/**
* Encode an IPv4 address into IPv6
* Similar functionality to ipEncode
*
* @param $ip
* @param bool $wildCards
* @param string $div
* @return string - the 'ip4' bit of an IPv6 address (i.e. last 32 bits)
*/
private function ip4Encode($ip, $wildCards = FALSE, $div = ':')
{
$ipa = explode('.', $ip);
$temp = '';
for ($s = 0; $s < 4; $s++)
{
if (!isset($ipa[$s])) $ipa[$s] = '*';
if ((($ipa[$s] == '*') || (strpos($ipa[$s], 'x') !== FALSE)) && $wildCards)
{
$temp .= 'xx';
}
else
{ // Put a zero in if wildcards not allowed
$temp .= sprintf('%02x', $ipa[$s]);
}
if ($s == 1) $temp .= $div;
}
return $temp;
}
/**
* Encode an IP address to internal representation. Returns string if successful; FALSE on error
* Default separates fields with ':'; set $div='' to produce a 32-char packed hex string
*
* @param string $ip - 'raw' IP address. May be IPv4, IPv6
* @param boolean $wildCards - if TRUE, wildcard characters allowed at the end of an address:
* '*' replaces 2 hex characters (primarily for 8-bit subnets of IPv4 addresses)
* 'x' replaces a single hex character
* @param string $div separator between 4-character blocks of the IPv6 address
*
* @return bool|string encoded IP. Always exactly 32 characters plus separators if conversion successful
* FALSE if conversion unsuccessful
*/
public function ipEncode($ip, $wildCards = FALSE, $div = ':')
{
$ret = '';
$divider = '';
if(strpos($ip, ':')!==FALSE)
{ // Its IPV6 (could have an IP4 'tail')
if(strpos($ip, '.')!==FALSE)
{ // IPV4 'tail' to deal with
$temp = strrpos($ip, ':')+1;
$ip = substr($ip, 0, $temp).$this->ip4Encode(substr($ip, $temp), $wildCards, $div);
}
// Now 'normalise' the address
$temp = explode(':', $ip);
$s = 8-count($temp); // One element will of course be the blank
foreach($temp as $f)
{
if($f=='')
{
$ret .= $divider.'0000'; // Always put in one set of zeros for the blank
$divider = $div;
if($s>0)
{
$ret .= str_repeat($div.'0000', $s);
$s = 0;
}
}
else
{
$ret .= $divider.sprintf('%04x', hexdec($f));
$divider = $div;
}
}
return $ret;
}
if(strpos($ip, '.')!==FALSE)
{ // Its IPV4
return str_repeat('0000'.$div, 5).'ffff'.$div.$this->ip4Encode($ip, $wildCards, $div);
}
return FALSE; // Unknown
}
/**
* Given a potentially truncated IPV6 address as used in the ban list files, adds 'x' characters etc to create
* a normalised IPV6 address as stored in the DB. Returned length is exactly 39 characters
* @param $address
* @return string
*/
public function ip6AddWildcards($address)
{
while (($togo = (39 - strlen($address))) > 0)
{
if (($togo % 5) == 0)
{
$address .= ':';
}
else
{
$address .= 'x';
}
}
return $address;
}
/**
* Takes an encoded IP address - returns a displayable one
* Set $IP4Legacy TRUE to display 'old' (IPv4) addresses in the familiar dotted format,
* FALSE to display in standard IPV6 format
* Should handle most things that can be thrown at it.
* If wildcard characters ('x' found, incorporated 'as is'
*
* @param string $ip encoded IP
* @param boolean $IP4Legacy
* @return string decoded IP
*/
public function ipDecode($ip, $IP4Legacy = TRUE)
{
if (strstr($ip,'.'))
{
if ($IP4Legacy) return $ip; // Assume its unencoded IPV4
$ipa = explode('.', $ip);
$ip = '0:0:0:0:0:ffff:'.sprintf('%02x%02x:%02x%02x', $ipa[0], $ipa[1], $ipa[2], $ipa[3]);
$ip = str_repeat('0000'.':', 5).'ffff:'.$this->ip4Encode($ip, TRUE, ':');
}
if (strstr($ip,'::')) return $ip; // Assume its a compressed IPV6 address already
if ((strlen($ip) == 8) && !strstr($ip,':'))
{ // Assume a 'legacy' IPV4 encoding
$ip = '0:0:0:0:0:ffff:'.implode(':',str_split($ip,4)); // Turn it into standard IPV6
}
elseif ((strlen($ip) == 32) && !strstr($ip,':'))
{ // Assume a compressed hex IPV6
$ip = implode(':',str_split($ip,4));
}
if (!strstr($ip,':')) return FALSE; // Return on problem - no ':'!
$temp = explode(':',$ip);
$z = 0; // State of the 'zero manager' - 0 = not started, 1 = running, 2 = done
$ret = '';
$zc = 0; // Count zero fields (not always required)
foreach ($temp as $t)
{
$v = hexdec($t);
if (($v != 0) || ($z == 2) || (strpos($t, 'x') !== FALSE))
{
if ($z == 1)
{ // Just finished a run of zeros
$z++;
$ret .= ':';
}
if ($ret) $ret .= ':';
if (strpos($t, 'x') !== FALSE)
{
$ret .= $t;
}
else
{
$ret .= sprintf('%x',$v); // Drop leading zeros
}
}
else
{ // Zero field
$z = 1;
$zc++;
}
}
if ($z == 1)
{ // Need to add trailing zeros, or double colon
if ($zc > 1) $ret .= '::'; else $ret .= ':0';
}
if ($IP4Legacy && (substr($ret,0,7) == '::ffff:'))
{
$temp = str_replace(':', '', substr($ip,-9, 9));
$tmp = str_split($temp, 2); // Four 2-character hex values
$z = array();
foreach ($tmp as $t)
{
if ($t == 'xx')
{
$z[] = '*';
}
else
{
$z[] = hexdec($t);
}
}
$ret = implode('.',$z);
}
return $ret;
}
/**
* Given a string which may be IP address, email address etc, tries to work out what it is
* Uses a fairly simplistic (but quick) approach - does NOT check formatting etc
*
* @param string $string
* @return string ip|email|url|ftp|unknown
*/
public function whatIsThis($string)
{
$string = trim($string);
if (strpos($string, '@') !== FALSE) return 'email'; // Email address
if (strpos($string, 'http://') === 0) return 'url';
if (strpos($string, 'https://') === 0) return 'url';
if (strpos($string, 'ftp://') === 0) return 'ftp';
if (strpos($string, ':') !== FALSE) return 'ip'; // Identify ipv6
$string = strtolower($string);
if (str_replace(' ', '', strtr($string,'0123456789abcdef.*', ' ')) == '') // Delete all characters found in ipv4 addresses, plus wildcards
{
return 'ip';
}
return 'unknown';
}
/**
* Retrieve & cache host name
*
* @param string $ip_address
* @return string host name
*/
public function get_host_name($ip_address)
{
if(!isset($this->_host_name_cache[$ip_address]))
{
$this->_host_name_cache[$ip_address] = gethostbyaddr($ip_address);
}
return $this->_host_name_cache[$ip_address];
}
/**
* Generate DB query for domain name-related checks
*
* If an email address is passed, discards the individual's name
*
* @param string $email - an email address or domain name string
* @param string $fieldName
* @return array|bool false if invalid domain name format
* false if invalid domain name format
* array of values to compare
* @internal param string $fieldname - if non-empty, each array entry is a comparison with this field
*
*/
function makeDomainQuery($email, $fieldName = 'banlist_ip')
{
$tp = e107::getParser();
if (($tv = strrpos('@', $email)) !== FALSE)
{
$email = substr($email, $tv+1);
}
$tmp = strtolower($tp -> toDB(trim($email)));
if ($tmp == '') return FALSE;
if (strpos($tmp,'.') === FALSE) return FALSE;
$em = array_reverse(explode('.',$tmp));
$line = '';
$out = array('*@'.$tmp); // First element looks for domain as email address
foreach ($em as $e)
{
$line = '.'.$e.$line;
$out[] = '*'.$line;
}
if ($fieldName)
{
foreach ($out as $k => $v)
{
$out[$k] = '(`'.$fieldName."`='".$v."')";
}
}
return $out;
}
/**
* Split up an email address to check for banned domains.
* @param string $email - email address to process
* @param string $fieldname - name of field being searched in DB
*
* @return bool|string false if invalid address. Otherwise returns a set of values to check
* (Moved in from user_handler.php)
*/
public function makeEmailQuery($email, $fieldname = 'banlist_ip')
{
$tp = e107::getParser();
$tmp = strtolower($tp -> toDB(trim(substr($email, strrpos($email, "@")+1)))); // Pull out the domain name
if ($tmp == '') return FALSE;
if (strpos($tmp,'.') === FALSE) return FALSE;
$em = array_reverse(explode('.',$tmp));
$line = '';
$out = array($fieldname."='*@{$tmp}'"); // First element looks for domain as email address
foreach ($em as $e)
{
$line = '.'.$e.$line;
$out[] = '`'.$fieldname."`='*{$line}'";
}
return implode(' OR ',$out);
}
/**
* Routines beyond here are to handle banlist-related tasks which involve the DB
* note: Most of these routines already existed; moved in from e107_class.php
*/
/**
* Check if current user is banned
*
* This is called soon after the DB is opened, to do checks which require it.
* Previous checks have already done IP-based bans.
*
* Starts by removing expired bans if $this->clearBan is set
*
* Generates the queries to interrogate the ban list, then calls $this->check_ban().
* If the user is banned, $check_ban() never returns - so a return from this routine indicates a non-banned user.
*
* @return void
*
* @todo should be possible to simplify, since IP addresses already checked earlier
*/
public function ban()
{
$sql = e107::getDb();
if ($this->clearBan !== FALSE)
{ // Expired ban to clear - match exactly the address which triggered this action - could be a wildcard
$clearAddress = $this->ip6AddWildcards($this->clearBan);
if ($sql->delete('banlist',"`banlist_ip`='{$clearAddress}'"))
{
$this->actionCount--; // One less item on list
$this->logBanItem(0,'Ban cleared: '.$clearAddress);
// Now regenerate the text files - so no further triggers from this entry
$this->regenerateFiles();
}
}
// do other checks - main IP check is in _construct()
if($this->actionCount)
{
$ip = $this->getip(); // This will be in normalised IPV6 form
if ($ip != e107::LOCALHOST_IP && $ip != e107::LOCALHOST_IP2) // Check host name, user email to see if banned
{
$vals = array();
if (e107::getPref('enable_rdns'))
{
$vals = array_merge($vals, $this->makeDomainQuery($this->get_host_name($ip), ''));
}
if ((defined('USEREMAIL') && USEREMAIL))
{
// @todo is there point to this? Usually avoid a complete query if we skip it
$vals = array_merge($vals, $this->makeDomainQuery(USEREMAIL, ''));
}
if (count($vals))
{
$vals = array_unique($vals); // Could get identical values from domain name check and email check
if($this->debug)
{
print_a($vals);
}
$match = "`banlist_ip`='".implode("' OR `banlist_ip`='", $vals)."'";
$this->checkBan($match);
}
}
elseif($this->debug)
{
print_a("IP is LocalHost - skipping ban-check");
}
}
}
/**
* Check the banlist table. $query is used to determine the match.
* If $do_return, will always return with ban status - TRUE for OK, FALSE for banned.
* If return permitted, will never display a message for a banned user; otherwise will display any message then exit
* @todo consider whether can be simplified
*
* @param string $query - the 'WHERE' part of the DB query to be executed
* @param boolean $show_error - if true, adds a '403 Forbidden' header for a banned user
* @param boolean $do_return - if TRUE, returns regardless without displaying anything. if FALSE, for a banned user displays any message and exits
* @return boolean TRUE for OK, FALSE for banned.
*/
public function checkBan($query, $show_error = TRUE, $do_return = FALSE)
{
$sql = e107::getDb();
$pref = e107::getPref();
$tp = e107::getParser();
$admin_log = e107::getAdminLog();
//$admin_log->e_log_event(4,__FILE__."|".__FUNCTION__."@".__LINE__,"DBG","Check for Ban",$query,FALSE,LOG_TO_ROLLING);
if ($sql->select('banlist', '*', $query.' ORDER BY `banlist_bantype` DESC'))
{
// Any whitelist entries will be first, because they are positive numbers - so we can answer based on the first DB record read
$row = $sql->fetch();
if ($row['banlist_bantype'] >= eIPHandler::BAN_TYPE_WHITELIST)
{
//$admin_log->e_log_event(4,__FILE__."|".__FUNCTION__."@".__LINE__,"DBG","Whitelist hit",$query,FALSE,LOG_TO_ROLLING);
return TRUE; // Whitelisted entry
}
// Found banlist entry in table here
if (($row['banlist_banexpires']>0) && ($row['banlist_banexpires']