diff --git a/inc/Data/Driver/Dns/DnsDriver.php b/inc/Data/Driver/Dns/DnsDriver.php new file mode 100644 index 00000000..cbfcc621 --- /dev/null +++ b/inc/Data/Driver/Dns/DnsDriver.php @@ -0,0 +1,21 @@ +timeout = $timeout; + } + + public function nameToIPs(string $name): ?array { + $ret = shell_exec_error("host -W {$this->timeout} {$name}"); + if ($ret === false) { + return null; + } + + $ipv4 = self::matchOrEmpty('/has address ([^\s]+)/', $ret); + $ipv6 = self::matchOrEmpty('/has IPv6 address ([^\s]+)/', $ret); + return \array_merge($ipv4, $ipv6); + } + + public function IPToNames(string $ip): ?array { + $ret = shell_exec_error("host -W {$this->timeout} {$ip}"); + if ($ret === false) { + return null; + } + + $names = self::matchOrEmpty('/domain name pointer ([^\s]+)\./', $ret); + return \array_map(fn($n) => \strtolower(\rtrim($n, '.')), $names); + } +} diff --git a/inc/Data/Driver/Dns/LibcDnsDriver.php b/inc/Data/Driver/Dns/LibcDnsDriver.php new file mode 100644 index 00000000..71e838df --- /dev/null +++ b/inc/Data/Driver/Dns/LibcDnsDriver.php @@ -0,0 +1,50 @@ +> $filters The config filters. * @param FloodService $floodService The FloodService. * @param LogDriver $logger The LogDriver. + * @param DnsDriver $dns_resolver DnsResolver for hostname matching. */ - public function __construct(array $filters, FloodService $floodService, LogDriver $logger) { + public function __construct(array $filters, FloodService $floodService, LogDriver $logger, DnsDriver $dns_resolver) { $this->filters = $filters; $this->floodService = $floodService; $this->logger = $logger; + $this->dns_resolver = $dns_resolver; } /** @@ -260,13 +268,17 @@ class FilterService { return false; } - $hostname = \rDNS($post['ip']); - if ($hostname === $post['ip']) { - $this->logger->log(LogDriver::WARNING, "RDNS lookup failed for IP: {$post['ip']}"); + $hostnames = $this->dns_resolver->IPToNames($post['ip']); + if ($hostnames === null) { return false; } - return $this->checkRegex($value, $hostname, 'RDNS'); + foreach ($hostnames as $name) { + if ($this->checkRegex($value, $name, 'RDNS')) { + return true; + } + } + return false; case 'agent': $this->validateType($value, 'array', 'Agent condition list'); return $this->matchAgentCondition($value); diff --git a/inc/Service/IpBlacklistService.php b/inc/Service/IpBlacklistService.php new file mode 100644 index 00000000..9eafbd79 --- /dev/null +++ b/inc/Service/IpBlacklistService.php @@ -0,0 +1,215 @@ +exceptions)) { + return true; + } + + if (\filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE) !== false) { + return true; + } + + return false; + } + + private function isIpBlacklistedImpl(string $ip, string $rip): ?string { + foreach ($this->blacklist_providers as $blacklist) { + $blacklist_host = $blacklist; + if (\is_array($blacklist)) { + $blacklist_host = $blacklist[0]; + } + + // The name that will be looked up. + $name = self::buildEndpoint($blacklist_host, $rip); + + // Do the actual check. + $is_blacklisted = $this->checkNameResolves($name); + + if ($is_blacklisted) { + // Pick the strategy to deal with this blacklisted host. + + if (!isset($blacklist[1])) { + // Just block them. + return $blacklist_host; + } elseif (\is_array($blacklist[1])) { + // Check if the blacklist applies only to some IPs. + foreach ($blacklist[1] as $octet_or_ip) { + if ($ip == $octet_or_ip || $ip == "127.0.0.$octet_or_ip") { + return $blacklist_host; + } + } + } elseif (\is_callable($blacklist[1])) { + // Custom user provided function. + if ($blacklist[1]($ip)) { + return $blacklist_host; + } + } else { + // Check if the blacklist only applies to a specific IP. + if ($ip == $blacklist[1] || $ip == "127.0.0.{$blacklist[1]}") { + return $blacklist_host; + } + } + } + } + return null; + } + + private function checkNameResolves(string $name): bool { + $value = $this->cache->get(self::buildDnsCacheKey($name)); + if ($value === null) { + $value = !empty($this->resolver->nameToIps(self::buildDnsCacheKey($name))); + $this->cache->set(self::buildDnsCacheKey($name), $value, self::DNS_CACHE_TIMEOUT); + } + return $value; + } + + + /** + * Build a DNS accessor. + * + * @param DnsDriver $resolver DNS driver. + * @param CacheDriver $cache Cache driver. + * @param array $blacklists Array of DNS blacklist providers. + * @param array $exceptions Exceptions to the blacklists. + * @param bool $rdns_validate If to validate the Reverse DNS queries results. + */ + public function __construct(DnsDriver $resolver, CacheDriver $cache, array $blacklist_providers, array $exceptions, bool $rdns_validate) { + $this->resolver = $resolver; + $this->cache = $cache; + $this->blacklist_providers = $blacklist_providers; + $this->exceptions = $exceptions; + $this->rdns_validate = $rdns_validate; + } + + /** + * Is the given IP known to a blacklist and not whitelisted? + * Documentation: https://github.com/vichan-devel/vichan/wiki/dnsbl + * + * @param string $ip The ip to lookup. + * @return ?string Returns the hit blacklist if the IP is a in known blacklist. Null if the IP is not blacklisted. + * @throws InvalidArgumentException Throws if $ip is not a valid IPv4 or IPv6 address. + */ + public function isIpBlacklisted(string $ip): ?string { + $rev_ip = false; + $ret = self::reverseIpv4Octets($ip); + if ($ret !== null) { + $rev_ip = $ret; + } + $ret = self::reverseIpv6Octets($ip); + if ($ret !== null) { + $rev_ip = $ret; + } + + if ($rev_ip === false) { + throw new \InvalidArgumentException("'$ip' is not a valid ip address"); + } + + if ($this->isIpWhitelisted($ip)) { + return null; + } + + return $this->isIpBlacklistedImpl($ip, $rev_ip); + } + + /** + * Performs the Reverse DNS lookup (rDNS) of the given IP. + * This function can be slow since may validate the response. + * + * @param string $ip The ip to lookup. + * @return array The hostnames of the given ip. + * @throws InvalidArgumentException Throws if $ip is not a valid IPv4 or IPv6 address. + */ + public function ipToNames(string $ip): ?array { + $ret = self::filterIp($ip); + if ($ret === false) { + throw new \InvalidArgumentException("'$ip' is not a valid ip address"); + } + + $names = $this->cache->get(self::buildRDnsCacheKey($ret)); + if ($names !== null) { + return $names; + } + + $names = $this->resolver->IpToNames($ret); + if ($names === false) { + $this->cache->set(self::buildRDnsCacheKey($ret), [], self::DNS_CACHE_TIMEOUT); + return []; + } + + // Do we bother with validating the result? + if (!$this->rdns_validate) { + $this->cache->set(self::buildRDnsCacheKey($ret), $names, self::DNS_CACHE_TIMEOUT); + return $names; + } + + // Filter out the names that do not resolve to the given ip. + $acc = []; + foreach ($names as $name) { + // Validate the response. + $resolved_ips = $this->resolver->nameToIps($name); + if ($resolved_ips !== null && \is_array($ret, $resolved_ips)) { + $acc[] = $name; + } + } + + $this->cache->set(self::buildRDnsCacheKey($ret), $acc, self::DNS_CACHE_TIMEOUT); + return $acc; + } +} diff --git a/inc/context.php b/inc/context.php index 8c7eff9c..92a8cc20 100644 --- a/inc/context.php +++ b/inc/context.php @@ -3,10 +3,12 @@ namespace Vichan; use Vichan\Controller\FloodManager; use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; +use Vichan\Data\Driver\Dns\{DnsDriver, HostDnsDriver, LibcDnsDriver}; use Vichan\Data\{FloodQueries, IpNoteQueries, UserPostQueries, ReportQueries}; use Vichan\Service\FilterService; use Vichan\Service\FloodService; use Vichan\Service\HCaptchaQuery; +use Vichan\Service\IpBlacklistService; use Vichan\Service\SecureImageCaptchaQuery; use Vichan\Service\ReCaptchaQuery; use Vichan\Service\YandexCaptchaQuery; @@ -71,6 +73,14 @@ function build_context(array $config): Context { ); }, CacheDriver::class => fn(): CacheDriver => \Cache::getCache(), + DnsDriver::class => function(Context $c) { + $config = $c->get('config'); + if ($config['dns_system']) { + return new HostDnsDriver(2); + } else { + return new LibcDnsDriver(2); + } + }, \PDO::class => function(): \PDO { global $pdo; // Ensure the PDO is initialized. @@ -99,7 +109,8 @@ function build_context(array $config): Context { FilterService::class => fn(Context $c): FilterService => new FilterService( $c->get('config')['filters'], $c->get(FloodService::class), - $c->get(LogDriver::class) + $c->get(LogDriver::class), + $c->get(DnsDriver::class) ), FloodManager::class => fn(Context $c): FloodManager => new FloodManager( $c->get(FilterService::class), @@ -107,6 +118,16 @@ function build_context(array $config): Context { $c->get(IpNoteQueries::class), $c->get(LogDriver::class) ), + IpBlacklistService::class => function(Context $c): IpBlacklistService { + $config = $c->get('config'); + return new IpBlacklistService( + $c->get(DnsDriver::class), + $c->get(CacheDriver::class), + $config['dnsbl'], + $config['dnsbl_exceptions'], + $config['fcrdns'] + ); + } ]); } diff --git a/inc/functions.php b/inc/functions.php index 52a7ec20..e568874f 100755 --- a/inc/functions.php +++ b/inc/functions.php @@ -1767,61 +1767,6 @@ function buildJavascript() { file_write($config['file_script'], $script); } -function checkDNSBL() { - global $config; - - if (isIPv6()) - return; // No IPv6 support yet. - - if (!isset($_SERVER['REMOTE_ADDR'])) - return; // Fix your web server configuration - - if (preg_match("/^(::(ffff:)?)?(127\.|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|0\.|255\.)/", $_SERVER['REMOTE_ADDR'])) - return; // It's pointless to check for local IP addresses in dnsbls, isn't it? - - if (in_array($_SERVER['REMOTE_ADDR'], $config['dnsbl_exceptions'])) - return; - - $ipaddr = ReverseIPOctets($_SERVER['REMOTE_ADDR']); - - foreach ($config['dnsbl'] as $blacklist) { - if (!is_array($blacklist)) - $blacklist = array($blacklist); - - if (($lookup = str_replace('%', $ipaddr, $blacklist[0])) == $blacklist[0]) - $lookup = $ipaddr . '.' . $blacklist[0]; - - if (!$ip = DNS($lookup)) - continue; // not in list - - $blacklist_name = isset($blacklist[2]) ? $blacklist[2] : $blacklist[0]; - - if (!isset($blacklist[1])) { - // If you're listed at all, you're blocked. - error(sprintf($config['error']['dnsbl'], $blacklist_name)); - } elseif (is_array($blacklist[1])) { - foreach ($blacklist[1] as $octet) { - if ($ip == $octet || $ip == '127.0.0.' . $octet) - error(sprintf($config['error']['dnsbl'], $blacklist_name)); - } - } elseif (is_callable($blacklist[1])) { - if ($blacklist[1]($ip)) - error(sprintf($config['error']['dnsbl'], $blacklist_name)); - } else { - if ($ip == $blacklist[1] || $ip == '127.0.0.' . $blacklist[1]) - error(sprintf($config['error']['dnsbl'], $blacklist_name)); - } - } -} - -function isIPv6() { - return strstr($_SERVER['REMOTE_ADDR'], ':') !== false; -} - -function ReverseIPOctets($ip) { - return implode('.', array_reverse(explode('.', $ip))); -} - function wordfilters(&$body) { global $config; @@ -2512,60 +2457,6 @@ function undoImage(array $post) { } } -function rDNS($ip_addr) { - global $config; - - if ($config['cache']['enabled'] && ($host = cache::get('rdns_' . $ip_addr))) { - return $host; - } - - if (!$config['dns_system']) { - $host = gethostbyaddr($ip_addr); - } else { - $resp = shell_exec_error('host -W 3 ' . $ip_addr); - if (preg_match('/domain name pointer ([^\s]+)$/', $resp, $m)) - $host = $m[1]; - else - $host = $ip_addr; - } - - $isip = filter_var($host, FILTER_VALIDATE_IP); - - if ($config['fcrdns'] && !$isip && DNS($host) != $ip_addr) { - $host = $ip_addr; - } - - if ($config['cache']['enabled']) - cache::set('rdns_' . $ip_addr, $host); - - return $host; -} - -function DNS($host) { - global $config; - - if ($config['cache']['enabled'] && ($ip_addr = cache::get('dns_' . $host))) { - return $ip_addr != '?' ? $ip_addr : false; - } - - if (!$config['dns_system']) { - $ip_addr = gethostbyname($host); - if ($ip_addr == $host) - $ip_addr = false; - } else { - $resp = shell_exec_error('host -W 1 ' . $host); - if (preg_match('/has address ([^\s]+)$/', $resp, $m)) - $ip_addr = $m[1]; - else - $ip_addr = false; - } - - if ($config['cache']['enabled']) - cache::set('dns_' . $host, $ip_addr !== false ? $ip_addr : '?'); - - return $ip_addr; -} - function shell_exec_error($command, $suppress_stdout = false) { global $config, $debug; diff --git a/inc/mod/pages.php b/inc/mod/pages.php index f815ab4b..4c860fe7 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -6,6 +6,7 @@ use Vichan\Context; use Vichan\Data\{IpNoteQueries, UserPostQueries, ReportQueries}; use Vichan\Functions\{Format, Net}; use Vichan\Data\Driver\{CacheDriver, LogDriver}; +use Vichan\Data\Driver\Dns\DnsDriver; defined('TINYBOARD') or exit; @@ -971,7 +972,16 @@ function mod_user_posts_by_ip(Context $ctx, string $cip, ?string $encoded_cursor } if ($config['mod']['dns_lookup'] && empty($config['ipcrypt_key'])) { - $args['hostname'] = rDNS($ip); + $resolver = $ctx->get(DnsDriver::class); + $names = $resolver->IPToNames($ip); + + if (!empty($names)) { + if (count($names) === 1) { + $args['hostname'] = $names[0]; + } else { + $args['hostname'] = $names; + } + } } if (hasPermission($config['mod']['view_ban'])) { diff --git a/post.php b/post.php index fb927987..d636b7a6 100644 --- a/post.php +++ b/post.php @@ -5,10 +5,9 @@ require_once 'inc/bootstrap.php'; -use Vichan\{Context, WebDependencyFactory}; use Vichan\Data\Driver\{LogDriver, HttpDriver}; use Vichan\Data\ReportQueries; -use Vichan\Service\{RemoteCaptchaQuery, SecureImageCaptchaQuery}; +use Vichan\Service\{IpBlacklistService, RemoteCaptchaQuery, SecureImageCaptchaQuery}; use Vichan\Functions\{Format, IP}; /** @@ -373,7 +372,10 @@ if (isset($_POST['delete'])) { } } - checkDNSBL(); + $blacklist = $context->get(IpBlacklistService::class)->isIpBlacklisted($_SERVER['REMOTE_ADDR']); + if ($blacklist !== false) { + error(\sprintf($config['error']['dnsbl'], $blacklist)); + } // Check if board exists if (!openBoard($_POST['board'])) @@ -468,7 +470,10 @@ if (isset($_POST['delete'])) { } } - checkDNSBL(); + $blacklist = $context->get(IpBlacklistService::class)->isIpBlacklisted($_SERVER['REMOTE_ADDR']); + if ($blacklist !== false) { + error(\sprintf($config['error']['dnsbl'], $blacklist)); + } // Check if board exists if (!openBoard($_POST['board'])) @@ -661,7 +666,10 @@ if (isset($_POST['delete'])) { (!isset($_SERVER['HTTP_REFERER']) || !preg_match($config['referer_match'], rawurldecode($_SERVER['HTTP_REFERER'])))) error($config['error']['referer']); - checkDNSBL(); + $blacklist = $context->get(IpBlacklistService::class)->isIpBlacklisted($_SERVER['REMOTE_ADDR']); + if ($blacklist !== false) { + error(\sprintf($config['error']['dnsbl'], $blacklist)); + } if ($post['mod'] = isset($_POST['mod']) && $_POST['mod']) { diff --git a/templates/page.html b/templates/page.html index b6203a61..75cb4939 100644 --- a/templates/page.html +++ b/templates/page.html @@ -16,7 +16,13 @@

{{ title }}

{% if subtitle %} - {{ subtitle }} + {% if subtitle is iterable %} + {% for s in subtitle %} + {{ s }}{% if not loop.last %}
{% endif %} + {% endfor %} + {% else %} + {{ subtitle }} + {% endif %} {% endif %} {% if mod and not hide_dashboard_link %}

{% trans %}Return to dashboard{% endtrans %}

{% endif %}