From aefb1b6440380d6107097083185456b080c34a86 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Tue, 26 May 2026 13:43:44 +0200 Subject: [PATCH] added free text input with security measures --- api/app_questionnaires.php | 4 ++ common.php | 2 + handlers/app_questionnaires.php | 26 +++++++- import_questionnaires.php | 11 ++++ lib/text_sanitize.php | 61 +++++++++++++++++++ website/js/pages/editor.js | 31 ++++++++++ .../temp-assets/questionnaire_0_readme.json | 11 ++++ 7 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 lib/text_sanitize.php diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index 8af1c6f..aa1d3d3 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -77,6 +77,10 @@ if ($qnID) { if (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; + if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; + if (!empty($config['multiline'])) $q['multiline'] = true; + if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; + if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; // String spinner static options if ($layout === 'string_spinner' && isset($config['options'])) { diff --git a/common.php b/common.php index 1a3b33a..9b05818 100644 --- a/common.php +++ b/common.php @@ -2,6 +2,8 @@ // /var/www/html/common.php // Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt). +require_once __DIR__ . '/lib/text_sanitize.php'; + /** * Load KEY=value pairs from .env in the project root. * Does not override variables already set in the real environment. diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index c764f30..9915f46 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -54,12 +54,20 @@ if ($method === 'POST') { : ($clientRow['coachID'] ?? ''); // Load all questions for this questionnaire: full questionID keyed by short ID - $qRows = $pdo->prepare("SELECT questionID FROM question WHERE questionnaireID = :qn"); + $qRows = $pdo->prepare( + "SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn" + ); $qRows->execute([':qn' => $qnID]); - $shortIdMap = []; // shortId -> fullQuestionID - foreach ($qRows->fetchAll(PDO::FETCH_COLUMN) as $fullId) { + $shortIdMap = []; // shortId -> fullQuestionID + $freeTextMaxLen = []; // fullQuestionID -> maxLength + foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { + $fullId = $qRow['questionID']; $parts = explode('__', $fullId); $shortIdMap[end($parts)] = $fullId; + if (($qRow['type'] ?? '') === 'free_text') { + $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; + $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); + } } // Load all answer options for this questionnaire: keyed by [questionID][defaultText] @@ -104,6 +112,14 @@ if ($method === 'POST') { $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null; $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; + if ($freeTextValue !== null && $freeTextValue !== '') { + $maxLen = $freeTextMaxLen[$fullQID] ?? 2000; + $freeTextValue = sanitize_free_text($freeTextValue, $maxLen); + if ($freeTextValue === '') { + continue; + } + } + // Resolve option key to answerOptionID and accumulate points $optionKey = $a['answerOptionKey'] ?? null; if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { @@ -252,6 +268,10 @@ if ($qnID) { if (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; + if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; + if (!empty($config['multiline'])) $q['multiline'] = true; + if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; + if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if ($layout === 'string_spinner' && isset($config['options'])) { $q['options'] = $config['options']; diff --git a/import_questionnaires.php b/import_questionnaires.php index 8cce9f6..3050999 100644 --- a/import_questionnaires.php +++ b/import_questionnaires.php @@ -86,6 +86,17 @@ try { if (isset($q['range'])) $config['range'] = $q['range']; if (isset($q['constraints'])) $config['constraints'] = $q['constraints']; if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection']; + if (isset($q['maxLength'])) $config['maxLength'] = (int)$q['maxLength']; + if (!empty($q['multiline'])) $config['multiline'] = true; + if (isset($q['otherNextQuestionId'])) $config['otherNextQuestionId'] = $q['otherNextQuestionId']; + if (isset($q['otherOptionKey'])) $config['otherOptionKey'] = $q['otherOptionKey']; + if (isset($q['config']) && is_array($q['config'])) { + foreach (['otherNextQuestionId', 'otherOptionKey', 'hint', 'maxLength', 'multiline', 'textKey'] as $ck) { + if (isset($q['config'][$ck])) { + $config[$ck] = $q['config'][$ck]; + } + } + } if ($layout === 'string_spinner' && isset($q['options']) && is_array($q['options']) && isset($q['options'][0]) && is_string($q['options'][0])) { diff --git a/lib/text_sanitize.php b/lib/text_sanitize.php new file mode 100644 index 0000000..009859c --- /dev/null +++ b/lib/text_sanitize.php @@ -0,0 +1,61 @@ +${(config.options || []).join('\n')} `; break; + case 'free_text': + html = ` +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
`; + break; case 'client_coach_code_question': html = `
@@ -249,6 +272,14 @@ function readConfigFromForm(layout, prefix) { if (opts.length) config.options = opts; break; } + case 'free_text': { + if (val('textKey')) config.textKey = val('textKey'); + if (val('hint')) config.hint = val('hint'); + const ml = parseInt(val('maxLength') || '500', 10); + if (!Number.isNaN(ml) && ml > 0) config.maxLength = Math.min(ml, 10000); + if (document.getElementById(`${prefix}_multiline`)?.checked) config.multiline = true; + break; + } case 'client_coach_code_question': if (val('hint1')) config.hint1 = val('hint1'); if (val('hint2')) config.hint2 = val('hint2'); diff --git a/website/temp-assets/questionnaire_0_readme.json b/website/temp-assets/questionnaire_0_readme.json index 6716798..2278137 100644 --- a/website/temp-assets/questionnaire_0_readme.json +++ b/website/temp-assets/questionnaire_0_readme.json @@ -43,6 +43,17 @@ }, + { + "id": "q3b", + "_comment": "Freitext-Eingabe (einzeilig oder mehrzeilig). maxLength und hint sind optional. Eingaben werden gegen SQL-Injection-Muster gefiltert.", + "layout": "free_text", + "question": "other_accommodation", + "hint": "enter_text_to_continue", + "maxLength": 500, + "multiline": true + }, + + { "id": "q4", "_comment": "Frage mit Spinner zur Auswahl des Datums",