Skip to content

Commit b1dc5a4

Browse files
author
Lukas Geiger
committed
fix: harden webapp static file serving
1 parent 7dac6b4 commit b1dc5a4

3 files changed

Lines changed: 59 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1212
- GitHub-Actions-Smoke-Matrix pinnt Windows auf `windows-2025-vs2026` und macOS auf `macos-26`, damit die 2026-Runner-Migration vor dem Stichtag validiert wird.
1313

1414
### Fehlerbehebungen / Bug Fixes
15+
- `webapp/server.py`: Statische Dateien werden nur noch über strikt normalisierte
16+
Pfade unterhalb der erlaubten Roots ausgeliefert; Content-Types kommen aus
17+
einer festen Endungs-Whitelist statt aus frei abgeleiteten Pfadwerten.
1518
- **UX-001** (`MethodenAnalyser3.py`): Die Hauptaktionen der Tkinter-GUI hatten keine sichtbaren Tastaturhinweise und keine direkten Shortcuts. Fix: kompakte Shortcut-Zeile (`Alt+D`, `Alt+P`, `Alt+F`, `F1`) ergänzt, globale Tastaturkürzel gebunden und die Initialansicht auf Tastaturnutzung vorbereitet. Regressionstests in `tests/test_cli.py` sichern Hinweistext und Shortcut-Bindings ab.
1619
- **B-004** (`MethodenAnalyser3.py`): `auto_fix_unused_imports` las Dateien ausschließlich als UTF-8, was bei Latin-1-kodierten Quellcode-Dateien zu `UnicodeDecodeError` führte. Fix: UTF-8-First mit `latin-1`-Fallback via `readlines()` (Zeilennummern bleiben mit `ast.lineno` synchron). 9 Regressionstests in `tests/test_cli.py` ergänzt.
1720
- **B-005** (`MethodenAnalyser3.py`): `analyze_project` fing nur `IOError`/`OSError`, nicht `UnicodeDecodeError` — Latin-1-Dateien brachen den gesamten Projekt-Scan ab. Fix: `UnicodeDecodeError` in denselben `except`-Block aufgenommen.

tests/test_webapp_server.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@
77
import urllib.request
88
import zipfile
99
from http.server import ThreadingHTTPServer
10+
from pathlib import Path
1011
from urllib.request import urlopen
1112

12-
from webapp.server import MethodenAnalyserPwaHandler, analyze_payload, build_runtime_info
13+
from webapp.server import (
14+
MethodenAnalyserPwaHandler,
15+
_content_type_for_path,
16+
_resolve_under,
17+
analyze_payload,
18+
build_runtime_info,
19+
)
1320

1421

1522
class MethodenAnalyserWebappServerTests(unittest.TestCase):
@@ -112,6 +119,26 @@ def test_runtime_info_for_wildcard_server_includes_lan_urls(self) -> None:
112119
["http://10.0.0.8:8765/", "http://192.168.0.5:8765/"],
113120
)
114121

122+
def test_resolve_under_blocks_traversal(self) -> None:
123+
root = Path(__file__).resolve().parent
124+
self.assertIsNone(_resolve_under(root, "../test_cli.py"))
125+
self.assertIsNone(_resolve_under(root, "/tmp/test_cli.py"))
126+
127+
def test_resolve_under_allows_existing_child_file(self) -> None:
128+
root = Path(__file__).resolve().parent
129+
expected = (root / "test_webapp_server.py").resolve()
130+
self.assertEqual(_resolve_under(root, "test_webapp_server.py"), expected)
131+
132+
def test_content_type_uses_fixed_suffix_map(self) -> None:
133+
self.assertEqual(
134+
_content_type_for_path(Path("app.webmanifest")),
135+
"application/manifest+json; charset=utf-8",
136+
)
137+
self.assertEqual(
138+
_content_type_for_path(Path("payload.bad\r\nX-Test: 1")),
139+
"application/octet-stream",
140+
)
141+
115142

116143
class MethodenAnalyserInvalidBodyTests(unittest.TestCase):
117144
"""Verifies that malformed POST bodies return 400, not 500."""

webapp/server.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
import io
77
import ipaddress
88
import json
9-
import mimetypes
10-
import os
119
import socket
1210
import sys
1311
import tempfile
@@ -40,12 +38,16 @@
4038
)
4139

4240

43-
mimetypes.add_type("application/manifest+json", ".webmanifest")
44-
mimetypes.add_type("text/javascript", ".js")
45-
mimetypes.add_type("text/css", ".css")
46-
4741
LOCAL_ONLY_HOSTS = {"127.0.0.1", "::1", "localhost"}
4842
WILDCARD_HOSTS = {"0.0.0.0", "::"}
43+
CONTENT_TYPES_BY_SUFFIX = {
44+
".css": "text/css; charset=utf-8",
45+
".html": "text/html; charset=utf-8",
46+
".js": "text/javascript; charset=utf-8",
47+
".json": "application/json; charset=utf-8",
48+
".png": "image/png",
49+
".webmanifest": "application/manifest+json; charset=utf-8",
50+
}
4951

5052

5153
def _clean_filename(filename: Any, source_kind: str) -> str:
@@ -172,15 +174,31 @@ def analyze_payload(payload: dict[str, Any]) -> dict[str, Any]:
172174

173175

174176
def _resolve_under(root: Path, relative_path: str) -> Path | None:
175-
root = root.resolve()
176-
target = (root / relative_path).resolve()
177-
if target == root or root not in target.parents:
177+
resolved_root = root.resolve()
178+
normalized_path = relative_path.replace("\\", "/").strip()
179+
if not normalized_path:
180+
return None
181+
relative = PurePosixPath(normalized_path)
182+
if relative.is_absolute():
183+
return None
184+
if any(part in {"", ".", ".."} for part in relative.parts):
185+
return None
186+
target = root.joinpath(*relative.parts).resolve()
187+
try:
188+
target.relative_to(resolved_root)
189+
except ValueError:
190+
return None
191+
if target == resolved_root:
178192
return None
179193
if not target.is_file():
180194
return None
181195
return target
182196

183197

198+
def _content_type_for_path(target: Path) -> str:
199+
return CONTENT_TYPES_BY_SUFFIX.get(target.suffix.lower(), "application/octet-stream")
200+
201+
184202
def _discover_candidate_ipv4_addresses() -> list[str]:
185203
addresses: set[str] = set()
186204

@@ -334,7 +352,7 @@ def _send_file_or_404(self, target: Path | None) -> None:
334352
self._send_json({"ok": False, "error": "Datei nicht gefunden."}, status=404)
335353
return
336354

337-
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
355+
content_type = _content_type_for_path(target)
338356
try:
339357
body = target.read_bytes()
340358
except OSError as exc:

0 commit comments

Comments
 (0)