fix: security headers, XSS, prototype pollution, focus trap, accessibility

This commit is contained in:
Michael 2026-05-03 20:09:36 +02:00
parent 696118f17b
commit a86afced2d
13 changed files with 154 additions and 85 deletions

View file

@ -133,7 +133,7 @@ Noch nicht umgesetzt — bitte nicht als vorhanden dokumentieren, bevor es wirkl
- [ ] Automatischer Server-Neustart (`systemd` oder `pm2`). - [ ] Automatischer Server-Neustart (`systemd` oder `pm2`).
- [ ] SSL/HTTPS für Produktionsbetrieb. - [ ] SSL/HTTPS für Produktionsbetrieb.
- [ ] Docker-Containerisierung. - [ ] Docker-Containerisierung.
- [ ] Path-Traversal-Schutz und Auth für den PUT-Endpoint. - [ ] Auth für den PUT-Endpoint.
--- ---

View file

@ -119,7 +119,7 @@ python scripts/validate.py pfad/zum/template.json
python scripts/validate.py --all python scripts/validate.py --all
# Nur JSON-Templates # Nur JSON-Templates
python scripts/validate.py --type-json python scripts/validate.py --all --type-json
``` ```
--- ---

View file

@ -25,4 +25,4 @@ Dieser Ordner enthält alle technischen und prozessbezogenen Dokumentationen fü
--- ---
*Letzte Aktualisierung: `$(date +%Y-%m-%d)`* *Letzte Aktualisierung: 2026-05-03*

View file

@ -10,25 +10,6 @@ from pathlib import Path
from typing import Optional from typing import Optional
def read_file(filepath: str) -> Optional[str]:
"""
Liest eine Datei und gibt den Inhalt als String zurück.
Args:
filepath: Absoluter Pfad zur Datei
Returns:
Dateiinhalt als UTF-8 String, oder None bei Fehler
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
except Exception:
return None
def read_file_binary(filepath: str) -> Optional[bytes]: def read_file_binary(filepath: str) -> Optional[bytes]:
""" """
Liest eine Datei im Binärmodus. Liest eine Datei im Binärmodus.
@ -71,11 +52,6 @@ def write_file(filepath: str, content: bytes) -> bool:
return False return False
def file_exists(filepath: str) -> bool:
"""Prüft, ob eine Datei existiert."""
return os.path.isfile(filepath)
def directory_exists(dirpath: str) -> bool: def directory_exists(dirpath: str) -> bool:
"""Prüft, ob ein Verzeichnis existiert.""" """Prüft, ob ein Verzeichnis existiert."""
return os.path.isdir(dirpath) return os.path.isdir(dirpath)

View file

@ -35,6 +35,26 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, directory=None, **kwargs): def __init__(self, *args, directory=None, **kwargs):
super().__init__(*args, directory=directory or self.directory, **kwargs) super().__init__(*args, directory=directory or self.directory, **kwargs)
def send_response(self, code, message=None):
if self.request_version != 'HTTP/0.9':
if message is None:
if code in self.responses:
message = self.responses[code][0]
else:
message = ''
if not hasattr(self, '_headers_buffer'):
self._headers_buffer = []
self._headers_buffer.append(("%s %d %s\r\n" %
(self.protocol_version, code, message)).encode('latin-1', 'strict'))
# Write Server and Date headers (mimicking base class behavior)
self.send_header('Server', self.version_string())
self.send_header('Date', self.date_time_string())
# Security headers
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'")
def do_PUT(self): def do_PUT(self):
"""Speichert eine Template-Datei.""" """Speichert eine Template-Datei."""
file_path = self.validator.resolve_template_path(urlparse(self.path).path) file_path = self.validator.resolve_template_path(urlparse(self.path).path)
@ -77,7 +97,7 @@ class Handler(http.server.SimpleHTTPRequestHandler):
self.wfile.write(b"File saved successfully") self.wfile.write(b"File saved successfully")
except Exception as e: except Exception as e:
logger.error("Failed to save file: %s", e) logger.error("Failed to save file: %s", e)
self.send_error(500, f"Failed to save file: {e}") self.send_error(500, "Failed to save file")
def do_GET(self): def do_GET(self):
"""Liefert Dateien aus.""" """Liefert Dateien aus."""

View file

@ -47,7 +47,7 @@
</div> </div>
<!-- Toast --> <!-- Toast -->
<div class="toast" id="toast"></div> <div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- Tune Modal --> <!-- Tune Modal -->
<div class="modal-overlay" id="tune-modal" role="dialog" aria-label="Prompt tunen"> <div class="modal-overlay" id="tune-modal" role="dialog" aria-label="Prompt tunen">
@ -90,7 +90,7 @@
</nav> </nav>
<div class="filter-bar"> <div class="filter-bar">
<input type="text" id="search" placeholder="Suche Templates... (Name, Beschreibung, Tags)"> <input type="text" id="search" placeholder="Suche Templates... (Name, Beschreibung, Tags)" aria-label="Templates durchsuchen">
</div> </div>
<div class="card"> <div class="card">

View file

@ -90,7 +90,7 @@ async function scanTemplates() {
format: 'json' format: 'json'
}); });
} }
} catch (e) {} } catch (e) { console.error('scanTemplates error:', e); }
} }
const userFiles = ['email_draft', 'brainstorming']; const userFiles = ['email_draft', 'brainstorming'];
@ -110,7 +110,7 @@ async function scanTemplates() {
format: 'md' format: 'md'
}); });
} }
} catch (e) {} } catch (e) { console.error('scanTemplates error:', e); }
} }
return templates; return templates;

View file

@ -5,7 +5,6 @@
* die Werte zurück in ein JSON-Objekt. * die Werte zurück in ein JSON-Objekt.
*/ */
let editContainerRef = null;
let currentIndent = 2; let currentIndent = 2;
/** /**
@ -81,6 +80,9 @@ function createJsonEditUI(container, jsonData, editPathHint = '') {
if (!container) return; if (!container) return;
Object.keys(data).forEach(key => { Object.keys(data).forEach(key => {
if (key.includes('__proto__') || key.includes('constructor') || key.includes('prototype')) {
return;
}
const fullKey = prefix ? `${prefix}.${key}` : key; const fullKey = prefix ? `${prefix}.${key}` : key;
const value = data[key]; const value = data[key];
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value); const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
@ -264,30 +266,39 @@ function extractJsonFromForm(formDiv) {
// Sort by key to ensure stable parent-first construction // Sort by key to ensure stable parent-first construction
inputEntries.sort((a,b) => a.key.localeCompare(b.key)); inputEntries.sort((a,b) => a.key.localeCompare(b.key));
const result = {}; const result = Object.create(null);
inputEntries.forEach(({ key, value }) => { inputEntries.forEach(({ key, value }) => {
const parts = key.split('.'); const parts = key.split('.');
let cur = result; let cur = result;
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
const isLast = i === parts.length - 1; const isLast = i === parts.length - 1;
const m = part.match(/^([^\[\]]+)\[(\d+)\]$/); const lastBracket = part.lastIndexOf('[');
if (m && !isLast) { const lastCloseBracket = part.lastIndexOf(']');
let m = null;
if (lastBracket !== -1 && lastCloseBracket !== -1 && lastCloseBracket > lastBracket) {
const beforeBracket = part.substring(0, lastBracket);
const between = part.substring(lastBracket + 1, lastCloseBracket);
const afterBracket = part.substring(lastCloseBracket + 1);
if (afterBracket === '' && /^\d+$/.test(between)) {
m = [part, beforeBracket, between];
}
}
if (m && isLast) {
const base = m[1]; const base = m[1];
const idx = Number(m[2]); const idx = Number(m[2]);
if (!cur[base]) cur[base] = []; if (!cur[base]) cur[base] = Object.create(null);
if (!cur[base][idx]) cur[base][idx] = {};
cur = cur[base][idx];
} else if (m && isLast) {
// Array leaf
const base = m[1];
const idx = Number(m[2]);
if (!cur[base]) cur[base] = [];
cur[base][idx] = value; cur[base][idx] = value;
} else if (m && !isLast) {
const base = m[1];
const idx = Number(m[2]);
if (!cur[base]) cur[base] = [];
if (!cur[base][idx]) cur[base][idx] = Object.create(null);
cur = cur[base][idx];
} else if (isLast) { } else if (isLast) {
cur[part] = value; cur[part] = value;
} else { } else {
if (!cur[part]) cur[part] = {}; if (!cur[part]) cur[part] = Object.create(null);
cur = cur[part]; cur = cur[part];
} }
} }
@ -520,7 +531,13 @@ async function applyTunedContent() {
* Schließe Tune-Modal * Schließe Tune-Modal
*/ */
function closeTuneModal() { function closeTuneModal() {
document.getElementById('tune-modal').classList.remove('active'); window._lastFocusedElement = document.activeElement;
var modal = document.getElementById('tune-modal');
modal.classList.remove('active');
_setAriaHidden('tune-modal', true);
if (window._lastFocusedElement && window._lastFocusedElement.focus) {
window._lastFocusedElement.focus();
}
window._tuneState = { path: '', rawPath: '', template: '' }; window._tuneState = { path: '', rawPath: '', template: '' };
} }

View file

@ -14,10 +14,26 @@ document.getElementById('search').addEventListener('input', (e) => {
document.getElementById('modal').addEventListener('click', (e) => { document.getElementById('modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal(); if (e.target === e.currentTarget) closeModal();
}); });
document.getElementById('edit-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
document.getElementById('tune-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeTuneModal();
});
// Focus trap for edit-modal
document.getElementById('edit-modal').addEventListener('keydown', function(e) {
if (e.key === 'Tab') { _trapFocus(e, document.getElementById('edit-modal')); }
});
// Focus trap for tune-modal
document.getElementById('tune-modal').addEventListener('keydown', function(e) {
if (e.key === 'Tab') { _trapFocus(e, document.getElementById('tune-modal')); }
});
// Escape key closes modal // Escape key closes modal
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeEditModal(); closeModal(); } if (e.key === 'Escape') { closeEditModal(); closeModal(); closeTuneModal(); }
}); });
// Hash -> type filter // Hash -> type filter
@ -38,6 +54,11 @@ document.querySelectorAll('.nav a').forEach(link => {
}); });
}); });
// Set initial aria-hidden on all modals
_setAriaHidden('modal', true);
_setAriaHidden('edit-modal', true);
_setAriaHidden('tune-modal', true);
// Initial load // Initial load
loadTemplates().then(t => { loadTemplates().then(t => {
allTemplates = t; allTemplates = t;

View file

@ -2,29 +2,87 @@
* Modal-Management für View und Edit Modals. * Modal-Management für View und Edit Modals.
*/ */
var _lastFocusedElement = null;
function _getFocusableElements(container) {
var selectors = 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(container.querySelectorAll(selectors)).filter(function(el) {
return !el.closest('.hidden');
});
}
function _trapFocus(e, container) {
if (e.key !== 'Tab') return;
var focusable = _getFocusableElements(container);
if (focusable.length === 0) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
function _setAriaHidden(id, hidden) {
var el = document.getElementById(id);
if (el) {
if (hidden) {
el.setAttribute('aria-hidden', 'true');
} else {
el.removeAttribute('aria-hidden');
}
}
}
/** /**
* Zeige View-Modal mit Titel und Inhalt * Zeige View-Modal mit Titel und Inhalt
* @param {string} title - Titel des Modals * @param {string} title - Titel des Modals
* @param {string} content - Inhalt als Text * @param {string} content - Inhalt als Text
*/ */
function showModal(title, content) { function showModal(title, content) {
_lastFocusedElement = document.activeElement;
document.getElementById('modal-title').textContent = title; document.getElementById('modal-title').textContent = title;
document.getElementById('modal-content').textContent = content; document.getElementById('modal-content').textContent = content;
document.getElementById('modal').classList.add('active'); var modal = document.getElementById('modal');
modal.classList.add('active');
_setAriaHidden('modal', false);
var focusable = _getFocusableElements(modal);
if (focusable.length > 0) {
focusable[0].focus();
}
modal.addEventListener('keydown', function(e) { _trapFocus(e, modal); });
} }
/** /**
* Schließe View-Modal * Schließe View-Modal
*/ */
function closeModal() { function closeModal() {
document.getElementById('modal').classList.remove('active'); var modal = document.getElementById('modal');
modal.classList.remove('active');
_setAriaHidden('modal', true);
if (_lastFocusedElement && _lastFocusedElement.focus) {
_lastFocusedElement.focus();
}
} }
/** /**
* Schließe Edit-Modal * Schließe Edit-Modal
*/ */
function closeEditModal() { function closeEditModal() {
document.getElementById('edit-modal').classList.remove('active'); _lastFocusedElement = document.activeElement;
var modal = document.getElementById('edit-modal');
modal.classList.remove('active');
_setAriaHidden('edit-modal', true);
if (_lastFocusedElement && _lastFocusedElement.focus) {
_lastFocusedElement.focus();
}
} }
/** /**
@ -35,11 +93,4 @@ function wasViewModalOpen() {
return window._wasViewModalOpen || false; return window._wasViewModalOpen || false;
} }
/**
* Merke dass View-Modal offen war
*/
function rememberViewModalOpen() {
window._wasViewModalOpen = true;
}
// exported functions are global (loaded as <script> tags) // exported functions are global (loaded as <script> tags)

View file

@ -34,10 +34,10 @@ function renderTemplates(templates) {
${t.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')} ${t.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-icon btn-view" data-path="${t.path}">Anzeigen</button> <button class="btn btn-icon btn-view" data-path="${t.path}" aria-label="Anzeigen: ${esc(t.name)}">Anzeigen</button>
<button class="btn btn-icon btn-edit" data-path="${t.path}">📝 Bearbeiten</button> <button class="btn btn-icon btn-edit" data-path="${t.path}" aria-label="Bearbeiten: ${esc(t.name)}">📝 Bearbeiten</button>
<button class="btn btn-icon btn-copy" data-path="${t.path}">Inhalt kopieren</button> <button class="btn btn-icon btn-copy" data-path="${t.path}" aria-label="Inhalt kopieren: ${esc(t.name)}">Inhalt kopieren</button>
<button class="btn btn-icon btn-tune" data-path="${t.path}">🎯 Tunen</button> <button class="btn btn-icon btn-tune" data-path="${t.path}" aria-label="Tunen: ${esc(t.name)}">🎯 Tunen</button>
</div> </div>
</div> </div>
`).join(''); `).join('');

View file

@ -66,14 +66,16 @@ class PathValidator:
# Vollständigen Pfad konstruieren # Vollständigen Pfad konstruieren
full_path = os.path.join(self.root_dir, "templates", normalized_rel) full_path = os.path.join(self.root_dir, "templates", normalized_rel)
# Explizite Prüfung, dass der Pfad innerhalb von ROOT_DIR/templates/ liegt templates_base = os.path.realpath(os.path.join(self.root_dir, "templates"))
templates_base = os.path.abspath(os.path.join(self.root_dir, "templates")) full_path_resolved = os.path.realpath(full_path)
full_path_abs = os.path.abspath(full_path)
if ( if (
not full_path_abs.startswith(templates_base + os.sep) not full_path_resolved.startswith(templates_base + os.sep)
and full_path_abs != templates_base and full_path_resolved != templates_base
): ):
return None return None
return full_path_abs if os.path.islink(full_path_resolved):
return None
return full_path_resolved

View file

@ -28,24 +28,6 @@ Handler.validator = validator
Handler.directory = DIRECTORY Handler.directory = DIRECTORY
def find_free_port(start_port=9000):
"""Finde einen freien Port ab start_port.
Für zukünftige Smoke-Test-Integration.
"""
import socket
port = start_port
while port < 10000:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", port))
return port
except OSError:
port += 1
return None
def main(): def main():
PORT = 8081 PORT = 8081
logging.info("Serving on http://localhost:%s", PORT) logging.info("Serving on http://localhost:%s", PORT)