Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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],
Expand Down
3 changes: 3 additions & 0 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
}
95 changes: 95 additions & 0 deletions src/components/Conditional/BranchConditionEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@
</div>
</template>

<!-- Text-based conditions (short, long) -->
<template v-else-if="isTextBasedTrigger">
<div class="condition-row">
<NcSelect
v-model="conditionType"
:options="textConditionTypes"
:placeholder="t('forms', 'Condition type')"
:ariaLabelCombobox="t('forms', 'Condition type')"
label="label"
:reduce="(opt) => opt.value"
class="condition-type-select" />
<NcTextField
v-model="textConditionValue"
:label="conditionValuePlaceholder"
:error="isRegexInvalid"
:helperText="
isRegexInvalid
? t(
'forms',
'Invalid regular expression, use the format /pattern/',
)
: ''
"
class="condition-value-input" />
</div>
</template>

<!-- Value-based conditions (linearscale, color) -->
<template v-else-if="isValueBasedTrigger">
<div class="condition-row">
Expand Down Expand Up @@ -147,6 +174,7 @@ import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { validateExpression } from '../../utils/RegularExpression.ts'

export default {
name: 'BranchConditionEditor',
Expand Down Expand Up @@ -188,6 +216,13 @@ export default {

emits: ['update:branch'],

data() {
return {
// Text being typed, an invalid regular expression is not saved
textDraft: null,
}
},

computed: {
/**
* Check if trigger is option-based (radio, dropdown, checkbox)
Expand All @@ -205,6 +240,13 @@ export default {
return ['multiple_unique', 'dropdown'].includes(this.triggerType)
},

/**
* Check if trigger is text-based (short, long)
*/
isTextBasedTrigger() {
return ['short', 'long'].includes(this.triggerType)
},

/**
* Check if trigger is value-based (linearscale, color)
*/
Expand Down Expand Up @@ -262,6 +304,22 @@ export default {
return this.isSingleSelect ? selected[0] || null : selected
},

/**
* Text condition type options
*/
textConditionTypes() {
const types = [
{ value: 'string_equals', label: t('forms', 'Equals') },
{ value: 'string_contains', label: t('forms', 'Contains') },
{ value: 'regex', label: t('forms', 'Matches regex') },
]
// Long text doesn't support string_equals
if (this.triggerType === 'long') {
return types.filter((t) => t.value !== 'string_equals')
}
return types
},

/**
* Value condition type options (for linear scale)
*/
Expand Down Expand Up @@ -292,6 +350,7 @@ export default {
* Default condition type based on trigger
*/
defaultConditionType() {
if (this.isTextBasedTrigger) return 'string_contains'
if (this.triggerType === 'linearscale') return 'value_equals'
if (this.isDateBasedTrigger) return 'date_range'
return null
Expand All @@ -310,6 +369,29 @@ export default {
},
},

/**
* Value of a text condition, regular expressions are only saved when valid
*/
textConditionValue: {
get() {
return this.textDraft ?? this.conditionValue
},

set(value) {
this.textDraft = value
if (this.conditionType !== 'regex' || validateExpression(value)) {
this.conditionValue = value
}
},
},

isRegexInvalid() {
return (
this.conditionType === 'regex'
&& !validateExpression(this.textConditionValue)
)
},

/**
* Storage format of date and time conditions, the same as for answers
*/
Expand Down Expand Up @@ -370,6 +452,19 @@ export default {
return this.branch.conditions?.[0]?.fileUploaded ?? true
},

/**
* Placeholder for condition value input
*/
conditionValuePlaceholder() {
if (this.conditionType === 'regex') {
return t('forms', 'Regular expression, e.g. /^yes$/i')
}
if (this.conditionType === 'string_equals') {
return t('forms', 'Exact text to match')
}
return t('forms', 'Text to search for')
},

/**
* Date picker type based on trigger type
*/
Expand Down
44 changes: 44 additions & 0 deletions src/components/Questions/QuestionConditional.vue
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ import {
import answerTypes from '../../models/AnswerTypes.ts'
import logger from '../../utils/Logger.ts'
import OcsResponse2Data from '../../utils/OcsResponse2Data.ts'
import { splitRegex } from '../../utils/RegularExpression.ts'

// Types of subquestions, conditional questions can not be nested
const SUBQUESTION_TYPES = [
Expand Down Expand Up @@ -411,6 +412,10 @@ export default defineComponent({
label: t('forms', 'Choice'),
types: ['multiple_unique', 'multiple', 'dropdown'],
},
{
label: t('forms', 'Text'),
types: ['short', 'long'],
},
{
label: t('forms', 'Date and time'),
types: ['date', 'time'],
Expand Down Expand Up @@ -975,6 +980,22 @@ export default defineComponent({
return false
}

if (['short', 'long'].includes(this.triggerType)) {
const text = this.triggerValues[0] || ''
return branch.conditions.some((c) => {
if (c.type === 'regex') {
return this.safeRegexMatch(c.value, text)
}
if (c.type === 'string_contains') {
return text.includes(c.value || '')
}
if (c.type === 'string_equals') {
return text === c.value
}
return false
})
}

if (this.triggerType === 'linearscale') {
const numValue = parseFloat(this.triggerValues[0]) || 0
return branch.conditions.some((c) => {
Expand Down Expand Up @@ -1033,6 +1054,29 @@ export default defineComponent({
return false
},

/**
* Safely execute a regex match to prevent ReDoS attacks
*
* @param {string} pattern The regular expression with delimiters, e.g. `/^yes$/i`
* @param {string} subject The string to match against
* @return {boolean} True if pattern matches
*/
safeRegexMatch(pattern, subject) {
if (!pattern || subject.length > 10000) {
return false
}
// Same format as the server: `/pattern/modifiers`
const { pattern: source, modifiers } = splitRegex(pattern)
if (!source) {
return false
}
try {
return new RegExp(source, modifiers).test(subject)
} catch {
return false
}
},

async validate() {
if (this.$refs.triggerQuestion?.validate) {
const triggerValid = await this.$refs.triggerQuestion.validate()
Expand Down
Loading
Loading