added free text input with security measures
This commit is contained in:
@ -77,6 +77,10 @@ if ($qnID) {
|
|||||||
if (isset($config['range'])) $q['range'] = $config['range'];
|
if (isset($config['range'])) $q['range'] = $config['range'];
|
||||||
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
||||||
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
|
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
|
// String spinner static options
|
||||||
if ($layout === 'string_spinner' && isset($config['options'])) {
|
if ($layout === 'string_spinner' && isset($config['options'])) {
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
// /var/www/html/common.php
|
// /var/www/html/common.php
|
||||||
// Gemeinsame Helfer für Token-Validierung, Key-Derivation (HKDF) und AES (CBC, IV vorangestellt).
|
// 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.
|
* Load KEY=value pairs from .env in the project root.
|
||||||
* Does not override variables already set in the real environment.
|
* Does not override variables already set in the real environment.
|
||||||
|
|||||||
@ -54,12 +54,20 @@ if ($method === 'POST') {
|
|||||||
: ($clientRow['coachID'] ?? '');
|
: ($clientRow['coachID'] ?? '');
|
||||||
|
|
||||||
// Load all questions for this questionnaire: full questionID keyed by short ID
|
// 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]);
|
$qRows->execute([':qn' => $qnID]);
|
||||||
$shortIdMap = []; // shortId -> fullQuestionID
|
$shortIdMap = []; // shortId -> fullQuestionID
|
||||||
foreach ($qRows->fetchAll(PDO::FETCH_COLUMN) as $fullId) {
|
$freeTextMaxLen = []; // fullQuestionID -> maxLength
|
||||||
|
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||||
|
$fullId = $qRow['questionID'];
|
||||||
$parts = explode('__', $fullId);
|
$parts = explode('__', $fullId);
|
||||||
$shortIdMap[end($parts)] = $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]
|
// 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;
|
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
||||||
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : 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
|
// Resolve option key to answerOptionID and accumulate points
|
||||||
$optionKey = $a['answerOptionKey'] ?? null;
|
$optionKey = $a['answerOptionKey'] ?? null;
|
||||||
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
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['range'])) $q['range'] = $config['range'];
|
||||||
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
|
||||||
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
|
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'])) {
|
if ($layout === 'string_spinner' && isset($config['options'])) {
|
||||||
$q['options'] = $config['options'];
|
$q['options'] = $config['options'];
|
||||||
|
|||||||
@ -86,6 +86,17 @@ try {
|
|||||||
if (isset($q['range'])) $config['range'] = $q['range'];
|
if (isset($q['range'])) $config['range'] = $q['range'];
|
||||||
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
|
if (isset($q['constraints'])) $config['constraints'] = $q['constraints'];
|
||||||
if (isset($q['minSelection'])) $config['minSelection'] = $q['minSelection'];
|
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'])
|
if ($layout === 'string_spinner' && isset($q['options']) && is_array($q['options'])
|
||||||
&& isset($q['options'][0]) && is_string($q['options'][0])) {
|
&& isset($q['options'][0]) && is_string($q['options'][0])) {
|
||||||
|
|||||||
61
lib/text_sanitize.php
Normal file
61
lib/text_sanitize.php
Normal 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;
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ const LAYOUT_TYPES = [
|
|||||||
{ value: 'value_spinner', label: 'Value Spinner' },
|
{ value: 'value_spinner', label: 'Value Spinner' },
|
||||||
{ value: 'date_spinner', label: 'Date Spinner' },
|
{ value: 'date_spinner', label: 'Date Spinner' },
|
||||||
{ value: 'string_spinner', label: 'String Spinner' },
|
{ value: 'string_spinner', label: 'String Spinner' },
|
||||||
|
{ value: 'free_text', label: 'Free Text' },
|
||||||
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
{ value: 'client_coach_code_question', label: 'Client / Coach Code' },
|
||||||
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
{ value: 'client_not_signed', label: 'Client Not Signed' },
|
||||||
{ value: 'last_page', label: 'Last Page' },
|
{ 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>
|
<textarea id="${prefix}_stringOptions" rows="4" style="font-family:monospace;font-size:.85rem">${(config.options || []).join('\n')}</textarea>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
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':
|
case 'client_coach_code_question':
|
||||||
html = `
|
html = `
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@ -249,6 +272,14 @@ function readConfigFromForm(layout, prefix) {
|
|||||||
if (opts.length) config.options = opts;
|
if (opts.length) config.options = opts;
|
||||||
break;
|
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':
|
case 'client_coach_code_question':
|
||||||
if (val('hint1')) config.hint1 = val('hint1');
|
if (val('hint1')) config.hint1 = val('hint1');
|
||||||
if (val('hint2')) config.hint2 = val('hint2');
|
if (val('hint2')) config.hint2 = val('hint2');
|
||||||
|
|||||||
@ -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",
|
"id": "q4",
|
||||||
"_comment": "Frage mit Spinner zur Auswahl des Datums",
|
"_comment": "Frage mit Spinner zur Auswahl des Datums",
|
||||||
|
|||||||
Reference in New Issue
Block a user