fix: security headers, XSS, prototype pollution, focus trap, accessibility
This commit is contained in:
parent
696118f17b
commit
a86afced2d
13 changed files with 154 additions and 85 deletions
|
|
@ -133,7 +133,7 @@ Noch nicht umgesetzt — bitte nicht als vorhanden dokumentieren, bevor es wirkl
|
|||
- [ ] Automatischer Server-Neustart (`systemd` oder `pm2`).
|
||||
- [ ] SSL/HTTPS für Produktionsbetrieb.
|
||||
- [ ] Docker-Containerisierung.
|
||||
- [ ] Path-Traversal-Schutz und Auth für den PUT-Endpoint.
|
||||
- [ ] Auth für den PUT-Endpoint.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ python scripts/validate.py pfad/zum/template.json
|
|||
python scripts/validate.py --all
|
||||
|
||||
# Nur JSON-Templates
|
||||
python scripts/validate.py --type-json
|
||||
python scripts/validate.py --all --type-json
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -10,25 +10,6 @@ from pathlib import Path
|
|||
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]:
|
||||
"""
|
||||
Liest eine Datei im Binärmodus.
|
||||
|
|
@ -71,11 +52,6 @@ def write_file(filepath: str, content: bytes) -> bool:
|
|||
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:
|
||||
"""Prüft, ob ein Verzeichnis existiert."""
|
||||
return os.path.isdir(dirpath)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,26 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||
def __init__(self, *args, directory=None, **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):
|
||||
"""Speichert eine Template-Datei."""
|
||||
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")
|
||||
except Exception as 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):
|
||||
"""Liefert Dateien aus."""
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Tune Modal -->
|
||||
<div class="modal-overlay" id="tune-modal" role="dialog" aria-label="Prompt tunen">
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
</nav>
|
||||
|
||||
<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 class="card">
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ async function scanTemplates() {
|
|||
format: 'json'
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { console.error('scanTemplates error:', e); }
|
||||
}
|
||||
|
||||
const userFiles = ['email_draft', 'brainstorming'];
|
||||
|
|
@ -110,7 +110,7 @@ async function scanTemplates() {
|
|||
format: 'md'
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) { console.error('scanTemplates error:', e); }
|
||||
}
|
||||
|
||||
return templates;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
* die Werte zurück in ein JSON-Objekt.
|
||||
*/
|
||||
|
||||
let editContainerRef = null;
|
||||
let currentIndent = 2;
|
||||
|
||||
/**
|
||||
|
|
@ -81,6 +80,9 @@ function createJsonEditUI(container, jsonData, editPathHint = '') {
|
|||
if (!container) return;
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key.includes('__proto__') || key.includes('constructor') || key.includes('prototype')) {
|
||||
return;
|
||||
}
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = data[key];
|
||||
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
|
||||
inputEntries.sort((a,b) => a.key.localeCompare(b.key));
|
||||
|
||||
const result = {};
|
||||
const result = Object.create(null);
|
||||
inputEntries.forEach(({ key, value }) => {
|
||||
const parts = key.split('.');
|
||||
let cur = result;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const m = part.match(/^([^\[\]]+)\[(\d+)\]$/);
|
||||
if (m && !isLast) {
|
||||
const lastBracket = part.lastIndexOf('[');
|
||||
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 idx = Number(m[2]);
|
||||
if (!cur[base]) cur[base] = [];
|
||||
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] = [];
|
||||
if (!cur[base]) cur[base] = Object.create(null);
|
||||
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) {
|
||||
cur[part] = value;
|
||||
} else {
|
||||
if (!cur[part]) cur[part] = {};
|
||||
if (!cur[part]) cur[part] = Object.create(null);
|
||||
cur = cur[part];
|
||||
}
|
||||
}
|
||||
|
|
@ -520,7 +531,13 @@ async function applyTunedContent() {
|
|||
* Schließe Tune-Modal
|
||||
*/
|
||||
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: '' };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,26 @@ document.getElementById('search').addEventListener('input', (e) => {
|
|||
document.getElementById('modal').addEventListener('click', (e) => {
|
||||
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
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { closeEditModal(); closeModal(); }
|
||||
if (e.key === 'Escape') { closeEditModal(); closeModal(); closeTuneModal(); }
|
||||
});
|
||||
|
||||
// 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
|
||||
loadTemplates().then(t => {
|
||||
allTemplates = t;
|
||||
|
|
|
|||
|
|
@ -2,29 +2,87 @@
|
|||
* 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
|
||||
* @param {string} title - Titel des Modals
|
||||
* @param {string} content - Inhalt als Text
|
||||
*/
|
||||
function showModal(title, content) {
|
||||
_lastFocusedElement = document.activeElement;
|
||||
document.getElementById('modal-title').textContent = title;
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merke dass View-Modal offen war
|
||||
*/
|
||||
function rememberViewModalOpen() {
|
||||
window._wasViewModalOpen = true;
|
||||
}
|
||||
|
||||
// exported functions are global (loaded as <script> tags)
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ function renderTemplates(templates) {
|
|||
${t.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-icon btn-view" data-path="${t.path}">Anzeigen</button>
|
||||
<button class="btn btn-icon btn-edit" data-path="${t.path}">📝 Bearbeiten</button>
|
||||
<button class="btn btn-icon btn-copy" data-path="${t.path}">Inhalt kopieren</button>
|
||||
<button class="btn btn-icon btn-tune" data-path="${t.path}">🎯 Tunen</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}" aria-label="Bearbeiten: ${esc(t.name)}">📝 Bearbeiten</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}" aria-label="Tunen: ${esc(t.name)}">🎯 Tunen</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
|
|||
|
|
@ -66,14 +66,16 @@ class PathValidator:
|
|||
# Vollständigen Pfad konstruieren
|
||||
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.abspath(os.path.join(self.root_dir, "templates"))
|
||||
full_path_abs = os.path.abspath(full_path)
|
||||
templates_base = os.path.realpath(os.path.join(self.root_dir, "templates"))
|
||||
full_path_resolved = os.path.realpath(full_path)
|
||||
|
||||
if (
|
||||
not full_path_abs.startswith(templates_base + os.sep)
|
||||
and full_path_abs != templates_base
|
||||
not full_path_resolved.startswith(templates_base + os.sep)
|
||||
and full_path_resolved != templates_base
|
||||
):
|
||||
return None
|
||||
|
||||
return full_path_abs
|
||||
if os.path.islink(full_path_resolved):
|
||||
return None
|
||||
|
||||
return full_path_resolved
|
||||
|
|
|
|||
18
web/serve.py
18
web/serve.py
|
|
@ -28,24 +28,6 @@ Handler.validator = validator
|
|||
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():
|
||||
PORT = 8081
|
||||
logging.info("Serving on http://localhost:%s", PORT)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue