fix(security): Audit-Nachzug – Rate-Limit, X-Real-IP-Vertrauen, Indizes
Security-Audit 2026-09-10 (siehe Ed25519-Reinstall-Commits): - Rate-Limit 10/min auf POST /kiosk/my-qr-token (Q-1) – kompromittiertes Employee-JWT konnte sonst unbegrenzt QR-Login-Tokens erzeugen. - _get_client_ip vertraut X-Real-IP nur noch, wenn die Verbindung selbst von 127.0.0.1 kommt (I-1) – sonst könnte ein Client den Header selbst setzen und die Kiosk-IP-Whitelist umgehen. - Migration 0047: Unique-Index auf kiosk_devices.device_token_hash (B-2, war Seq-Scan pro Bearer-Request), Index auf time_entries.kiosk_device_id (K-1). RLS auf kiosk_devices bereits seit Migration 0024 vorhanden, K-1-Verdacht einer RLS-Lücke damit ausgeräumt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
This commit is contained in:
1 parent
7873236de3
commit
1d163cc299
3 files changed
+52
-10
No files matched your search
@@ -116,18 +116,23 @@ def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
|
||||
|
||||
# ── Client-IP ermitteln (nginx-Proxy-sicher) ─────────────────────────────────
|
||||
|
||||
_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Liest die echte Client-IP auch hinter nginx-Proxy.
|
||||
|
||||
nginx setzt X-Real-IP auf die echte Client-IP.
|
||||
Ohne diesen Header würde request.client.host hinter nginx immer
|
||||
127.0.0.1 zurückgeben, womit IP-Whitelisting wirkungslos wäre.
|
||||
nginx setzt X-Real-IP auf die echte Client-IP. Der Header wird NUR
|
||||
akzeptiert, wenn die Verbindung selbst von nginx auf demselben Host
|
||||
kommt (127.0.0.1) – sonst könnte ein Client den Header selbst setzen
|
||||
und die IP-Whitelist trivial umgehen (Security-Audit 2026-09-10, I-1).
|
||||
"""
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
# Fallback: direkte Verbindungs-IP (lokal/dev)
|
||||
return request.client.host if request.client else "unknown"
|
||||
direct_ip = request.client.host if request.client else None
|
||||
if direct_ip in _TRUSTED_PROXY_IPS:
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return direct_ip or "unknown"
|
||||
|
||||
|
||||
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,12 +2,13 @@ import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.dependencies import CurrentUser, require_role
|
||||
from app.core.kiosk_security import verify_kiosk_request
|
||||
from app.core.limiter import limiter
|
||||
from app.models.company import Company
|
||||
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
||||
from app.models.user import User, UserRole
|
||||
@@ -49,7 +50,8 @@ _admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||
# ── Mitarbeiter-QR-Login (normale JWT-Auth, für die Employee-App) ────────────
|
||||
|
||||
@router.post("/my-qr-token", response_model=KioskQrTokenResponse)
|
||||
async def get_my_qr_token(current_user: CurrentUser):
|
||||
@limiter.limit("10/minute")
|
||||
async def get_my_qr_token(request: Request, current_user: CurrentUser):
|
||||
"""Erzeugt einen 5 min gültigen, einmalig einlösbaren QR-Login-Token für
|
||||
den eingeloggten Mitarbeiter – die Employee-App zeigt ihn als QR-Code,
|
||||
der Kiosk scannt ihn (POST /kiosk/auth/qr) und meldet den Mitarbeiter an."""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""kiosk_devices.device_token_hash: Unique-Index
|
||||
|
||||
Revision ID: 0047
|
||||
Revises: 0046
|
||||
Create Date: 2026-09-10
|
||||
|
||||
Security-Audit 2026-09-10 (B-2): Lookup in _verify_bearer_device lief bisher
|
||||
ohne Index (Seq-Scan pro Kiosk-Request), zudem keine Eindeutigkeitsgarantie.
|
||||
Partial Index (nur wo NOT NULL, da die meisten Geräte ed25519 nutzen und
|
||||
dort kein Token gesetzt ist).
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0047"
|
||||
down_revision = "0046"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"CREATE UNIQUE INDEX ix_kiosk_devices_device_token_hash "
|
||||
"ON kiosk_devices (device_token_hash) WHERE device_token_hash IS NOT NULL"
|
||||
)
|
||||
# Gleicher Audit-Fund (K-1): kiosk_device_id auf time_entries hatte
|
||||
# ebenfalls keinen Index (Migration 0046).
|
||||
op.execute(
|
||||
"CREATE INDEX ix_time_entries_kiosk_device_id "
|
||||
"ON time_entries (kiosk_device_id) WHERE kiosk_device_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_time_entries_kiosk_device_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_kiosk_devices_device_token_hash")
|
||||
Reference in new issue
Block a user