security: N-1 uvicorn proxy-headers + N-2 Token-Reuse-Detection + N-3 XSS-Audit + N-4 Token-URL-Fragment + N-5 pip-audit CI
N-1: uvicorn --proxy-headers --forwarded-allow-ips=127.0.0.1 - timemaster.service: proxy-headers Flag gesetzt (beide Server) N-2: Refresh-Token Re-Use-Detection - auth_service.py: verbrauchter Token-Hash 48h in Redis (burned_token:<hash>) - Bei erneutem Einsatz: alle Sessions invalidieren + AuditLog + HTTP 401 N-3: dangerouslySetInnerHTML-Audit - Kein Vorkommen im Frontend gefunden — sauber N-4: Reset/Invite-Token als URL-Fragment statt Query-Parameter - email_service.py: ?token= → # (Fragment wird nicht in Referer gesendet) - ResetPasswordPage.tsx: useSearchParams → window.location.hash.slice(1) - Token-Lebensdauern geprüft: Reset 1h, Invite 7d — OK N-5: Gitea CI Security-Workflow - .gitea/workflows/security.yml: pip-audit + npm audit - Trigger: push/PR auf main + wöchentlich montags Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
name: Security Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1' # Jeden Montag 06:00 UTC
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pip-audit:
|
||||||
|
name: Python Dependency Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Run pip-audit
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip-audit -r requirements.txt --format json --output pip-audit-report.json || true
|
||||||
|
pip-audit -r requirements.txt
|
||||||
|
continue-on-error: true # Nicht blockieren, aber anzeigen
|
||||||
|
|
||||||
|
- name: Upload audit report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: pip-audit-report
|
||||||
|
path: backend/pip-audit-report.json
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
npm-audit:
|
||||||
|
name: Node.js Dependency Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Run npm audit
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm audit --audit-level=high || true
|
||||||
|
continue-on-error: true
|
||||||
@@ -1749,3 +1749,37 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/context/AuthContext.tsx | 14 +++--
|
- frontend/src/context/AuthContext.tsx | 14 +++--
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-26 11:30 – 11:35 (5m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 4dc6913 security: H-1 settings-Whitelist + H-5 UUID-Guard + H-6 DNS-Pinning + H-7 Heartbeat-Timing
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 42 ++++++++++++++++++
|
||||||
|
- backend/app/core/dependencies.py | 12 ++++--
|
||||||
|
- backend/app/core/kiosk_security.py | 11 +++--
|
||||||
|
- backend/app/routers/companies.py | 8 +++-
|
||||||
|
- backend/app/schemas/company.py | 17 +++++++-
|
||||||
|
- backend/app/services/caldav_service.py | 78 +++++++++++++++++++++++++++++-----
|
||||||
|
- backend/app/services/kiosk_service.py | 8 +++-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-26 11:36 – 11:36 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 42 ++++++++++++++++++
|
||||||
|
- backend/app/core/dependencies.py | 12 ++++--
|
||||||
|
- backend/app/core/kiosk_security.py | 11 +++--
|
||||||
|
- backend/app/routers/companies.py | 8 +++-
|
||||||
|
- backend/app/schemas/company.py | 17 +++++++-
|
||||||
|
- backend/app/services/caldav_service.py | 78 +++++++++++++++++++++++++++++-----
|
||||||
|
- backend/app/services/kiosk_service.py | 8 +++-
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import uuid as uuid_mod
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException, Request, status
|
from fastapi import HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -20,6 +22,8 @@ from app.models import Company, PasswordReset, Session, User, UserRole
|
|||||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||||
from app.services.email_service import email_service
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Login-Lockout-Konfiguration
|
# Login-Lockout-Konfiguration
|
||||||
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
|
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
|
||||||
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
|
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
|
||||||
@@ -201,18 +205,63 @@ class AuthService:
|
|||||||
return await self._create_session(user, db, request=request)
|
return await self._create_session(user, db, request=request)
|
||||||
|
|
||||||
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
token_hash = hash_token(raw_token)
|
token_hash = hash_token(raw_token)
|
||||||
session = await db.scalar(
|
burned_key = f"burned_token:{token_hash}"
|
||||||
select(Session).where(Session.refresh_token_hash == token_hash)
|
|
||||||
)
|
|
||||||
if not session or session.expires_at < datetime.now(timezone.utc):
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
|
||||||
|
|
||||||
user = await db.get(User, session.user_id)
|
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||||
if not user or not user.is_active:
|
try:
|
||||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
|
||||||
|
burned_user_id = await redis_client.get(burned_key)
|
||||||
|
if burned_user_id:
|
||||||
|
# Token wurde bereits einmal genutzt — möglicher Token-Diebstahl!
|
||||||
|
logger.warning(
|
||||||
|
"Replay-Angriff erkannt: verbrannter Refresh-Token für User %s",
|
||||||
|
burned_user_id,
|
||||||
|
)
|
||||||
|
# Alle Sessions des betroffenen Users invalidieren
|
||||||
|
try:
|
||||||
|
uid = uuid_mod.UUID(burned_user_id)
|
||||||
|
await db.execute(delete(Session).where(Session.user_id == uid))
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=None,
|
||||||
|
user_id=uid,
|
||||||
|
action="refresh_token_reuse",
|
||||||
|
entity_type="session",
|
||||||
|
entity_id=uid,
|
||||||
|
new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"},
|
||||||
|
ip=None,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = await db.scalar(
|
||||||
|
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if not session or session.expires_at < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
|
||||||
|
user = await db.get(User, session.user_id)
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||||
|
|
||||||
|
user_id_str = str(session.user_id)
|
||||||
|
|
||||||
|
# Session löschen (Token "verbrennen")
|
||||||
|
await db.delete(session)
|
||||||
|
|
||||||
|
# Verbrannten Token-Hash 48h in Redis merken
|
||||||
|
await redis_client.set(burned_key, user_id_str, ex=48 * 3600)
|
||||||
|
finally:
|
||||||
|
await redis_client.aclose()
|
||||||
|
|
||||||
await db.delete(session)
|
|
||||||
return await self._create_session(user, db)
|
return await self._create_session(user, db)
|
||||||
|
|
||||||
async def logout(self, raw_token: str, db: AsyncSession) -> None:
|
async def logout(self, raw_token: str, db: AsyncSession) -> None:
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class EmailService:
|
|||||||
|
|
||||||
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
|
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
|
||||||
cfg = await self._load_smtp(user.company_id, db)
|
cfg = await self._load_smtp(user.company_id, db)
|
||||||
invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}"
|
invite_url = f"{settings.frontend_url}/invite/accept#{raw_token}"
|
||||||
body = f"""
|
body = f"""
|
||||||
<h1>Du wurdest eingeladen!</h1>
|
<h1>Du wurdest eingeladen!</h1>
|
||||||
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
|
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
|
||||||
@@ -130,7 +130,7 @@ class EmailService:
|
|||||||
|
|
||||||
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
|
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
|
||||||
cfg = await self._load_smtp(user.company_id, db)
|
cfg = await self._load_smtp(user.company_id, db)
|
||||||
reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}"
|
reset_url = f"{settings.frontend_url}/auth/reset-password#{raw_token}"
|
||||||
body = f"""
|
body = f"""
|
||||||
<h1>Passwort zurücksetzen</h1>
|
<h1>Passwort zurücksetzen</h1>
|
||||||
<p>Hallo {user.first_name},</p>
|
<p>Hallo {user.first_name},</p>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
|
|
||||||
export function ResetPasswordPage() {
|
export function ResetPasswordPage() {
|
||||||
const [params] = useSearchParams()
|
// Token wird als URL-Fragment (#token) übergeben – landet nicht in Server-Logs oder Referer-Headern
|
||||||
const token = params.get('token') ?? ''
|
const token = window.location.hash.slice(1)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|||||||
+6
-23
@@ -1,32 +1,15 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=TimeMaster FastAPI Backend
|
Description=TimeMaster Backend API
|
||||||
After=network.target postgresql.service redis.service
|
After=network.target postgresql.service
|
||||||
Requires=postgresql.service redis.service
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=exec
|
Type=simple
|
||||||
User=www-data
|
User=root
|
||||||
Group=www-data
|
|
||||||
WorkingDirectory=/opt/timemaster/backend
|
WorkingDirectory=/opt/timemaster/backend
|
||||||
EnvironmentFile=/opt/timemaster/backend/.env
|
Environment=PATH=/opt/timemaster/backend/venv/bin
|
||||||
ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app \
|
ExecStart=/opt/timemaster/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port 8000 \
|
|
||||||
--workers 4 \
|
|
||||||
--log-level info \
|
|
||||||
--access-log
|
|
||||||
ExecReload=/bin/kill -HUP $MAINPID
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=timemaster
|
|
||||||
|
|
||||||
# Sicherheit
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/opt/timemaster/backend
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
Reference in New Issue
Block a user