Neu veröffentlicht: E-Commerce mit Power Pages, Stripe & Analytics

· DevOps  · 8 minuten Lesezeit

Redis ohne Auth und Qdrant mit offenem Admin-Port vor dem Launch

Vor dem Launch habe ich meine Architektur nicht nur auf Skalierung geprüft, sondern auch auf stillschweigende Sicherheitsannahmen. Dabei sind mir zwei kleine Lücken aufgefallen, die in Produktion sehr schnell teuer werden können.

Vor dem Launch habe ich meine Architektur nicht nur auf Skalierung geprüft, sondern auch auf stillschweigende Sicherheitsannahmen. Dabei sind mir zwei kleine Lücken aufgefallen, die in Produktion sehr schnell teuer werden können.

Inhalt

Der Ausgangspunkt einer Architekturprüfung

Bevor ich ein System in Produktion schiebe, stelle ich mir eine einfache Frage: Würde diese Architektur auch dann noch tragen, wenn morgen nicht hundert, sondern hunderttausend Nutzer auftauchen und ich an der Infrastruktur nichts Grundlegendes ändere?

Diese Frage ist absichtlich unbequem. Sie zwingt mich dazu, nicht nur auf CPU, RAM und horizontale Skalierung zu schauen, sondern auch auf Annahmen, die heute harmlos wirken und morgen riskant werden.

Genau in so einer Prüfung sind mir zwei Punkte aufgefallen. Beide waren klein. Beide liefen lokal sauber. Beide hatten auf den ersten Blick nichts mit Performance zu tun. Aber beide hatten das Potenzial, in Produktion zu einem echten Sicherheitsproblem zu werden.

Redis ohne Passwort

In meinem aktuellen docker-compose.yml startete Redis so:

redis:
  command: redis-server --appendonly yes

Innerhalb eines einzelnen Docker-Netzwerks auf einer einzelnen VM ist das technisch erst einmal vertretbar. Der Container ist nicht direkt aus dem Internet erreichbar. Docker kapselt das Netz intern. Solange alle beteiligten Services auf genau diesem Host laufen, fühlt sich das sicher genug an.

Genau hier beginnt aber die gefährliche Denkweise. Sicher, weil isoliert, ist keine Eigenschaft des Dienstes selbst. Es ist nur eine Eigenschaft der aktuellen Topologie.

Sobald die Architektur wächst, wird diese Annahme brüchig. In einer späteren Phase mit mehreren VMs, etwa wenn Qdrant auf einen eigenen Host zieht und Redis vielleicht ebenfalls ausgelagert wird, sprechen die Services nicht mehr über ein rein internes Docker-Netzwerk. Dann läuft der Verkehr über das private Netzwerk des Infrastruktur-Anbieters.

Ab diesem Punkt kann jeder Prozess auf diesem privaten Netz theoretisch versuchen, sich mit Redis zu verbinden. Ohne Authentifizierung bedeutet das: Queue lesen, Queue manipulieren, Queue leeren.

Bei BullMQ ist das nicht nur ein Betriebsproblem, sondern auch ein Datenschutzproblem. Der Job-Payload in Redis enthält Metadaten, die ich nicht offen herumliegen lassen will:

// Payload stored in Redis queue per job
{
  captureId: string,
  userId: string,
  blobName: string,
  // ... full QdrantPayload
}

Je nach Job liegen dort zusätzlich weitere Teile des QdrantPayload. Wer Zugriff auf Redis hat, kann also sehen, welcher Nutzer welche Datei gespeichert hat, welche S3 Keys verwendet werden und welche Jobs gerade in der Pipeline stehen. Im schlimmsten Fall leert ein Angreifer die Queue oder verändert Jobs gezielt.

Für mich ist das keine harmlose Fehlkonfiguration mehr. Eine offengelegte Redis-Queue ist ein Datenleck.

requirepass mit env-var-Default

Die saubere Lösung war überraschend klein. Ich wollte dieselbe Compose-Datei lokal und in Produktion behalten und nur über Umgebungsvariablen steuern, ob Redis Auth verlangt oder nicht.

So sieht die Compose-Änderung aus:

# docker-compose.yml
redis:
  # REDIS_PASSWORD unset -> requirepass "" -> Redis accepts connections without auth (local dev).
  # REDIS_PASSWORD=<secret> -> Redis requires AUTH on every connection (production).
  command: redis-server --appendonly yes --appendfsync everysec --requirepass "${REDIS_PASSWORD:-}"
  healthcheck:
    # Pass password to redis-cli; works whether or not requirepass is set
    test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-}', 'ping']

Im Backend nutze ich dieselbe Variable direkt in der Verbindungs-URL:

# docker-compose.yml
backend:
  environment:
    - REDIS_URL=redis://:${REDIS_PASSWORD:-}@redis:6379

Der wichtige Teil ist :-. Das ist der Default-Operator in Compose-Interpolation.

  1. Lokal ist REDIS_PASSWORD nicht gesetzt.
  2. Compose ersetzt den Ausdruck dann durch einen leeren String.
  3. Redis bekommt effektiv requirepass "" und verlangt damit kein Passwort.
  4. Die URL wird zu redis://:@redis:6379.
  5. Der Redis-Client sieht ein leeres Passwort und sendet kein AUTH.
  6. Alles funktioniert wie bisher.

In Produktion läuft es genauso, nur mit gesetzter Variable:

  1. REDIS_PASSWORD enthält ein echtes Secret.
  2. Redis startet mit --requirepass und erzwingt Authentifizierung.
  3. Die Backend-URL enthält das Passwort.
  4. Der Client authentifiziert sich automatisch.

Für mich ist genau das die elegante Variante. Eine Compose-Datei, ein Codepfad, zwei Umgebungen. Das Verhalten ändert sich nur über die Variable.

In .env.example würde ich das explizit dokumentieren:

# REDIS_PASSWORD is optional in local dev (empty = no auth required).
# In production, always set a strong password.
# REDIS_PASSWORD=<generate with: openssl rand -hex 32>

Damit ist die unsichtbare Annahme aus der Infrastruktur herausgezogen und in eine bewusste Sicherheitskonfiguration verwandelt.

Qdrant mit offenem Admin-Port

Das zweite Problem war noch unscheinbarer. In der Compose-Datei stand bei Qdrant:

qdrant:
  ports:
    - '6333:6333'
    - '6334:6334'

Port 6333 ist die REST API. Port 6334 ist die gRPC-Schnittstelle von Qdrant. Sie wird vor allem für Cluster-Kommunikation zwischen Qdrant-Knoten genutzt. In einem Single-Node-Setup stellt sie funktional dieselben Datenoperationen bereit, nur eben über gRPC statt HTTP.

Genau deshalb ist dieser Port in meinem Setup unnötig. Ich betreibe keinen Qdrant-Cluster. Ich habe keinen zweiten Node, der über diesen Port koordinieren müsste.

Wenn ich 6334:6334 in Compose angebe, bindet Docker den Port auf 0.0.0.0 des Hosts. Damit hängt er auf allen Netzwerkschnittstellen der VM, also auch auf der öffentlichen IP.

Natürlich kann eine Firewall diesen Port blockieren. Aber genau darauf allein will ich mich nicht verlassen. Firewalls können falsch konfiguriert werden. Regeln können versehentlich erweitert werden. Infrastruktur ändert sich. Defense in depth bedeutet für mich, unnötige Angriffsfläche gar nicht erst zu veröffentlichen.

Hinzu kommt ein strukturelles Problem: Qdrant OSS bringt keine eingebaute Authentifizierung mit. Wer Port 6333 oder 6334 erreicht, kann lesen, schreiben und löschen. Collections, Vektoren, Payloads mit userId, Metadaten, alles liegt offen.

Die unmittelbare Korrektur ist deshalb banal:

qdrant:
  ports:
    - '6333:6333'
    # Port 6334 intentionally omitted. It is only needed for Qdrant cluster mode.
    # Exposing it on the host adds unnecessary attack surface.

Der Backend-Container spricht intern ohnehin mit http://qdrant:6333. Dafür braucht er keinen Host-Port 6334. Die Host-Freigabe war nur für lokale Diagnose praktisch. Genau solche Bequemlichkeiten will ich aber nicht versehentlich in Produktion mitschleppen.

Was bleibt

Auch nach dieser Korrektur bleibt eine unbequeme Wahrheit bestehen: Qdrant OSS hat keine native Auth.

Wenn Port 6333 in Produktion offen im Internet läge, wäre das ein ernstes Problem. In meinem Setup entsteht die Sicherheit deshalb aus mehreren Schichten.

  1. Die Hetzner Firewall blockiert direkte Zugriffe aus dem Internet auf Port 6333.
  2. Qdrant läuft im privaten Netzwerk und ist damit nicht für beliebige externe Clients erreichbar.
  3. Nur das Backend spricht mit Qdrant und erzwingt die Trennung pro Nutzer über eigene Collections.

Diese Schichten sind gut, aber sie sind nicht perfekt. Sie ersetzen keine echte Auth auf Datenbankebene. Wenn das Thema mit wachsender Nutzerzahl kritischer wird, sind für mich zwei Wege naheliegend: Qdrant Cloud mit API-Key im EU-Standort oder ein zusätzlicher Reverse Proxy, der nur Requests aus dem Backend-Kontext annimmt.

Mir ist wichtig, das offen zu benennen. Port 6334 zu entfernen löst nicht jedes Problem. Es entfernt nur unnötige Angriffsfläche und macht die bestehende Lage deutlich besser.

Warum das vor dem Launch wichtig ist

Solche Änderungen wirken im Vorfeld fast lächerlich klein. Es sind ein paar Zeilen YAML und eine zusätzliche Umgebungsvariable.

Vor dem Launch bedeutet das vielleicht zwanzig Minuten Arbeit. Nach dem Launch kann derselbe Fehler plötzlich eine ganz andere Größenordnung haben.

Dann geht es nicht mehr nur um Konfiguration. Dann geht es um Log-Auswertung, Rekonstruktion des Vorfalls, interne Dokumentation, mögliche Benachrichtigung von Betroffenen und im Ernstfall um eine Meldung nach DSGVO Art. 33 innerhalb von 72 Stunden an die zuständige Aufsichtsbehörde.

Genau deshalb nehme ich solche Dinge vor dem Go-Live ernst. DSGVO Art. 25 spricht von Datenschutz durch Technikgestaltung und durch datenschutzfreundliche Voreinstellungen. Für mich heißt das sehr praktisch: Die Default-Konfiguration darf nicht nur bequem sein. Sie muss in der Zielumgebung sicher sein.

Das vollständige Bild

Nach beiden Korrekturen sieht der relevante Ausschnitt so aus:

# docker-compose.yml
redis:
  command: redis-server --appendonly yes --appendfsync everysec --requirepass "${REDIS_PASSWORD:-}"
  healthcheck:
    test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD:-}', 'ping']

backend:
  environment:
    - REDIS_URL=redis://:${REDIS_PASSWORD:-}@redis:6379

qdrant:
  ports:
    - '6333:6333'
    # 6334 removed

Lokal bleibt alles reibungslos. Ich setze keine zusätzliche Variable und der Stack verhält sich wie bisher.

In Produktion setze ich REDIS_PASSWORD, starte Redis neu und habe sofort eine deutlich robustere Ausgangslage. Gleichzeitig verschwindet mit dem offenen gRPC-Port ein unnötiger Eintrittspunkt.

Für mich ist das genau die Art von Sicherheitsarbeit, die vor dem Launch passieren muss. Nicht spektakulär. Nicht marketingtauglich. Aber im Zweifel der Unterschied zwischen einem sauberen Start und einem sehr unangenehmen ersten Incident.

Redis mit Passwortschutz und reduzierter Qdrant-Angriffsfläche in Docker Compose

Die Grafik zeigt zwei kleine Konfigurationsänderungen mit großer Wirkung: Redis verlangt in Produktion ein Passwort, und Qdrant veröffentlicht nur noch den wirklich benötigten Port. Lokal bleibt der Stack bequem, in Produktion fällt unnötige Angriffsfläche weg.


Alle Artikel der Serie

  1. Vision und Systemübersicht: Chrome Extension, RAG-Architektur, Projekthintergrund: Artikel lesen
  2. RAG-System Aufbau: Qdrant, Embeddings, Cosine-Ähnlichkeit in TypeScript: Artikel lesen
  3. AI Provider Abstraktion: Ollama vs. OpenAI, Interface-Design, kein Vendor-Lock-in: Artikel lesen
  4. Chrome Extension MV3: Drei isolierte Laufzeitkontexte, Message Passing, Strategy Pattern: Artikel lesen
  5. Docker Compose Strategie: Override-Pattern, von lokal zu Azure: Artikel lesen
  6. Ollama lokal vs. Docker: Die Entscheidung und ihre Konsequenzen: Artikel lesen
  7. Ollama Auto-Pull Entrypoint: Automatisiertes Modell-Setup beim Container-Start: Artikel lesen
  8. tsconfig und Vite: Node16 vs. bundler, warum Vite eigene Regeln hat: Artikel lesen
  9. Instagram Caption mit MutationObserver vollständig laden: Artikel lesen
  10. Chrome Extension Foundation mit Health-Dot und Retry-Queue: Artikel lesen
  11. Phase 2 Features: Shadow DOM Overlay, Tailwind v4, Duplicate Detection: Artikel lesen
  12. Race Condition bei der Plattformerkennung: Wie ein UI-Event die Instagram-Erkennung bricht: Artikel lesen
  13. PostId-Extraktion in zwei Instagram-Layouts: querySelector vs. Ancestor-Traversal: Artikel lesen
  14. Instagram Karussell vollständig erfassen mit MutationObserver: Lazy-Loading, Observer-before-click, Timeout-Fallback: Artikel lesen
  15. Notiz und Tags beim Screenshot-Speichern: Artikel lesen
  16. Instagram Tastatur-Shortcuts blockieren Chrome Extension Eingaben: Artikel lesen
  17. Lowercase-Normalisierung und Duplikat-Erkennung im Tag-Input: Artikel lesen
  18. Zitadel Login V2 in Docker Compose: drei versteckte Fehler: Artikel lesen
  19. PKCE OAuth in einer Chrome MV3 Extension: Artikel lesen
  20. React Frontend mit react-oidc-context und Zitadel: Artikel lesen
  21. Vite Build-Time-Umgebungsvariablen in Docker: Artikel lesen
  22. Event-Driven Ingestion mit BullMQ und Redis: Artikel lesen
  23. MinIO statt Azurite: S3-kompatible Objektspeicherung lokal und auf Hetzner: Artikel lesen
  24. access_token, id_token und der Userinfo-Endpoint: was wohin gehört: Artikel lesen
  25. Qdrant Multi-Tenancy: Pro Nutzer eine eigene Collection: Artikel lesen
  26. Wenn Backend und Frontend unterschiedliche Typen kennen: Artikel lesen
  27. Zitadel Bootstrap entfernt: Host-Header-Bug und manuelles Setup: Artikel lesen
  28. Backend Code Review: sechs Probleme vor dem Launch behoben: Artikel lesen
  29. Traefik statt NGINX: Reverse Proxy für einen wachsenden Docker-Compose-Stack: Artikel lesen
  30. Zweischichtiges Rate Limiting: Traefik und express-rate-limit mit Redis: Artikel lesen
  31. DSGVO Art. 17 korrekt implementieren: Promise.allSettled und Export-Batching: Artikel lesen
  32. Embedding-Modell-Lock-in: Warum mxbai-embed-large eine Produktionsentscheidung für immer ist: Artikel lesen
  33. Docker Volumes in Produktion: Named Volumes, Bind Mounts und der Hetzner-Volume-Trick: Artikel lesen
  34. Zwei Sicherheitslücken vor dem Launch: Redis ohne Auth und ein offener Qdrant-Admin-Port: (dieser Artikel)

Du baust gerade ein ähnliches System und überlegst, welche Entscheidungen für dein Projekt passen? Lass uns das gemeinsam einschätzen.

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Traefik statt NGINX für einen wachsenden Docker-Compose-Stack

Traefik statt NGINX für einen wachsenden Docker-Compose-Stack

Ab acht Services im Docker-Compose-Stack wird nginx.conf zur Wartungslast. Traefik liest Service-Konfiguration direkt aus Docker-Labels, terminiert TLS automatisch über ACME und braucht keine separate Konfigurationsdatei. Warum ich gewechselt habe und wie die Konfiguration aussieht.