diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md index 036e970..282ed9e 100644 --- a/backend/DEVLOG.md +++ b/backend/DEVLOG.md @@ -63,3 +63,16 @@ Keine Commits in dieser Session. - backend/migrations/versions/0047_kiosk_device_token_hash_index.py | 35 +++++++++++++++++++++++++++++++++++ --- +## 2026-11-03 22:09 – 22:09 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ +- backend/app/DEVLOG.md | 17 +++++++++++++++++ +- backend/app/routers/DEVLOG.md | 15 +++++++++++++++ + +--- diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index a75e090..8b2f272 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -3,6 +3,7 @@ import csv import io from collections import defaultdict from datetime import date, datetime, time, timedelta +from html import escape as _esc from decimal import Decimal from uuid import UUID @@ -323,6 +324,24 @@ def _check_arbzg_day(entry: TimeEntry) -> list[str]: return warnings + +def _deny_fetch(url: str, *args, **kwargs): + # Nutzerdaten landen im HTML; externe/lokale Ressourcen (file://, http://) dürfen nie geladen werden. + if url.startswith("data:"): + from weasyprint import default_url_fetcher + return default_url_fetcher(url) + raise ValueError(f"Ressourcenzugriff blockiert: {url[:40]}") + + +def _csv_safe(v): + if v is None: + return "" + if not isinstance(v, str): + return str(v) + s = v + return "'" + s if s and s[0] in ("=", "+", "-", "@", "\t", "\r") else s + + class ReportService: # ── Employee Dashboard ─────────────────────────────────────────────────── @@ -806,7 +825,7 @@ class ReportService: {fmt_h(r.duration_hours)} {r.code or ""} {recorded} - {r.note or ""} + {_esc(r.note or "")} """ month_names = [ @@ -839,9 +858,9 @@ class ReportService:

Stundenaufzeichnung – {period}

-
Firma{sheet.company_name}
-
Name des Mitarbeiters{sheet.user_name}
-
Pers.-Nr.{sheet.personnel_number or "—"}
+
Firma{_esc(sheet.company_name)}
+
Name des Mitarbeiters{_esc(sheet.user_name)}
+
Pers.-Nr.{_esc(str(sheet.personnel_number or "—"))}
@@ -865,7 +884,7 @@ class ReportService: """ from weasyprint import HTML - return HTML(string=html).write_pdf() + return HTML(string=html, url_fetcher=_deny_fetch).write_pdf() # ── Absence Report ─────────────────────────────────────────────────────── @@ -1241,7 +1260,7 @@ class ReportService: def to_pdf(self, html: str) -> bytes: from weasyprint import HTML - return HTML(string=html).write_pdf() + return HTML(string=html, url_fetcher=_deny_fetch).write_pdf() def time_report_to_pdf(self, report: "TimeReport", company_name: str = "") -> bytes: def fmt_h(h: float) -> str: @@ -1261,11 +1280,11 @@ class ReportService: rows_html = "
" for rows in groups.values(): sub = sum(r.worked_hours or 0 for r in rows) - rows_html += f"" + rows_html += f"" for r in rows: d = __import__('datetime').date.fromisoformat(str(r.date)) day = ["Mo","Di","Mi","Do","Fr","Sa","So"][d.weekday()] - rows_html += f"" + rows_html += f"" rows_html += f"
DatumMitarbeiterBeginnEndePauseNettoStatusNotiz
{rows[0].user_name}{' · ' + rows[0].department if rows[0].department else ''}  –  {fmt_h(sub)} gesamt
{_esc(rows[0].user_name)}{' · ' + _esc(rows[0].department) if rows[0].department else ''}  –  {fmt_h(sub)} gesamt
{day} {d.strftime('%d.%m.')}{fmt_t(r.start_time)}{fmt_t(r.end_time)}{r.break_minutes} min{fmt_h(r.worked_hours or 0)}{status_label.get(r.status, r.status)}{r.note or ''}
{day} {d.strftime('%d.%m.')}{fmt_t(r.start_time)}{fmt_t(r.end_time)}{r.break_minutes} min{fmt_h(r.worked_hours or 0)}{status_label.get(r.status, r.status)}{_esc(r.note or '')}
Gesamt{fmt_h(report.total_hours)}
" summary = f"
Einträge
{report.total_rows}
Gesamt-Stunden
{fmt_h(report.total_hours)}
Mitarbeiter
{len(groups)}
" @@ -1285,9 +1304,9 @@ class ReportService: rows_html = "" for rows in groups.values(): sub = sum(r.working_days for r in rows) - rows_html += f"" + rows_html += f"" for r in rows: - rows_html += f"" + rows_html += f"" rows_html += f"
MitarbeiterArtVonBisTageStatusNotiz
{rows[0].user_name}{' · ' + rows[0].department if rows[0].department else ''}  –  {sub:.1f} Tage
{_esc(rows[0].user_name)}{' · ' + _esc(rows[0].department) if rows[0].department else ''}  –  {sub:.1f} Tage
{r.absence_type}{__import__('datetime').date.fromisoformat(str(r.start_date)).strftime('%d.%m.%Y')}{__import__('datetime').date.fromisoformat(str(r.end_date)).strftime('%d.%m.%Y')}{r.working_days}{status_label.get(r.status, r.status)}{r.note or ''}
{_esc(r.absence_type or '')}{__import__('datetime').date.fromisoformat(str(r.start_date)).strftime('%d.%m.%Y')}{__import__('datetime').date.fromisoformat(str(r.end_date)).strftime('%d.%m.%Y')}{r.working_days}{status_label.get(r.status, r.status)}{_esc(r.note or '')}
Gesamt Arbeitstage{report.total_days:.1f}
" summary = f"
Anträge
{report.total_rows}
Arbeitstage
{report.total_days:.1f}
Mitarbeiter
{len(groups)}
" @@ -1304,7 +1323,7 @@ class ReportService: for r in report.rows: ot_cls = "plus" if r.overtime_hours > 0 else "minus" if r.overtime_hours < 0 else "" sign = "+" if r.overtime_hours > 0 else "" - rows_html += f"{r.user_name}{r.department or '–'}{fmt_h(r.hours_expected)}{fmt_h(r.hours_worked)}{sign}{fmt_h(r.overtime_hours)}" + rows_html += f"{_esc(r.user_name)}{_esc(r.department or '–')}{fmt_h(r.hours_expected)}{fmt_h(r.hours_worked)}{sign}{fmt_h(r.overtime_hours)}" total_sign = "+" if report.total_overtime > 0 else "" rows_html += f"{fmt_h(sum(r.hours_expected for r in report.rows))}{fmt_h(sum(r.hours_worked for r in report.rows))}{total_sign}{fmt_h(report.total_overtime)}" @@ -1319,7 +1338,7 @@ class ReportService: output = io.StringIO() writer = csv.DictWriter(output, fieldnames=list(rows[0].keys())) writer.writeheader() - writer.writerows(rows) + writer.writerows([{k: _csv_safe(v) for k, v in row.items()} for row in rows]) return output.getvalue() def to_xlsx(self, rows: list[dict], sheet_name: str = "Report") -> bytes: @@ -1330,7 +1349,7 @@ class ReportService: if rows: ws.append(list(rows[0].keys())) for row in rows: - ws.append([str(v) if v is not None else "" for v in row.values()]) + ws.append([_csv_safe(v) for v in row.values()]) output = io.BytesIO() wb.save(output) return output.getvalue() diff --git a/backend/tests/test_report_security.py b/backend/tests/test_report_security.py new file mode 100644 index 0000000..2bd4eaf --- /dev/null +++ b/backend/tests/test_report_security.py @@ -0,0 +1,40 @@ +"""Härtung der Report-Exporte: PDF-Ressourcenzugriff, HTML-Escaping, Formula-Injection.""" +import io + +import pytest + +from app.services.report_service import _csv_safe, report_service + + +def test_pdf_blocks_file_attachment(tmp_path): + secret = tmp_path / "secret.txt" + secret.write_text("TOPSECRET-MARKER") + html = f'x' + pdf = report_service.to_pdf(html) + assert b"TOPSECRET-MARKER" not in pdf + + +def test_pdf_blocks_remote_image(): + html = '' + assert report_service.to_pdf(html).startswith(b"%PDF") + + +def test_csv_safe_prefixes_formulas_only_for_strings(): + assert _csv_safe('=HYPERLINK("http://x")').startswith("'=") + assert _csv_safe("@SUM(A1)").startswith("'@") + assert _csv_safe("+1").startswith("'+") + assert _csv_safe(-2.5) == "-2.5" + assert _csv_safe(None) == "" + assert _csv_safe("Normal") == "Normal" + + +def test_csv_and_xlsx_neutralize_formulas(): + rows = [{"note": "=1+1", "hours": -1.5}] + csv_out = report_service.to_csv(rows) + assert "'=1+1" in csv_out and "-1.5" in csv_out + + from openpyxl import load_workbook + wb = load_workbook(io.BytesIO(report_service.to_xlsx(rows))) + cell = wb.active["A2"] + assert cell.data_type != "f" + assert str(cell.value).startswith("'=")