fix(security): PDF-Export ohne Ressourcenzugriff, HTML-Escaping, CSV/XLSX Formula-Injection
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8dfee59bf0
commit
a3470a135c
3 files changed
+85
-13
No files matched your search
@@ -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 +++++++++++++++
|
||||
|
||||
---
|
||||
@@ -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 ''} – {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 ''} – {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 ''} – {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 ''} – {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()
|
||||
|
||||
@@ -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("'=")
|
||||
Reference in new issue
Block a user