<?php
/**
 * @package   admintools
 * @copyright Copyright (c)2010-2026 Nicholas K. Dionysopoulos / Akeeba Ltd
 * @license   GNU General Public License version 3, or later
 */

namespace Akeeba\Plugin\System\AdminTools\Feature;

defined('_JEXEC') || die;

use Akeeba\Plugin\System\AdminTools\Utility\Cache;

class WAFDenyList extends Base
{
	/**
	 * Is this feature enabled?
	 *
	 * @return bool
	 */
	public function isEnabled()
	{
		return true;
	}

	public function onAfterApiRoute(): void
	{
		$this->onAfterRoute();
	}

	/**
	 * Filters visitor access using WAF blacklist rules
	 */
	public function onAfterRoute(): void
	{
		// Get the option, view, controller and task for the request
		$method     = strtoupper($this->input->server->getCmd('REQUEST_METHOD', 'GET'));
		$controller = $this->input->getCmd('controller', '');
		$view       = $this->input->getCmd('view', '');
		$task       = $this->input->getCmd('task', '');
		$option     = $this->input->getCmd('option', '');
		$client     = strtolower($this->app->getName());

		if (strpos($task, '.') !== false)
		{
			// NB! The controller.task convention always overrides an explicit controller query string parameter.
			[$controller, $task] = explode('.', $task);
		}

		// Load all rules which match the current HTTP method, application, component, controller, view and task.
		$rules = array_filter(Cache::getCache('wafblacklists'),
			function ($rule) use ($method, $client, $controller, $view, $task, $option) {
				$controllerFilter = '';

				if (strpos($rule['task'], '.') !== false)
				{
					[$controllerFilter, $rule['task']] = explode('.', $rule['task']);
				}

				if (!in_array($rule['application'], ['both', '', $client]))
				{
					return false;
				}

				if (!in_array($rule['verb'], ['', $method]))
				{
					return false;
				}

				if (!in_array($rule['option'], ['', '*', $option]))
				{
					return false;
				}

				if (!in_array($controllerFilter, ['', $controller]))
				{
					return false;
				}

				if (!in_array($rule['view'], ['', '*', empty($controllerFilter) ? $controller : '', $view]))
				{
					return false;
				}

				if (!in_array($rule['task'], ['', '*', $task]))
				{
					return false;
				}

				return true;
			});

		// Don't block anything if there are no matching rules.
		if (empty($rules))
		{
			return;
		}

		// Ok, let's analyze all the matching rules
		$block = array_reduce($rules, function ($alreadyBlocked, $rule) {
			/**
			 * If a previous rule already blocks this request return true immediately.
			 *
			 * If the query of this rule is empty then we're supposed to block the access to this component, controller,
			 * view and task regardless of any request parameters. We return an immediate true value in this case.
			 */
			if ($alreadyBlocked || empty($rule['query']))
			{
				return true;
			}

			/**
			 * I need to address each source individually. The main input object pulls from $_REQUEST which might draw data
			 * from cookies. Moreover, $_REQUEST can "shade" content. If the input order is EGPCS the cookies shadow the
			 * POST parameters which shadow the GET parameters. Depending on the component, it might be using data from a
			 * specific source, e.g. GET. In this case a wiley attacker will send a POST request with an innocuous POST
			 * parameter and a malicious GET parameter. If we used $_REQUEST we'd see the innocuous content because of the
			 * "shading" but the component would use the malicious content and get compromised.
			 */
			foreach (['get', 'post'] as $inputSource)
			{
				$inputObject = $this->input->{$inputSource};

				foreach ($inputObject->getArray() as $key => $value)
				{
					if ($this->isBlockedByRule((object) $rule, $key, $value))
					{
						return true;
					}
				}
			}

			return false;
		}, false);

		if (!$block)
		{
			return;
		}

		$extraInfo = '';

		// If the rule matched any variable, let's print the variables that caused the block, so we can inspect later
		if (isset($inputSource) && isset($inputObject))
		{
			// PLEASE NOTE! If POST data is passed, but the GET array is empty, Input will use the whole $_REQUEST
			// array, so $inputSource will be GET even if we truly had a POST request. However this is an edge case
			$extraInfo = "Hash      : " . strtoupper($inputSource) . "\n";
			$extraInfo .= "Variables :\n";
			$extraInfo .= print_r($inputObject->getArray(), true);
			$extraInfo .= "\n";
		}

		$this->exceptionsHandler->blockRequest('wafblacklist', null, $extraInfo);
	}

	private function isBlockedByRule(object $rule, string $key, $value, string $prefix = ''): bool
	{
		// Handle array values
		if (is_array($value))
		{
			foreach ($value as $subKey => $subValue)
			{
				$newPrefix = empty($prefix) ? $key : sprintf("%s[%s]", $prefix, $key);

				if ($this->isBlockedByRule($rule, $subKey, $subValue, $newPrefix))
				{
					return true;
				}
			}

			return false;
		}

		$key       = empty($prefix) ? $key : sprintf("%s[%s]", $prefix, $key);
		$ruleQuery = $rule->query;

		switch (strtoupper($rule->query_type))
		{
			// Partial match
			case 'P':
				$found = stripos($key, $ruleQuery) !== false;
				break;

			// RegEx match
			case 'R':
				$regex  = $ruleQuery;
				$negate = false;

				if (substr($regex, 0, 1) == '!')
				{
					$negate = true;
					$regex  = substr($regex, 1);
				}

				$found = @preg_match($regex, $key) > 0;
				$found = $negate ? !$found : $found;
				break;

			// Exact match
			default:
				// Empty rule query: always matches. Empty key: never matches. Else: exact match of the key.
				$found = empty($ruleQuery) || (!empty($key) && ($key == $ruleQuery));
				break;
		}

		if (!$found)
		{
			return false;
		}

		// Empty content rule => always block, no matter what
		if (!$rule->query_content)
		{
			return true;
		}

		// The content rule is non-empty, therefore it's a regular expression.
		$negate = false;
		$regex  = $rule->query_content;

		if (substr($regex, 0, 1) == '!')
		{
			$negate = true;
			$regex  = substr($regex, 1);
		}

		$isFiltered = (@preg_match($regex, $value) ?: 0) >= 1;

		if ($negate)
		{
			$isFiltered = !$isFiltered;
		}

		return $isFiltered;
	}
}
