diff --git a/ci/apiv2/test_config.py b/ci/apiv2/test_config.py index 2ba1408ed..2f2ce08ae 100644 --- a/ci/apiv2/test_config.py +++ b/ci/apiv2/test_config.py @@ -1,6 +1,8 @@ from hashtopolis import Config +from hashtopolis import HashtopolisError from utils import BaseTest + class ConfigTest(BaseTest): model_class = Config @@ -25,7 +27,7 @@ def test_patch_many(self): newConfigs = Config.objects.filter(configId__lte='9') for new_config, new_attribute in zip(newConfigs, attributes_to_change): - self.assertEqual(new_config.value, new_attribute) + self.assertEqual(new_config.value, new_attribute) def test_expandables(self): model_obj = Config.objects.get(pk=1) @@ -45,4 +47,47 @@ def test_blacklist_chars(self): config.save() obj = Config.objects.get(item='blacklistChars') - self.assertEqual(obj.value, tmp_value) \ No newline at end of file + self.assertEqual(obj.value, tmp_value) + + def test_bounds_are_exposed_via_aggregate(self): + default_config = Config.objects.get(item='hashcatBrainPort') + self.assertFalse(hasattr(default_config, 'valueBoundaries')) + + numeric_config = Config.objects.params(**{"aggregate[config]": "valueBoundaries"}).get(item='hashcatBrainPort') + self.assertTrue(hasattr(numeric_config, 'valueBoundaries')) + self.assertEqual(numeric_config.valueBoundaries['min'], 1) + self.assertEqual(numeric_config.valueBoundaries['max'], 65535) + + field_separator = Config.objects.params(**{"aggregate[config]": "valueBoundaries"}).get(item='fieldseparator') + self.assertEqual(field_separator.valueBoundaries['maxLength'], 1) + + tickbox_config = Config.objects.params( + **{"aggregate[config]": "valueBoundaries"} + ).get(item='multicastTransferRateEnable') + self.assertEqual(tickbox_config.valueBoundaries['binaryValues'], ['0', '1']) + + def test_numeric_bounds_are_validated(self): + config = Config.objects.get(item='hashcatBrainPort') + original_value = config.value + + try: + config.value = '70000' + with self.assertRaises(HashtopolisError) as e: + config.save() + self.assertIn('at most 65535', e.exception.title) + finally: + config.value = original_value + config.save() + + def test_field_separator_max_length_is_validated(self): + config = Config.objects.get(item='fieldseparator') + original_value = config.value + + try: + config.value = '::' + with self.assertRaises(HashtopolisError) as e: + config.save() + self.assertIn('at most 1', e.exception.title) + finally: + config.value = original_value + config.save() diff --git a/ci/apiv2/test_global_config_helper.py b/ci/apiv2/test_global_config_helper.py index 66c557606..3514193ce 100644 --- a/ci/apiv2/test_global_config_helper.py +++ b/ci/apiv2/test_global_config_helper.py @@ -46,3 +46,16 @@ def test_consistent_across_calls(self): result1 = Helper().get_global_config() result2 = Helper().get_global_config() self.assertEqual(result1, result2) + + def test_bounds_are_exposed(self): + configs = Helper().get_global_config() + + port_config = next(c for c in configs if c.item == 'hashcatBrainPort') + self.assertEqual(port_config.min, 1) + self.assertEqual(port_config.max, 65535) + + field_separator = next(c for c in configs if c.item == 'fieldseparator') + self.assertEqual(field_separator.maxLength, 1) + + tickbox_config = next(c for c in configs if c.item == 'multicastTransferRateEnable') + self.assertEqual(tickbox_config.binaryValues, ['0', '1']) diff --git a/src/inc/apiv2/helper/GetGlobalConfigHelperAPI.php b/src/inc/apiv2/helper/GetGlobalConfigHelperAPI.php index 17ac6ed9a..9e4ab93aa 100644 --- a/src/inc/apiv2/helper/GetGlobalConfigHelperAPI.php +++ b/src/inc/apiv2/helper/GetGlobalConfigHelperAPI.php @@ -9,6 +9,7 @@ use Hashtopolis\inc\apiv2\error\HttpError; use Hashtopolis\inc\apiv2\error\HttpForbidden; use Hashtopolis\inc\HTException; +use Hashtopolis\inc\utils\ConfigUtils; use JsonException; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -88,5 +89,14 @@ static public function register($app): void { public static function getResponse(): string { return "Config"; } + + protected function filterData(array $object): array { + $item = $object['item'] ?? null; + if (!is_string($item) || $item === '') { + return $object; + } + + return array_merge($object, ConfigUtils::getConfigValueBounds($item)); + } } diff --git a/src/inc/apiv2/model/ConfigAPI.php b/src/inc/apiv2/model/ConfigAPI.php index 5cb85f330..78e9ef667 100644 --- a/src/inc/apiv2/model/ConfigAPI.php +++ b/src/inc/apiv2/model/ConfigAPI.php @@ -66,4 +66,20 @@ protected function updateObject(int $objectId, array $data): void { protected function updateObjects(array $objects): void { ConfigUtils::updateConfigs($objects); } + + public function getAggregateFieldsets(): array { + return [ + 'config' => [ + 'valueBoundaries' => [$this, 'getAggregateValueBoundaries'], + ] + ]; + } + + protected function getAggregateValueBoundaries(AbstractModel $object): ?array { + if (!($object instanceof Config) || !is_string($object->getItem()) || $object->getItem() === '') { + return null; + } + + return ConfigUtils::getConfigValueBounds($object->getItem()); + } } diff --git a/src/inc/utils/ConfigUtils.php b/src/inc/utils/ConfigUtils.php index f0bfb602c..84374bdb4 100644 --- a/src/inc/utils/ConfigUtils.php +++ b/src/inc/utils/ConfigUtils.php @@ -16,6 +16,7 @@ use Hashtopolis\dba\models\ConfigSection; use Hashtopolis\dba\Factory; use Hashtopolis\inc\defines\DConfig; +use Hashtopolis\inc\defines\DConfigType; use Hashtopolis\inc\defines\DDirectories; use Hashtopolis\inc\defines\DHashlistFormat; use Hashtopolis\inc\defines\DLogEntry; @@ -26,6 +27,112 @@ use Hashtopolis\inc\Util; class ConfigUtils { + const DEFAULT_NUMBER_MIN = 0; + const DEFAULT_NUMBER_MAX = 999999999; + const DEFAULT_STRING_MAX_LENGTH = 65535; + + /** + * @return array + */ + public static function getConfigValueBounds(string $item): array { + $type = DConfig::getConfigType($item); + $bounds = []; + + if ($type === DConfigType::NUMBER_INPUT) { + $bounds = [ + 'min' => self::DEFAULT_NUMBER_MIN, + 'max' => self::DEFAULT_NUMBER_MAX, + ]; + } + else if ($type === DConfigType::TICKBOX) { + $bounds = [ + 'binaryValues' => ['0', '1'], + ]; + } + else if ($type === DConfigType::STRING_INPUT || $type === DConfigType::EMAIL || $type === DConfigType::SELECT) { + $bounds = [ + 'maxLength' => self::DEFAULT_STRING_MAX_LENGTH, + ]; + } + + $itemBounds = match ($item) { + DConfig::HASHCAT_BRAIN_PORT, + DConfig::NOTIFICATIONS_PROXY_PORT => ['min' => 1, 'max' => 65535], + DConfig::DISP_TOLERANCE, + DConfig::AGENT_UTIL_THRESHOLD_1, + DConfig::AGENT_UTIL_THRESHOLD_2 => ['min' => 0, 'max' => 100], + DConfig::HASHES_PAGE_SIZE, + DConfig::HASHES_PER_PAGE, + DConfig::DEFAULT_PAGE_SIZE, + DConfig::MAX_PAGE_SIZE => ['min' => 1], + DConfig::EMAIL_SENDER, + DConfig::CONTACT_EMAIL => ['maxLength' => 320], + DConfig::FIELD_SEPARATOR => ['maxLength' => 1], + default => [], + }; + + return array_merge($bounds, $itemBounds); + } + + /** + * @throws HTException + */ + public static function validateAndNormalizeConfigValue(string $item, mixed $value): string { + if (is_null($value)) { + throw new HTException("No new config value provided"); + } + + $type = DConfig::getConfigType($item); + $bounds = self::getConfigValueBounds($item); + + if ($type === DConfigType::TICKBOX) { + if (in_array($value, [true, 1, '1', 'true'], true)) { + return '1'; + } + if (in_array($value, [false, 0, '0', 'false', ''], true)) { + return '0'; + } + throw new HTException("Value most be boolean!"); + } + + if ($type === DConfigType::NUMBER_INPUT) { + if (!is_numeric($value)) { + throw new HTException("Value must be numeric!"); + } + + $numericValue = $value + 0; + if (isset($bounds['min']) && $numericValue < $bounds['min']) { + throw new HTException("Value must be at least " . $bounds['min'] . "!"); + } + if (isset($bounds['max']) && $numericValue > $bounds['max']) { + throw new HTException("Value must be at most " . $bounds['max'] . "!"); + } + + return (string)$value; + } + + if (!is_scalar($value)) { + throw new HTException("Value must be scalar!"); + } + $normalizedValue = (string)$value; + + if (isset($bounds['maxLength']) && strlen($normalizedValue) > $bounds['maxLength']) { + throw new HTException("Value length must be at most " . $bounds['maxLength'] . "!"); + } + + if ($type === DConfigType::EMAIL && !filter_var($normalizedValue, FILTER_VALIDATE_EMAIL)) { + throw new HTException("Value must be email!"); + } + + if ($type === DConfigType::SELECT) { + if (!in_array($normalizedValue, DConfig::getSelection($item)->getKeys(), true)) { + throw new HTException("Value is not in selection!"); + } + } + + return $normalizedValue; + } + /** * @param Config $config * @param boolean $new @@ -92,13 +199,10 @@ public static function updateSingleConfig($id, $attributes): void { if (is_null($currentConfig)) { throw new HTException("No config with this ID!"); } - $newValue = $attributes[Config::VALUE] ?? null; $name = $currentConfig->getItem(); - - if (is_null($newValue)) { - throw new HTException("No new config value provided"); - } - if ($currentConfig->getValue() === $newValue) { + + $newValue = self::validateAndNormalizeConfigValue($name, $attributes[Config::VALUE] ?? null); + if ((string)$currentConfig->getValue() === $newValue) { return; //The value was not changed so we don't need to update it. } @@ -143,31 +247,32 @@ public static function updateConfig(array $arr): void { foreach ($arr as $item => $val) { if (str_starts_with($item, "config_")) { $name = substr($item, 7); - if (SConfig::getInstance()->getVal($name) == $val) { + $newValue = self::validateAndNormalizeConfigValue($name, $val); + if ((string)SConfig::getInstance()->getVal($name) === $newValue) { continue; // the value was not changed, so we don't need to update it } $qF = new QueryFilter(Config::ITEM, $name, "="); $config = Factory::getConfigFactory()->filter([Factory::FILTER => $qF], true); if ($config == null) { - $config = new Config(null, self::DEFAULT_CONFIG_SECTION, $name, $val); + $config = new Config(null, self::DEFAULT_CONFIG_SECTION, $name, $newValue); Factory::getConfigFactory()->save($config); } else { if ($name == DConfig::HASH_MAX_LENGTH) { - $limit = intval($val); + $limit = intval($newValue); if (!Util::setMaxHashLength($limit)) { throw new HTException("Failed to update max hash length!"); } } else if ($name == DConfig::PLAINTEXT_MAX_LENGTH) { - $limit = intval($val); + $limit = intval($newValue); if (!Util::setPlaintextMaxLength($limit)) { throw new HTException("Failed to update max plaintext length!"); } } - SConfig::getInstance()->addValue($name, $val); - $config->setValue($val); + SConfig::getInstance()->addValue($name, $newValue); + $config->setValue($newValue); ConfigUtils::set($config, false); } }