diff --git a/docs/DataStructure.md b/docs/DataStructure.md index 9d29f8cae..e339d9322 100644 --- a/docs/DataStructure.md +++ b/docs/DataStructure.md @@ -308,6 +308,8 @@ Supported trigger types for conditional questions: | `multiple_unique` | `option_selected` | Radio buttons - the selected option | | `dropdown` | `option_selected` | Dropdown - the selected option | | `multiple` | `options_combination` | Checkboxes - all options of a combination | +| `short` | `string_equals`, `string_contains`, `regex` | Short text - string or regex matching | +| `long` | `string_contains`, `regex` | Long text - string or regex matching | | `linearscale` | `value_equals`, `value_not_equals`, `value_range`, `value_min`, `value_max` | Linear scale - numeric value matching | | `date` | `date_range` | Date - range including its bounds (`YYYY-MM-DD`) | | `datetime` | `date_range` | Deprecated date and time (`YYYY-MM-DD HH:mm`) | @@ -363,6 +365,26 @@ Conditions determine when a branch is activated. The structure depends on the tr All options in `optionIds` must be selected for the condition to match. +#### string_equals (for short) + +```json +{ "type": "string_equals", "value": "yes" } +``` + +#### string_contains (for short, long) + +```json +{ "type": "string_contains", "value": "keyword" } +``` + +#### regex (for short, long) + +```json +{ "type": "regex", "value": "/^yes/i" } +``` + +The expression uses the same format as `validationRegex`: enclosed in slashes, with the optional modifiers `i`, `m` and `s`. + #### value_equals, value_not_equals (for linearscale), value_equals (for color) ```json diff --git a/lib/Constants.php b/lib/Constants.php index 31690f596..aabae35bf 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -261,6 +261,9 @@ class Constants { */ public const CONDITION_TYPE_OPTION_SELECTED = 'option_selected'; public const CONDITION_TYPE_OPTIONS_COMBINATION = 'options_combination'; + public const CONDITION_TYPE_STRING_EQUALS = 'string_equals'; + public const CONDITION_TYPE_STRING_CONTAINS = 'string_contains'; + public const CONDITION_TYPE_REGEX = 'regex'; public const CONDITION_TYPE_VALUE_EQUALS = 'value_equals'; public const CONDITION_TYPE_VALUE_NOT_EQUALS = 'value_not_equals'; public const CONDITION_TYPE_VALUE_RANGE = 'value_range'; @@ -277,6 +280,8 @@ class Constants { self::ANSWER_TYPE_MULTIPLEUNIQUE => [self::CONDITION_TYPE_OPTION_SELECTED], self::ANSWER_TYPE_DROPDOWN => [self::CONDITION_TYPE_OPTION_SELECTED], self::ANSWER_TYPE_MULTIPLE => [self::CONDITION_TYPE_OPTIONS_COMBINATION], + self::ANSWER_TYPE_SHORT => [self::CONDITION_TYPE_STRING_EQUALS, self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], + self::ANSWER_TYPE_LONG => [self::CONDITION_TYPE_STRING_CONTAINS, self::CONDITION_TYPE_REGEX], self::ANSWER_TYPE_LINEARSCALE => [self::CONDITION_TYPE_VALUE_EQUALS, self::CONDITION_TYPE_VALUE_NOT_EQUALS, self::CONDITION_TYPE_VALUE_RANGE, self::CONDITION_TYPE_VALUE_MIN, self::CONDITION_TYPE_VALUE_MAX], self::ANSWER_TYPE_DATE => [self::CONDITION_TYPE_DATE_RANGE], self::ANSWER_TYPE_DATETIME => [self::CONDITION_TYPE_DATE_RANGE], diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 863aa132c..1e9ade3e2 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -965,6 +965,9 @@ private function areBranchConditionsValid(string $triggerType, array $conditions return false; } } + if (($condition['type'] ?? null) === Constants::CONDITION_TYPE_REGEX && !$this->isValidRegex((string)($condition['value'] ?? ''))) { + return false; + } } return true; } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 4f0b97618..f7f45436d 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -1159,6 +1159,33 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns } } return false; + case Constants::ANSWER_TYPE_SHORT: + case Constants::ANSWER_TYPE_LONG: + // Text-based: evaluate regex/string conditions + $text = is_scalar($triggerAnswer[0] ?? null) ? (string)$triggerAnswer[0] : ''; + foreach ($conditions as $condition) { + $type = $condition['type'] ?? Constants::CONDITION_TYPE_STRING_CONTAINS; + $value = $condition['value'] ?? ''; + + switch ($type) { + case Constants::CONDITION_TYPE_STRING_EQUALS: + if ($text === $value) { + return true; + } + break; + case Constants::CONDITION_TYPE_STRING_CONTAINS: + if (str_contains($text, $value)) { + return true; + } + break; + case Constants::CONDITION_TYPE_REGEX: + if ($this->safeRegexMatch($value, $text)) { + return true; + } + break; + } + } + return false; case Constants::ANSWER_TYPE_LINEARSCALE: $numValue = (float)($triggerAnswer[0] ?? 0); foreach ($conditions as $condition) { @@ -1240,4 +1267,33 @@ private function evaluateBranchConditions(string $triggerType, array $triggerAns return false; } } + + /** + * Safely execute a regex match with validation to prevent ReDoS attacks + * + * @param string $pattern The regex pattern to match + * @param string $subject The string to match against + * @return bool True if the pattern matches, false otherwise + */ + private function safeRegexMatch(string $pattern, string $subject): bool { + if (empty($pattern) || strlen($subject) > 10000) { + return false; + } + + // Validate regex syntax + if (@preg_match($pattern, '') === false) { + return false; + } + + // Set backtrack limit to prevent catastrophic backtracking + $previousLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.backtrack_limit', '10000'); + + try { + $result = @preg_match($pattern, $subject); + return $result === 1; + } finally { + ini_set('pcre.backtrack_limit', $previousLimit); + } + } } diff --git a/src/components/Conditional/BranchConditionEditor.vue b/src/components/Conditional/BranchConditionEditor.vue index e6ea37aa5..255f7f7e6 100644 --- a/src/components/Conditional/BranchConditionEditor.vue +++ b/src/components/Conditional/BranchConditionEditor.vue @@ -32,6 +32,33 @@ + + +