added free text input with security measures

This commit is contained in:
2026-05-26 13:43:44 +02:00
parent 244fd16140
commit aefb1b6440
7 changed files with 143 additions and 3 deletions

View File

@ -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'])) {

View File

@ -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.

View File

@ -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'];

View File

@ -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])) {

61
lib/text_sanitize.php Normal file
View File

@ -0,0 +1,61 @@
<?php
/**
* Sanitize user-entered free text before storage (defense in depth; queries use prepared statements).
*/
function sanitize_free_text(mixed $value, int $maxLength = 2000): string {
if (!is_string($value) && !is_numeric($value)) {
return '';
}
$s = trim((string) $value);
if ($s === '') {
return '';
}
$maxLength = max(1, min($maxLength, 10000));
$s = mb_substr($s, 0, $maxLength);
// Control characters (keep tab/newline for multiline answers).
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
$s = str_replace(["\0", '--', '/*', '*/', ';'], '', $s);
$patterns = [
'/\bunion\s+select\b/i',
'/\bselect\s+.+\s+from\b/i',
'/\binsert\s+into\b/i',
'/\bupdate\s+.+\s+set\b/i',
'/\bdelete\s+from\b/i',
'/\bdrop\s+(table|database)\b/i',
'/\bexec(\s|\()/i',
'/\bxp_\w+/i',
'/\bor\s+1\s*=\s*1\b/i',
'/\band\s+1\s*=\s*1\b/i',
"/'\s*or\s+'/i",
'/"\s*or\s+"/i',
'/\bchar\s*\(/i',
'/\bconcat\s*\(/i',
'/\bsleep\s*\(/i',
'/\bbenchmark\s*\(/i',
];
foreach ($patterns as $pattern) {
$s = preg_replace($pattern, '', $s) ?? $s;
}
return trim(preg_replace('/\s{2,}/u', ' ', $s) ?? '');
}
/**
* Returns sanitized text or calls json_error when nothing safe remains.
*/
function require_safe_free_text(mixed $value, string $fieldName, int $maxLength = 2000): string {
$raw = is_string($value) || is_numeric($value) ? trim((string) $value) : '';
if ($raw === '') {
json_error('INVALID_FIELD', "$fieldName is required", 400);
}
$clean = sanitize_free_text($raw, $maxLength);
if ($clean === '') {
json_error('INVALID_FIELD', "$fieldName contains disallowed content", 400);
}
return $clean;
}

View File

@ -20,6 +20,7 @@ const LAYOUT_TYPES = [
{ value: 'value_spinner', label: 'Value Spinner' },
{ value: 'date_spinner', label: 'Date Spinner' },
{ value: 'string_spinner', label: 'String Spinner' },
{ value: 'free_text', label: 'Free Text' },
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
{ value: 'client_not_signed', label: 'Client Not Signed' },
{ value: 'last_page', label: 'Last Page' },
@ -165,6 +166,28 @@ function configFormHTML(layout, config, prefix) {
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
</div>`;
break;
case 'free_text':
html = `
<div class="form-row">
<div class="form-group">
<label>Hint key (optional)</label>
<input type="text" id="${prefix}_hint" value="${esc(config.hint || '')}" placeholder="e.g. enter_text_to_continue">
</div>
<div class="form-group">
<label>Max length</label>
<input type="number" id="${prefix}_maxLength" value="${config.maxLength ?? 500}" min="1" max="10000">
</div>
</div>
<div class="form-group">
<label class="checkbox-label" style="display:inline-flex">
<input type="checkbox" id="${prefix}_multiline" ${config.multiline ? 'checked' : ''}> Multiline input
</label>
</div>
<div class="form-group">
<label>Extra text key (optional)</label>
<input type="text" id="${prefix}_textKey" value="${esc(config.textKey || '')}" placeholder="e.g. additional_notes_label">
</div>`;
break;
case 'client_coach_code_question':
html = `
<div class="form-row">
@ -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');

View File

@ -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",