prompt_template/web/serve.py
Michael c294336058 fix: 26 kritische + 29 wichtige Sicherheits- und Robustheitsprobleme behoben
- serve.py: URL-Encoding fix, Body-Limit (10MB), Port-Reuse, TOCTOU-Race, Logging
- index.html: XSS in renderTemplates geheilt, Escape-Key für Edit-Modal, Accessibility
- templates.json: Pfade ohne ../, leere Descriptions ergänzt
- validate.py: categories/ entfernt, CLI-Flags umbenannt, zu mutually_exclusive_group
- smoke_test.sh: set -euo pipefail, Port-Validation, Timeout 5s, code-Fallback
- cleanup_server.sh: lsof statt pgrep, Graceful-Term + SIGKILL-Fallback
- agent_verify.sh: set -euo pipefail, ROOT-Pfade, dynamischer Port, grep-Crash fix
- AGENTS.md: history/ entfernt, Pfad-Schema präzisiert
- README.md: categories/ entfernt, Web-Ansicht + API-Endpunkte hinzugefügt
2026-05-03 14:00:55 +02:00

179 lines
5.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Minimaler Entwicklungs-Server für die Prompt Templates Webansicht.
Startet auf Port 8081 und dient die statischen Dateien aus.
"""
import http.server
import socketserver
import socket
import os
import json
import logging
from urllib.parse import urlparse, unquote
logging.basicConfig(level=logging.INFO)
DIRECTORY = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(DIRECTORY, ".."))
def find_free_port(start_port=9000):
"""Finde einen freien Port ab start_port
Für zukünftige Smoke-Test-Integration.
"""
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
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIRECTORY, **kwargs)
def _validate_template_path(self, path):
"""Validiert, dass der Pfad auf Schritte Beschränkt ist und innerhalb von ROOT_DIR/templates/ oder DIRECTORY liegt."""
parsed = urlparse(path).path
path = parsed
# /templates.json ist ein Sonderfall - exakter Pfad im web/-Verzeichnis
if path == "/templates.json":
return os.path.join(DIRECTORY, "templates.json")
# Nur /templates/* Pfade erlauben
if not path.startswith("/templates/"):
return None
# Extrahiere den relativen Pfad nach /templates/
rel_path = path[len("/templates/") :] # 'system/test.json' oder '../etc/passwd'
rel_path = unquote(rel_path)
# Ablehnen bei leeren Pfaden oder absoluten Pfaden
if not rel_path or os.path.isabs(rel_path):
return None
# Ablehnen bei '..' Sequenzen (vor der Normalisierung!)
if ".." in rel_path.split(os.sep):
return None
# Pfad normalisieren
normalized_rel = os.path.normpath(rel_path)
# Nach Normalisierung nochmal prüfen
if normalized_rel.startswith("..") or os.path.isabs(normalized_rel):
return None
# Vollständigen Pfad konstruieren
full_path = os.path.join(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(ROOT_DIR, "templates"))
full_path_abs = os.path.abspath(full_path)
if (
not full_path_abs.startswith(templates_base + os.sep)
and full_path_abs != templates_base
):
return None
return full_path_abs
def do_PUT(self):
# Nur PUT auf /templates/* Pfade erlauben
file_path = self._validate_template_path(urlparse(self.path).path)
if file_path is None:
self.send_error(403, "Forbidden: Invalid path")
return
# Verzeichnis prüfen - muss existieren
file_dir = os.path.dirname(file_path)
if not os.path.exists(file_dir) or not os.path.isdir(file_dir):
self.send_error(404, "Directory not found")
return
# Content-Type prüfen
content_type = self.headers.get("Content-Type", "")
if "text/plain" not in content_type and not content_type.startswith("text/"):
self.send_error(400, "Unsupported content type")
return
# Inhalt lesen und speichern
MAX_BODY = 10 * 1024 * 1024
content_length = int(self.headers.get("Content-Length", 0))
if content_length <= 0:
self.send_error(400, "No content provided")
return
if content_length > MAX_BODY:
self.send_error(413, "Request body too large")
return
try:
file_content = self.rfile.read(content_length)
# Datei schreiben
with open(file_path, "wb") as f:
f.write(file_content)
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write(b"File saved successfully")
except Exception as e:
self.send_error(500, f"Failed to save file: {e}")
def do_GET(self):
# Für Root-Pfad: index.html servieren
parsed_path = urlparse(self.path).path
if parsed_path == "/" or parsed_path == "/index.html":
self.path = "/index.html"
return super().do_GET()
# Anfragen für /templates.json oder /templates/* umleiten
file_path = self._validate_template_path(self.path)
if file_path is not None:
try:
with open(file_path, "rb") as f:
self.send_response(200)
self.send_header(
"Content-type",
"text/plain"
if file_path.endswith(".md")
else "application/json",
)
self.end_headers()
self.wfile.write(f.read())
return
except FileNotFoundError:
self.send_error(404, "File not found")
return
except Exception as e:
self.send_error(500, f"Error serving file: {e}")
return
return super().do_GET()
def main():
PORT = 8081
logging.info("Serving on http://localhost:%s", PORT)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), Handler) as httpd:
logging.info("Serving Prompt Templates on http://localhost:%s", PORT)
logging.info("Press Ctrl+C to stop")
logging.info("Directory: %s", DIRECTORY)
try:
httpd.serve_forever()
except KeyboardInterrupt:
logging.info("\nServer stopped")
if __name__ == "__main__":
main()