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['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'])) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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) {
|
||||
$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'];
|
||||
|
||||
@ -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
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: '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');
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user