Merge pull request #933 from Zankaria/dep-inj-dns-2

Dependecy injected DNS
This commit is contained in:
Lorenzo Yario 2025-04-24 22:54:09 -05:00 committed by GitHub
commit 2a9f47105d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 399 additions and 122 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace Vichan\Data\Driver\Dns;
interface DnsDriver {
/**
* Resolve a domain name to 1 or more ips.
*
* @param string $name Domain name.
* @return ?array Returns an array of IPv4 and IPv6 addresses or null on error.
*/
public function nameToIPs(string $name): ?array;
/**
* Resolve an ip address to a domain name.
*
* @param string $ip Ip address.
* @return ?array Returns the domain names or null on error.
*/
public function IPToNames(string $ip): ?array;
}

View File

@ -0,0 +1,43 @@
<?php
namespace Vichan\Data\Driver\Dns;
/**
* Relies on the `host` command line executable.
*/
class HostDnsDriver implements DnsDriver {
private int $timeout;
private static function matchOrEmpty(string $pattern, string $subject): array {
$ret = \preg_match_all($pattern, $subject, $out);
if ($ret === false || $ret === 0) {
return [];
}
return $out[1];
}
public function __construct(int $timeout) {
$this->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);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Vichan\Data\Driver\Dns;
/**
* For the love of god never use this implementation if you can.
*/
class LibcDnsDriver implements DnsDriver {
public function __construct(int $timeout) {
// Try to impose a very frail timeout https://www.php.net/manual/en/function.gethostbyname.php#118841
\putenv("RES_OPTIONS=retrans:1 retry:1 timeout:{$timeout} attempts:1");
}
public function nameToIPs(string $name): ?array {
$ret = \dns_get_record($name, DNS_A | DNS_AAAA);
if ($ret === false) {
return null;
}
$ips = [];
foreach ($ret as $dns_record) {
if ($dns_record['type'] == 'A') {
$ips[] = $dns_record['ip'];
} elseif ($dns_record['type'] == 'AAAA') {
$ips[] = $dns_record['ipv6'];
}
}
if (empty($ips)) {
return [];
} else {
// Stable return order.
\sort($ips, \SORT_STRING);
return $ips;
}
}
/**
* For the love of god never use this.
* https://www.php.net/manual/en/function.gethostbyaddr.php#57553
*/
public function IPToNames(string $ip): ?array {
$ret = \gethostbyaddr($ip);
if ($ret === $ip || $ret === false) {
return null;
}
// Case extravaganza: https://www.php.net/manual/en/function.gethostbyaddr.php#123563
return [ \strtolower($ret) ];
}
}

View File

@ -3,6 +3,7 @@
namespace Vichan\Service; namespace Vichan\Service;
use Throwable; use Throwable;
use Vichan\Data\Driver\Dns\DnsDriver;
use Vichan\Data\Driver\LogDriver; use Vichan\Data\Driver\LogDriver;
/** /**
@ -24,6 +25,11 @@ class FilterService {
*/ */
private LogDriver $logger; private LogDriver $logger;
/**
* @var DnsDriver Dns driver for reverse DNS lookup.
*/
private DnsDriver $dns_resolver;
/** /**
* Filter service constructor * Filter service constructor
@ -31,11 +37,13 @@ class FilterService {
* @param array<int, array<string, mixed>> $filters The config filters. * @param array<int, array<string, mixed>> $filters The config filters.
* @param FloodService $floodService The FloodService. * @param FloodService $floodService The FloodService.
* @param LogDriver $logger The LogDriver. * @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->filters = $filters;
$this->floodService = $floodService; $this->floodService = $floodService;
$this->logger = $logger; $this->logger = $logger;
$this->dns_resolver = $dns_resolver;
} }
/** /**
@ -260,13 +268,17 @@ class FilterService {
return false; return false;
} }
$hostname = \rDNS($post['ip']); $hostnames = $this->dns_resolver->IPToNames($post['ip']);
if ($hostname === $post['ip']) { if ($hostnames === null) {
$this->logger->log(LogDriver::WARNING, "RDNS lookup failed for IP: {$post['ip']}");
return false; return false;
} }
return $this->checkRegex($value, $hostname, 'RDNS'); foreach ($hostnames as $name) {
if ($this->checkRegex($value, $name, 'RDNS')) {
return true;
}
}
return false;
case 'agent': case 'agent':
$this->validateType($value, 'array', 'Agent condition list'); $this->validateType($value, 'array', 'Agent condition list');
return $this->matchAgentCondition($value); return $this->matchAgentCondition($value);

View File

@ -0,0 +1,215 @@
<?php
namespace Vichan\Service;
use Vichan\Data\Driver\CacheDriver;
use Vichan\Data\Driver\Dns\DnsDriver;
use Lifo\IP\IP;
class IpBlacklistService {
private const DNS_CACHE_TIMEOUT = 3600; // 1 hour.
private DnsDriver $resolver;
private CacheDriver $cache;
private array $blacklist_providers;
private array $exceptions;
private bool $rdns_validate;
private static function buildDnsCacheKey(string $host) {
return 'dns_queries_dns_' . \strtolower($host);
}
private static function buildRDnsCacheKey(string $ip) {
return "dns_queries_rdns_$ip";
}
private static function reverseIpv4Octets(string $ip): ?string {
$ret = \filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4);
if ($ret === false) {
return null;
}
return \implode('.', \array_reverse(\explode('.', $ip)));
}
private static function reverseIpv6Octets(string $ip): ?string {
$ret = \filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6);
if ($ret === false) {
return null;
}
return \strrev(\implode(".", \str_split(\str_replace(':', '', IP::inet_expand($ip)))));
}
/**
* Builds the name/host to resolve to discover if an ip is the host via DNS blacklists.
*/
private static function buildEndpoint(string $host, string $ip) {
$replaced = 0;
// See inc/config.php for the meaning of '%'.
$lookup = \str_replace('%', $ip, $host, $replaced);
if ($replaced !== 0) {
return $lookup;
}
return "$ip.$host";
}
private static function filterIp(string $str): string|false {
return \filter_var($str, \FILTER_VALIDATE_IP);
}
private function isIpWhitelisted(string $ip): bool {
if (\in_array($ip, $this->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;
}
}

View File

@ -3,10 +3,12 @@ namespace Vichan;
use Vichan\Controller\FloodManager; use Vichan\Controller\FloodManager;
use Vichan\Data\Driver\{CacheDriver, HttpDriver, ErrorLogLogDriver, FileLogDriver, LogDriver, StderrLogDriver, SyslogLogDriver}; 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\Data\{FloodQueries, IpNoteQueries, UserPostQueries, ReportQueries};
use Vichan\Service\FilterService; use Vichan\Service\FilterService;
use Vichan\Service\FloodService; use Vichan\Service\FloodService;
use Vichan\Service\HCaptchaQuery; use Vichan\Service\HCaptchaQuery;
use Vichan\Service\IpBlacklistService;
use Vichan\Service\SecureImageCaptchaQuery; use Vichan\Service\SecureImageCaptchaQuery;
use Vichan\Service\ReCaptchaQuery; use Vichan\Service\ReCaptchaQuery;
use Vichan\Service\YandexCaptchaQuery; use Vichan\Service\YandexCaptchaQuery;
@ -71,6 +73,14 @@ function build_context(array $config): Context {
); );
}, },
CacheDriver::class => fn(): CacheDriver => \Cache::getCache(), 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 { \PDO::class => function(): \PDO {
global $pdo; global $pdo;
// Ensure the PDO is initialized. // Ensure the PDO is initialized.
@ -99,7 +109,8 @@ function build_context(array $config): Context {
FilterService::class => fn(Context $c): FilterService => new FilterService( FilterService::class => fn(Context $c): FilterService => new FilterService(
$c->get('config')['filters'], $c->get('config')['filters'],
$c->get(FloodService::class), $c->get(FloodService::class),
$c->get(LogDriver::class) $c->get(LogDriver::class),
$c->get(DnsDriver::class)
), ),
FloodManager::class => fn(Context $c): FloodManager => new FloodManager( FloodManager::class => fn(Context $c): FloodManager => new FloodManager(
$c->get(FilterService::class), $c->get(FilterService::class),
@ -107,6 +118,16 @@ function build_context(array $config): Context {
$c->get(IpNoteQueries::class), $c->get(IpNoteQueries::class),
$c->get(LogDriver::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']
);
}
]); ]);
} }

View File

@ -1767,61 +1767,6 @@ function buildJavascript() {
file_write($config['file_script'], $script); 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) { function wordfilters(&$body) {
global $config; 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) { function shell_exec_error($command, $suppress_stdout = false) {
global $config, $debug; global $config, $debug;

View File

@ -6,6 +6,7 @@ use Vichan\Context;
use Vichan\Data\{IpNoteQueries, UserPostQueries, ReportQueries}; use Vichan\Data\{IpNoteQueries, UserPostQueries, ReportQueries};
use Vichan\Functions\{Format, Net}; use Vichan\Functions\{Format, Net};
use Vichan\Data\Driver\{CacheDriver, LogDriver}; use Vichan\Data\Driver\{CacheDriver, LogDriver};
use Vichan\Data\Driver\Dns\DnsDriver;
defined('TINYBOARD') or exit; 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'])) { 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'])) { if (hasPermission($config['mod']['view_ban'])) {

View File

@ -5,10 +5,9 @@
require_once 'inc/bootstrap.php'; require_once 'inc/bootstrap.php';
use Vichan\{Context, WebDependencyFactory};
use Vichan\Data\Driver\{LogDriver, HttpDriver}; use Vichan\Data\Driver\{LogDriver, HttpDriver};
use Vichan\Data\ReportQueries; use Vichan\Data\ReportQueries;
use Vichan\Service\{RemoteCaptchaQuery, SecureImageCaptchaQuery}; use Vichan\Service\{IpBlacklistService, RemoteCaptchaQuery, SecureImageCaptchaQuery};
use Vichan\Functions\{Format, IP}; 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 // Check if board exists
if (!openBoard($_POST['board'])) 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 // Check if board exists
if (!openBoard($_POST['board'])) 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'])))) (!isset($_SERVER['HTTP_REFERER']) || !preg_match($config['referer_match'], rawurldecode($_SERVER['HTTP_REFERER']))))
error($config['error']['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']) { if ($post['mod'] = isset($_POST['mod']) && $_POST['mod']) {

View File

@ -16,7 +16,13 @@
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<div class="subtitle"> <div class="subtitle">
{% if subtitle %} {% if subtitle %}
{{ subtitle }} {% if subtitle is iterable %}
{% for s in subtitle %}
{{ s }}{% if not loop.last %}<br>{% endif %}
{% endfor %}
{% else %}
{{ subtitle }}
{% endif %}
{% endif %} {% endif %}
{% if mod and not hide_dashboard_link %}<p><a href="?/">{% trans %}Return to dashboard{% endtrans %}</a></p>{% endif %} {% if mod and not hide_dashboard_link %}<p><a href="?/">{% trans %}Return to dashboard{% endtrans %}</a></p>{% endif %}
</div> </div>