fix(security): PDF-Export ohne Ressourcenzugriff, HTML-Escaping, CSV/XLSX Formula-Injection
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-11-04 23:02:39 +01:00
1 parent 8dfee59bf0
commit a3470a135c
3 files changed
+85 -13

No files matched your search

+13
View File
@@ -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 +++++++++++++++
---
+32 -13
View File
@@ -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:
<td class="right">{fmt_h(r.duration_hours)}</td>
<td class="bold" style="text-align:center">{r.code or ""}</td>
<td>{recorded}</td>
<td>{r.note or ""}</td>
<td>{_esc(r.note or "")}</td>
</tr>"""
month_names = [
@@ -839,9 +858,9 @@ class ReportService:
<body>
<h1>Stundenaufzeichnung – {period}</h1>
<div class="meta">
<div><span>Firma</span>{sheet.company_name}</div>
<div><span>Name des Mitarbeiters</span>{sheet.user_name}</div>
<div><span>Pers.-Nr.</span>{sheet.personnel_number or "—"}</div>
<div><span>Firma</span>{_esc(sheet.company_name)}</div>
<div><span>Name des Mitarbeiters</span>{_esc(sheet.user_name)}</div>
<div><span>Pers.-Nr.</span>{_esc(str(sheet.personnel_number or "—"))}</div>
</div>
<table>
<thead><tr>
@@ -865,7 +884,7 @@ class ReportService:
</body>
</html>"""
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 = "<table><thead><tr><th>Datum</th><th>Mitarbeiter</th><th>Beginn</th><th>Ende</th><th class='right'>Pause</th><th class='right'>Netto</th><th>Status</th><th>Notiz</th></tr></thead><tbody>"
for rows in groups.values():
sub = sum(r.worked_hours or 0 for r in rows)
rows_html += f"<tr class='group-header'><td colspan='8'>{rows[0].user_name}{' · ' + rows[0].department if rows[0].department else ''} &nbsp;–&nbsp; {fmt_h(sub)} gesamt</td></tr>"
rows_html += f"<tr class='group-header'><td colspan='8'>{_esc(rows[0].user_name)}{' · ' + _esc(rows[0].department) if rows[0].department else ''} &nbsp;–&nbsp; {fmt_h(sub)} gesamt</td></tr>"
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"<tr><td>{day} {d.strftime('%d.%m.')}</td><td></td><td>{fmt_t(r.start_time)}</td><td>{fmt_t(r.end_time)}</td><td class='right'>{r.break_minutes} min</td><td class='right bold'>{fmt_h(r.worked_hours or 0)}</td><td><span class='badge {status_cls.get(r.status,'badge-gray')}'>{status_label.get(r.status, r.status)}</span></td><td>{r.note or ''}</td></tr>"
rows_html += f"<tr><td>{day} {d.strftime('%d.%m.')}</td><td></td><td>{fmt_t(r.start_time)}</td><td>{fmt_t(r.end_time)}</td><td class='right'>{r.break_minutes} min</td><td class='right bold'>{fmt_h(r.worked_hours or 0)}</td><td><span class='badge {status_cls.get(r.status,'badge-gray')}'>{status_label.get(r.status, r.status)}</span></td><td>{_esc(r.note or '')}</td></tr>"
rows_html += f"</tbody><tfoot><tr><td colspan='5' class='right'>Gesamt</td><td class='right'>{fmt_h(report.total_hours)}</td><td colspan='2'></td></tr></tfoot></table>"
summary = f"<div class='summary'><div class='kpi'><div class='kpi-label'>Einträge</div><div class='kpi-value'>{report.total_rows}</div></div><div class='kpi'><div class='kpi-label'>Gesamt-Stunden</div><div class='kpi-value'>{fmt_h(report.total_hours)}</div></div><div class='kpi'><div class='kpi-label'>Mitarbeiter</div><div class='kpi-value'>{len(groups)}</div></div></div>"
@@ -1285,9 +1304,9 @@ class ReportService:
rows_html = "<table><thead><tr><th>Mitarbeiter</th><th>Art</th><th>Von</th><th>Bis</th><th class='right'>Tage</th><th>Status</th><th>Notiz</th></tr></thead><tbody>"
for rows in groups.values():
sub = sum(r.working_days for r in rows)
rows_html += f"<tr class='group-header'><td colspan='7'>{rows[0].user_name}{' · ' + rows[0].department if rows[0].department else ''} &nbsp;–&nbsp; {sub:.1f} Tage</td></tr>"
rows_html += f"<tr class='group-header'><td colspan='7'>{_esc(rows[0].user_name)}{' · ' + _esc(rows[0].department) if rows[0].department else ''} &nbsp;–&nbsp; {sub:.1f} Tage</td></tr>"
for r in rows:
rows_html += f"<tr><td></td><td>{r.absence_type}</td><td>{__import__('datetime').date.fromisoformat(str(r.start_date)).strftime('%d.%m.%Y')}</td><td>{__import__('datetime').date.fromisoformat(str(r.end_date)).strftime('%d.%m.%Y')}</td><td class='right bold'>{r.working_days}</td><td><span class='badge {status_cls.get(r.status,'badge-gray')}'>{status_label.get(r.status, r.status)}</span></td><td>{r.note or ''}</td></tr>"
rows_html += f"<tr><td></td><td>{_esc(r.absence_type or '')}</td><td>{__import__('datetime').date.fromisoformat(str(r.start_date)).strftime('%d.%m.%Y')}</td><td>{__import__('datetime').date.fromisoformat(str(r.end_date)).strftime('%d.%m.%Y')}</td><td class='right bold'>{r.working_days}</td><td><span class='badge {status_cls.get(r.status,'badge-gray')}'>{status_label.get(r.status, r.status)}</span></td><td>{_esc(r.note or '')}</td></tr>"
rows_html += f"</tbody><tfoot><tr><td colspan='4' class='right'>Gesamt Arbeitstage</td><td class='right'>{report.total_days:.1f}</td><td colspan='2'></td></tr></tfoot></table>"
summary = f"<div class='summary'><div class='kpi'><div class='kpi-label'>Anträge</div><div class='kpi-value'>{report.total_rows}</div></div><div class='kpi'><div class='kpi-label'>Arbeitstage</div><div class='kpi-value'>{report.total_days:.1f}</div></div><div class='kpi'><div class='kpi-label'>Mitarbeiter</div><div class='kpi-value'>{len(groups)}</div></div></div>"
@@ -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"<tr><td class='bold'>{r.user_name}</td><td>{r.department or '–'}</td><td class='right'>{fmt_h(r.hours_expected)}</td><td class='right'>{fmt_h(r.hours_worked)}</td><td class='right'><span class='{ot_cls}'>{sign}{fmt_h(r.overtime_hours)}</span></td></tr>"
rows_html += f"<tr><td class='bold'>{_esc(r.user_name)}</td><td>{_esc(r.department or '–')}</td><td class='right'>{fmt_h(r.hours_expected)}</td><td class='right'>{fmt_h(r.hours_worked)}</td><td class='right'><span class='{ot_cls}'>{sign}{fmt_h(r.overtime_hours)}</span></td></tr>"
total_sign = "+" if report.total_overtime > 0 else ""
rows_html += f"</tbody><tfoot><tr><td colspan='2'></td><td class='right'>{fmt_h(sum(r.hours_expected for r in report.rows))}</td><td class='right'>{fmt_h(sum(r.hours_worked for r in report.rows))}</td><td class='right'>{total_sign}{fmt_h(report.total_overtime)}</td></tr></tfoot></table>"
@@ -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()
+40
View File
@@ -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'<html><body><a rel="attachment" href="file://{secret}">x</a></body></html>'
pdf = report_service.to_pdf(html)
assert b"TOPSECRET-MARKER" not in pdf
def test_pdf_blocks_remote_image():
html = '<html><body><img src="http://127.0.0.1:9/x.png"></body></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("'=")