Skip to content

Last- und Sicherheitstests ​

Vor dem Launch wurden das öffentliche Collector-API unter Last gemessen und die öffentlichen Endpunkte mit OWASP ZAP und gezielten Prüfungen untersucht – beides gegen die Produktions-Images (docker-compose.prod.yml, APP_ENV=prod, FrankenPHP im Worker-Modus), nicht gegen den Dev-Stack.

WasWieWerkzeug
Lasttest + Rollup-Prüfungmake load-test (ci/load/run.sh)k6 grafana/k6:1.3.0, Details in ci/load/README.md
Security-Scanmake security-scan (ci/security/zap-scan.sh)ZAP ghcr.io/zaproxy/zaproxy:stable + k6, Details in ci/security/README.md

Beide laufen als eigenes Compose-Projekt pollyou-load (Ports 8690–8692, 5390–5391) und stören make prod-check nicht. Rohberichte (ZAP-HTML, k6-Messpunkte) werden nicht eingecheckt.

Ergebnis in Kürze ​

  • Ziel 500 Stimmen/s pro Instanz mit p95 < 150 ms: nicht erreicht. Das API selbst ist schnell (Median 3–4 ms, p95 im ruhigen Betrieb ~6,5 ms), aber unter Dauerlast gibt es wiederkehrende Stauphasen von 10–30 s, in denen alle Endpunkte gleichzeitig auf 0,5–3 s gehen. Bei 250 Stimmen/s wird das Ziel erreicht (p95 79 ms), bei 500 Stimmen/s nicht (p95 566 ms). Fehlerquote in allen Läufen 0 %.
  • Stauphasen behoben (Oktober 2026, Stauphasen: WAL und Redis): Ursache waren die synchronen Commits des Collectors (Warten auf den WAL-Flush, dabei gehaltene Zeilensperren) und die gemeinsame Redis-Instanz mit AOF und RDB-Snapshots. Mit asynchronen Commits nur für den Collector, PostgreSQL-Tuning und getrennten Redis-Instanzen: 250 Stimmen/s p95 8 ms ohne eine einzige Stauphase (vorher bis 32 s), 500 Stimmen/s p95 17 ms ohne Stauphase (vorher 74 s am Stück, p95 2,3 s).
  • Engpass Nr. 1 ist die Auswertungs-Pipeline (Messenger-Worker): Ein Worker schafft netto nur ~45 Stimmen/s; die Rollups hinken bei 250 Stimmen/s nach 4 Minuten Last über 10 Minuten hinterher, Bestätigungs-Mails stecken hinter der Warteschlange fest. Stand 12.10.2026: gebündelte Rollups, Prioritäts-Transport, skalierbare Worker und gebündelte Live-Ergebnisse mit Mercure transport local – ein Worker schafft ~315 Stimmen/s, mit 4 Workern bleiben 500 Stimmen/s ohne Rückstau (Rollup-Verzug 14 s, siehe Live-Ergebnisse und Mercure).
  • Rollups sind exakt: Nach dem Leerlaufen der Queue stimmen alle Rollups mit den Rohdaten überein (zwei Läufe mit 54 551 bzw. 97 496 Antworten und 286 391 bzw. 511 852 Events).
  • Security: keine offenen High-Befunde. Ein echter Fehler (500 statt 400 beim Login ohne JSON) und zwei Härtungen (Framing des Direktlinks, CSP der Share-Seite, Standard-Header des API) sind behoben; /s/ und /p/ haben inzwischen eine vollständige CSP; die übrigen Medium-Befunde sind bewusste Ausnahmen (siehe Tabelle).

Die Engpässe und offenen Punkte stehen als Tickets im Board (siehe Offene Punkte).

Maschine und Konfiguration ​

CPUAMD Ryzen 9 9900X, 12 Kerne / 24 Threads
RAM60 GiB
SystemLinux 7.2.9 (zen), Docker 29.8.2, alle Container auf einem Host
Commit62046ba (main)
API1 Container, FrankenPHP Worker-Modus, FRANKENPHP_WORKER_COUNT Standard = 2 × CPUs = 48 Worker
Worker1 Messenger-Worker (async + Scheduler), wie in Produktion
PostgreSQL17.11, Standardwerte: max_connections=100, shared_buffers=128MB, synchronous_commit=on
Redis7, appendonly yes (wie docker-compose.prod.yml)
Lastgeneratork6 im Docker-Netz desselben Rechners (bis 4000 VUs, ~0,6–2 Kerne, 3 GiB RAM)

Gemeinsam genutzter Rechner

Die Messungen liefen auf einer Entwickler-Workstation, auf der parallel andere Stacks und Testläufe aktiv waren (Last ~10–19). Absolute Zahlen sind daher eher pessimistisch, die Stauphasen können teilweise von dort stammen. Die Engpässe der Pipeline (Nachrichtenmenge, WAL-Flushes, Lock-Contention) sind davon unabhängig. Vor dem Launch auf der Zielhardware wiederholen.

Lasttest ​

Szenario ​

Ein virtueller Teilnehmer durchläuft den Ablauf des Embeds: Umfrage laden → Events impression/view → Session starten → Events start/page_view → (Umfrage: Seite 1 speichern + page_view) → abschließen → Event complete, mit 1–2 s Denkzeit pro Seite. Eine Iteration ist eine Stimme (≈ 6,6 Requests). 320 veröffentlichte Umfragen in 20 Organisationen (je 12 Polls und 4 zweiseitige Umfragen) – damit bleibt jede Organisation unter ihrem Rate-Limit und jede Umfrage unter der survey_spike-Schwelle (siehe unten).

Rate-Limits pro IP werden nicht verändert: k6 läuft im privaten Docker-Netz, dem das Produktions-Image als Proxy vertraut (TRUSTED_PROXIES=PRIVATE_SUBNETS), und setzt pro Teilnehmer eine zufällige X-Forwarded-For-Adresse aus 198.18.0.0/15.

Ergebnisse ​

LaufZielKonfigurationStimmenRequests/s (Mittel)p50p95p99FehlerRollup-Verzug
A500/sStandard107 30424134,3 ms566 ms1,55 s0 %nicht abgebaut¹
B250/sStandard54 5511192 (Plateau ~1625)2,9 ms79 ms508 ms0 %627 s²
C500/sPG synchronous_commit=off, 2 GB shared_buffers, 8 Consumer97 496220111 ms1,79 s3,19 s0 %123 s

Dauer jeweils 1 min Rampe + 3 min Plateau. ¹ Nach Lauf A lagen 555 000 Nachrichten in der Queue, der eine Worker baute sie praktisch nicht ab. ² Lauf B: Nach 9 Minuten waren noch 245 000 Nachrichten offen; erst mit 7 zusätzlichen Consumern war die Queue in 15 s leer (der Rest bestand fast nur aus AnalyticsChanged).

Pro Endpunkt liegen die Perzentile in allen Läufen fast gleichauf (Lauf A: p95 zwischen 522 ms für POST /events und 664 ms für GET /surveys/{publicId}; Mediane 1,6–8,7 ms, Abschluss am langsamsten). Das spricht gegen einen langsamen Endpunkt und für Stauphasen, die alle Requests gleichzeitig treffen.

Zeitverlauf Lauf B (10-s-Fenster): Im Plateau 1625 Requests/s mit p95 6–7 ms; dazwischen drei Stauphasen (bei 70 s, 120–140 s, 240 s) mit p95 bis 1,08 s. Sie fallen zeitlich mit Redis-AOF-Rewrites/RDB-Snapshots (Redis meldet aof-write-Latenzen bis 130 ms) und hoher WAL-Last von PostgreSQL zusammen; FrankenPHPs eigene Warteschlange blieb leer (queue_depth 0).

Ressourcen bei 500 Stimmen/s (Lauf A): API ⌀ 5,2 Kerne (max. 7,7), PostgreSQL ⌀ 5,0 (max. 8,9), Redis 0,2, Worker 0,2 – zusammen ~10,6 von 24 Kernen; RAM API 645 MiB, PostgreSQL 366 MiB, Redis 626 MiB (wächst mit der Queue). PostgreSQL wartete vor allem auf LWLock:WALWrite / IO:WalSync (Commits); mit synchronous_commit=off (Lauf C) verschwand das, stattdessen dominierte LWLock:LockManager (Sperren auf den vielen Partitionen und deren Indizes) – und die 8 Consumer konkurrierten zusätzlich mit dem API, daher war Lauf C insgesamt langsamer.

Rollup-Prüfung ​

ci/load/rollup-check.sql vergleicht nach dem Leerlaufen der Queue Starts, Abschlüsse und Dauersumme pro Umfrage und Stunde, Events pro Umfrage/Stunde/Typ, Antworten pro Frage/Option/Wert und die Buchhaltungsspalten (rollup_state, rolled_up).

LaufAntwortenEventsAntwortzeilenAbweichungenfehlgeschlagene Nachrichten
B54 551286 39195 45900
C97 496511 852170 61200

Auch doppelt zugestellte Nachrichten (mehrere Consumer mit gleichem Namen, siehe unten) änderten nichts – die Rollups sind idempotent.

Beobachtungen und Engpässe ​

  1. Nachrichtenmenge der Auswertung. Pro Stimme entstehen ~5 Rollup-Nachrichten (ResponseStarted, ResponseCompleted, 3–4 × SurveyEventsRecorded), und jede verarbeitete schickt ein AnalyticsChanged hinterher (Live-Modus), dazu PublishLiveResults/PublishAnalyticsUpdate – zusammen ~10 Nachrichten pro Stimme. Ein Worker verarbeitet brutto ~490 Nachrichten/s, die Queue schrumpft aber netto nur um ~56/s. Jede Rollup-Nachricht ist eine eigene Transaktion mit WAL-Flush.
  2. Mails hinter der Analytics-Queue. Während des Rückstands kam die Bestätigungs-Mail einer Registrierung nicht innerhalb von 60 s an (gleiche async-Queue, FIFO). Behoben: Mails, Webhooks und Benachrichtigungen laufen über den Transport async_priority, den jeder Worker vor async abarbeitet.
  3. Mehrere Consumer brauchen eigene Namen. Mit dem Standardnamen consumer holt ein zweiter Worker die offenen Nachrichten des ersten ab, beide verarbeiten sie, und das zweite Ack scheitert („Could not acknowledge redis message“, Worker beendet sich). Behoben: Jeder Container liest unter seinem Hostnamen, der Scheduler läuft in einem eigenen Container; Worker lassen sich beliebig skalieren (WORKER_REPLICAS).
  4. Virale Umfragen werden als Spam markiert. In einem Vorlauf mit nur 16 Umfragen (~8 Stimmen/s je Umfrage) markierte die Anomalie-Erkennung 86 % der Antworten als survey_spike (mehr als 120 Abschlüsse/min ohne Vorgeschichte); sie zählen in keinem Ergebnis. Behoben: Die Spitzenerkennung berücksichtigt jetzt die Verteilung auf Adressen und eine mitwachsende Grundlast, das Minimum hängt vom Plan ab und ist pro Umfrage einstellbar (siehe Missbrauchsschutz).
  5. Rate-Limit pro Organisation (damals fest 6000 Starts + Abschlüsse pro Minute) begrenzte eine Organisation auf ~50 Stimmen/s – über alle ihre Umfragen. Behoben: planabhängig, Standard 24.000 und intern 60.000 pro Minute (siehe Durchsatz je Plan); der Lasttest stellt seine Organisationen auf den Plan LOAD_PLAN um.
  6. Stauphasen (siehe Zeitverlauf): WAL-Flushes, Sperren auf Partitionen und Redis- Persistenz (AOF-Rewrite bei wachsender Queue) auf derselben Platte. Behoben, siehe Stauphasen: WAL und Redis.

Stauphasen: WAL und Redis ​

Nachmessung im Oktober 2026 (Commit-Basis 57ed1ac, gleiche Maschine, 2 Worker-Container, 48 FrankenPHP-Worker; je 1 min Rampe + 3 min Plateau; Auswertung ci/load/stalls.py: Stauphase = Sekunden, in denen das p95 aller Collector-Requests über 500 ms liegt). Geändert wurde:

  • Asynchrone Commits nur für den Collector (COLLECT_ASYNC_COMMIT=1): Session-Start, Zwischenspeichern, Abschluss und Events committen mit SET LOCAL synchronous_commit = off. Abwägung: Ein Absturz von PostgreSQL oder des Servers kann die Stimmen der letzten ≤ 600 ms verlieren (siehe Deployment).
  • PostgreSQL-Einstellungen für einen CPX41 (deploy/postgres/20-pollyou-tuning.sql: shared_buffers 4 GB, wal_buffers 64 MB, max_wal_size 8 GB, checkpoint_timeout 15 min, wal_compression=lz4, commit_delay 20 µs).
  • Zwei Redis-Instanzen (deploy/redis): Queue mit AOF everysec, ohne RDB-Snapshots und mit Rewrite erst ab 1 GB; Cache/Rate-Limiter ohne AOF, mit LRU-Verdrängung.
LaufZielKonfigurationp50p95p99maxs mit p95 > 500 mslängste Stauphaseerreicht¹Last (Median/Max)
D250/svorher (main)22 ms784 ms1,20 s1,91 s4632 s54 0778,6 / 15,7
E250/sneue PG-/Redis-Konfiguration, synchrone Commits18 ms818 ms1,14 s1,67 s6217 s53 88717,3 / 29,4
F250/sneue Konfiguration + asynchrone Commits2,3 ms8,2 ms9,9 ms44 ms0–54 7647,3 / 12,0
G500/svorher (main)653 ms2,29 s3,49 s4,25 s16374 s88 63622,7 / 35,5
H500/sneue Konfiguration + asynchrone Commits3,5 ms17 ms63 ms272 ms0–109 49916,7 / 25,2

¹ Abgeschlossene Iterationen (Stimmen) inkl. Rampe; Soll 54 764 bzw. 109 499. Lauf G verwarf 20 863 Iterationen, weil alle 4000 VUs in Stauphasen festhingen. Fehlerquote überall 0 %. Wiederholungen: F zweimal (p95 8,6 bzw. 8,2 ms, beide ohne Stauphase), D zweimal (19 s bzw. 32 s längste Phase). Eine erste Wiederholung von H (p95 725 ms) hatte eine Stauphase von 50 s (LWLock:LockManager, siehe unten), während die Last des Rechners durch andere Prozesse von 25 auf 52 stieg; die zweite Messung (Tabelle) blieb ohne Stauphase. Die Vorher-Läufe bei 500/s liefen teils parallel zu einem anderen Lasttest (eine Messung mit Last ~52 erreichte nur 186 Stimmen/s) – die Tabelle zeigt den ruhigeren Lauf.

Wartezustände (Summe aktiver PostgreSQL-Sessions über alle 5-s-Stichproben):

LaufLWLock:WALWriteIO:WalSyncLock:transactionidLWLock:LockManagerCPU
D57931412102
E53339273111
F6160080
G7433410241211
H412016250

CPU (Kerne im Plateau, Mittel/Max): API 2,9/4,2 und PostgreSQL 3,5/5,5 (D) gegenüber API 2,3/2,7 und PostgreSQL 3,0/3,8 (F); bei 500/s API 5,8/7,4 und PostgreSQL 7,2/9,3 (H, volle Rate) gegenüber 4,7/9,4 und 5,7/11,2 (G, nur 295 Stimmen/s). Redis-Instanzen je ≤ 0,2 Kerne.

Redis: Vorher in jedem Lauf 5 RDB-Snapshots und 8–10 AOF-Rewrites in ~5 min (Fork des ganzen Datenbestands samt Queue-Rückstand), aof-write-Spitzen 41–85 ms. Nachher keine Snapshots und keine Rewrites in der Queue-Instanz, die Cache-Instanz meldet in keinem Lauf Latenzereignisse > 25 ms. Die Queue-Instanz meldete in 2 von 5 Läufen einzelne aof-write-Spitzen (44–113 ms; das fsync teilt sich die Platte mit dem WAL) – ohne Auswirkung auf die API, da Rate-Limiter und Cache nicht mehr dort liegen.

Ursache und Wirkung: Die neue PostgreSQL-/Redis-Konfiguration allein (E) beseitigt die Redis-Spitzen, aber nicht die Stauphasen: Jede Collector-Transaktion wartet auf ihr eigenes WAL-fsync, hunderte pro Sekunde stauen sich auf WALWrite, und während des Wartens halten sie Zeilensperren (u. a. den Monatszähler der Organisation, Lock:transactionid), sodass sich ein kurzer fsync-Hänger zu 10–30 s Stau aufschaukelt. Erst die asynchronen Commits (F, H) entkoppeln die Antwortzeit vom fsync. Der Durchsatz der Platte bleibt gleich (~480 MB WAL pro Lauf bei 250/s, ~900 MB bei 500/s), die Commits warten nur nicht mehr darauf.

Verbleibend: LWLock:LockManager bei 500/s. Die Partitionen von response haben je 12 Indizes (5 Partitionen in einer frischen Datenbank: laufender Monat + Vorlauf). Abfragen wie SELECT … FROM response WHERE id = ? AND started_at >= ? schließen nur ältere Partitionen aus; die übrigen samt Indizes übersteigen die 16 „Fast-Path“-Sperren je Transaktion von PostgreSQL 17, sodass jede dieser Abfragen über den gemeinsamen Lock-Manager geht. Das trat nur in einer von zwei Messungen bei 500/s auf. Gegenmaßnahmen (eigene Tickets): obere Schranke für started_at in den Collector-Abfragen (Abfragen treffen nur noch die laufende Partition) und PostgreSQL 18 (dort wachsen die Fast-Path-Plätze mit max_locks_per_transaction). Umgesetzt und nachgemessen: Partitionssperren.

Auf der Zielhardware wiederholen. Diese Werte stammen von einer geteilten Workstation (24 Threads, NVMe, parallel laufende Stacks); ein CPX41 hat 8 vCPU. Vor dem Launch make load-test (250 und 500 Stimmen/s) auf einem Server gleicher Größe laufen lassen und stalls.txt, pg-activity.tsv und redis-latency.txt mit dieser Tabelle vergleichen. Der Rollup-Verzug ist davon unabhängig (Worker-Durchsatz, eigenes Ticket); die Läufe D–H brachen das Leerlaufen der Queue nach 30–60 s ab.

Rollup-Pipeline nach dem Umbau (12.10.2026) ​

Umgesetzt: Batch-Handler (bis 100 Rollup-Nachrichten in einer Transaktion, Ack erst nach dem Commit), Live-Modus ohne AnalyticsChanged (ein Mercure-Update pro Batch), Events pro Umfrage statt pro Request (SurveyEventsPending, höchstens eine Nachricht pro Umfrage und Sekunde). Details: backend/src/Analytics/README.md („Messages“). Gleiche Maschine, aber parallel lief zeitweise ein zweiter Lasttest eines anderen Stacks (Last 8–100); das API schaffte nur 280–345 statt 500 Stimmen/s.

LaufStandStimmen/s (erreicht)Nachrichten pro StimmeWorker brutto (Abbau ohne Last)Rollup-VerzugRollup-Prüfung
vorhere563840353~10 (7,2 bis Abbruch)~320 Nachr./s, netto ≈ 0nicht abgebaut–
1Batch, ohne AnalyticsChanged3015,7500–580 Nachr./s (≈ 95 Stimmen/s)> 600 s–
2+ Events pro Umfrage166 (zweiter Lasttest parallel)2,3~100 Nachr./s (Mercure-Latenz)674 sexakt
3+ ein Mercure-Update pro Batch3082,5110–330 Nachr./s626 sexakt
4wie 3, Mercure transport local3442,2~500 Nachr./s (≈ 225 Stimmen/s)366 sexakt
5wie 3, 4 Consumer285–~2000 Nachr./s zusammen127 sexakt
6nach Merge (RLS, Prioritäts-Transport), 1 Worker178 (Last 13–25)2,2100–300 Nachr./s638 sexakt, 0 Ack-Fehler

Neuer Engpass ist nicht mehr die Rollup-Pipeline (≈ 1,5 ms pro Stimme), sondern LiveResultsHandler (Ergebnisabfrage + Mercure-Publish pro Umfrage und Sekunde): Mercures bolt-Transport schreibt jedes Update mit fsync; auf der ausgelasteten Platte dauerte ein Publish 20–350 ms (Mittel 18,7 ms) statt ~1 ms. Während der Last selbst bekam der eine Worker neben API und k6 zu wenig CPU, die Queue wuchs bei allen Läufen; 500 Stimmen/s mit weniger als 60 s Verzug brauchen mehrere Consumer (Ticket Worker-Skalierung) und eine Mercure-Konfiguration ohne fsync pro Update (oder eigene Platte).

Live-Ergebnisse und Mercure (12.10.2026) ​

Umgesetzt: Mercure mit transport local (kein fsync pro Update, dev/prod/Lasttest/CapRover), Live-Ergebnisse umfrageübergreifend gebündelt (ein Veröffentlichungslauf pro Sekunde für alle Umfragen mit neuen Antworten, Ergebnisse aller Umfragen einer Organisation mit einer Abfrage, volle Ergebnisse nur bei öffentlicher Sichtbarkeit; Details backend/src/Collect/README.md „Live results“), Worker mit --keepalive und redeliver_timeout 300 s statt 3600 s (docs/guide/deployment.md „Worker“). Gleiche Maschine, diesmal ohne parallelen zweiten Lasttest (Last vor dem Start < 1), 500 Stimmen/s, 1 min Rampe, 3 min Plateau.

Gemessen im Worker (Lauf „vorher“, Mittel über die 100 größten Umfragen): ein Publish pro Umfrage kostete ~2,7 ms (Umfrage laden 0,5 ms, Ergebnisabfrage 1,8 ms, zwei Mercure-Requests mit transport local 0,4 ms). Weil jede der 320 Umfragen weniger als eine Stimme pro Sekunde bekam, griff die Drossel pro Umfrage kaum: etwa ein Publish pro Stimme – der größte Posten pro Stimme im Worker.

LaufStandWorkerStimmen/s (erreicht)API p95Nachrichten pro StimmeAbbau ohne Last (1 Worker)Rollup-VerzugRollup-Prüfung
vorher65c7402 + Mercure transport local15009,9 ms2,65~480 Nachr./s ≈ 180 Stimmen/s328 sexakt, 0 Ack-Fehler
nachhergebündelte Live-Ergebnisse, Keepalive1500 ¹– ¹2,46~780 Nachr./s ≈ 315 Stimmen/s170 sexakt, 0 Ack-Fehler
nachher+ volle Ergebnisse nur bei öffentlicher Sichtbarkeit450012,3 ms3,1 ²Rückstand nie über ~530 Nachr.14 sexakt, 0 Ack-Fehler

¹ Während des Laufs liefen auf derselben Maschine statische Analysen (PHPStan) – zwei Stauphasen (p95 bis 1 s); der Worker-Durchsatz im Abbau danach ist davon unberührt. ² Mehr Nachrichten pro Stimme, weil die Worker mithalten: Die Nachrichten pro Umfrage und Sekunde (SurveyEventsPending, verzögerte Live-Updates der Auswertung) fallen dann jede Sekunde an statt gebündelt hinter einem Rückstau.

Ergebnis: Pro Worker ~75 % mehr Stimmen/s (180 → ~315 im Abbau). 500 Stimmen/s mit 4 Workern: Die Queue läuft während der Last nicht voll (höchstens ~530 Nachrichten), der Rollup-Verzug nach Lastende beträgt 14 s (Ziel < 60 s), Rollups exakt, keine Stauphase, keine Ack-Fehler, keine Neustarts. Live-Ergebnisse und Live-Modus der Auswertung danach gegen denselben Stack geprüft (e2e/embed-live-results.spec.ts, e2e/analytics.spec.ts: grün).

Auf der Zielhardware (CPX41, 8 vCPU) offen: Hier teilten sich API, 4 Worker, PostgreSQL und Redis 24 Threads (13–21 Kerne belegt). Auf 8 vCPU reicht die CPU für 500 Stimmen/s plus 4 Worker voraussichtlich nicht; dort mit WORKER_REPLICAS=2…4 messen und den Rollup-Verzug gegen die API-Latenz abwägen (Worker eventuell auf einen eigenen Knoten).

Security-Scan ​

ZAP ​

Passiver Baseline-Scan auf Dashboard, Direktlink /s/, iframe-Seite /p/ und Share-Seite /r/; aktiver API-Scan auf das öffentliche API v1 (mit Lese-Key) und die öffentlichen Pfade (Collector, Auth, oEmbed – 37 URLs). Endstand nach den Korrekturen (keine High-Befunde, zap-scan.sh endet erfolgreich):

RisikoBefund (ZAP-Regel)WoBewertung
HighSQL Injection (40018)POST /api/auth/login, Feld passwordFehlalarm (in 2 von 6 API-Scans gemeldet, im Endstand als Fehlalarm markiert): Das Passwort erreicht nie SQL (Benutzer per E-Mail-Parameter, Hash-Vergleich in PHP). Die unterschiedlichen Antworten kamen vom Login-Captcha/Rate-Limit; mit curl liefern x' AND 1=1 -- und x' AND 1=2 -- identische 401. In rules.tsv/hooks.py dokumentiert.
MediumCSP: Wildcard Directive / style-src unsafe-inline / Failure to Define Directive (10055)/s/, /p/Vollständige CSP seit dem CSP-Ticket (default-src 'none', Script-Nonce, form-action 'none', Permissions-Policy). Übrig und bewusst: img-src https: (Bilder der Bildauswahl und Theme-Logos sind beliebige https-URLs; http: nur im HTTP-Teststack), style-src 'unsafe-inline' (Lit-Templates, Custom-CSS im Shadow DOM), frame-ancestors * bei uneingeschränkten Umfragen (Einbetten überall ist der Zweck). Akzeptiert.
MediumCSP: Wildcard Directive / style-src unsafe-inline (10055)DashboardBewusst: img-src https: für Bilder aus Bildauswahl-Fragen, style-src 'unsafe-inline' für PrimeVue. Akzeptiert.
MediumCSP: style-src unsafe-inline (10055)/r/Bewusst: die Balken der Share-Seite sind Inline-Styles; die Seite hat kein JavaScript. Akzeptiert.
MediumCSP Header Not Set (10038)API-Antworten (JSON)Für JSON ohne Wirkung. Akzeptiert.
LowPermissions-Policy fehlt (10063), Cross-Origin-Resource-/Embedder-/Opener-Policy (90004)API-Seiten, /s/, /p/Permissions-Policy fehlt nur noch auf API-Antworten (/, robots.txt, sitemap.xml: JSON/404). COEP/COOP/CORP auf /s/, /p/ bewusst nicht: COEP würde fremde Bilder ohne CORP blockieren, /p/ soll von fremden Seiten geladen werden. Akzeptiert.
LowUnexpected Content-Type (100001)404/405 auf erfundenen PfadenHTML-Fehlerseiten für Requests ohne Accept: application/json. Akzeptiert.
–Cross-Domain JavaScript (10017), Sub Resource Integrity fehlt (90003)/s/, /p/Fehlalarm/bewusst: embed.js kommt vom eigenen Embed-CDN; der Alias /v1/embed.js ändert sich mit jedem Release, versionierte Dateien haben Hashes in integrity.json.
–Timestamp Disclosure (10096)Captcha, expiresAtFehlalarm: gewollte API-Daten.

Vorher zusätzlich gefunden und behoben:

BefundKorrekturTest
POST /api/auth/login ohne JSON-Body (Formular, leer) → 500 (LogicException, CRITICAL im Log)400 „Expected a JSON body …“LoginTest::testRequestsWithoutJsonBodyAreRejectedWith400
Missing Anti-clickjacking Header auf /s/: Umfragen, deren Embeds alle auf bestimmte Origins beschränkt sind, ließen sich über den Direktlink trotzdem überall einbetten/s/ sendet frame-ancestors wie /p/ (aus den Embed-Konfigurationen), plus base-uri 'none'; object-src 'none'DirectLinkTest::testFramingFollowsTheEmbedConfigurations
Missing Anti-clickjacking Header + CSP Header Not Set auf /r/strikte CSP: default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'ShareLinkTest
X-Content-Type-Options fehlte auf JSON-Antworten, kein HSTS vom APIFrankenPHP-Caddyfile setzt X-Content-Type-Options: nosniff und Strict-Transport-Security: max-age=31536000, sofern die Anwendung sie nicht selbst setztci/security/checks.js

Gezielte Prüfungen ​

ci/security/checks.js (56 Prüfungen, alle grün):

  • CORS: Dashboard-API nur für die Dashboard-Origin (mit Credentials), fremde Origins ohne Access-Control-Allow-Origin; öffentliches API v1 ohne CORS; Collector für offene Umfragen jede Origin, nie mit Credentials; eingeschränkte Embeds lehnen fremde Origins ab (Preflight ohne ACAO, Request 403 origin_not_allowed).
  • Framing: /p/ frame-ancestors * bzw. nur die erlaubten Origins; /s/ folgt den Embed-Konfigurationen; /r/ und Dashboard nie (frame-ancestors 'none', Dashboard zusätzlich X-Frame-Options: DENY).
  • Header: Dashboard mit CSP, HSTS, nosniff, Referrer-Policy, ohne Server; API und Direktlink mit nosniff und HSTS; keine Stacktraces, kein Profiler.
  • Cookies: Refresh-Cookie HttpOnly; Secure; SameSite=Strict; Path=/api/auth; Direktlink setzt keine Cookies.
  • Rate-Limits (429 ab Versuch): Login 6, Registrierung 11, Passwort vergessen 21, Collector schreiben 61 / Events 121 / lesen 301 (je IP und Umfrage), Share-Link-Passwort 11, Public-API 601 pro Key und Minute.
  • Open Redirects: OAuth-returnTo (//evil, absolute URLs, /\, CRLF) führt nie auf fremde Hosts; Export-Download mit gefälschter Signatur → 403 ohne Weiterleitung.
  • SSRF: Webhook-Ziele http://, 127.0.0.1, localhost, 10.0.0.5, 169.254.169.254, [::1] und file:// werden beim Speichern mit 422 abgelehnt. Hostnamen (auch interne wie api und Schreibweisen wie 0x7f000001, die kein IP-Literal sind) werden erst vor jeder Zustellung aufgelöst: Das Speichern gelingt, das Testereignis endet mit „Blocked: …“ ohne Request. Die Zustellung pinnt die geprüfte IP und folgt keinen Weiterleitungen.

Externer Pentest (Empfehlung) ​

Vor dem öffentlichen Launch einen externen Pentest beauftragen (Grey-Box, ca. 5–8 Personentage), Schwerpunkte:

  • Mandantentrennung: Organisationswechsel per X-Organization-Id, Rollen/Permissions, IDOR auf allen /api/*-Ressourcen, API-Keys mit Scopes, Export- und Share-Links.
  • Authentifizierung: Refresh-Token-Rotation, 2FA, OAuth (Account-Linking, state/PKCE), Passwort-Reset und E-Mail-Wechsel.
  • Öffentliche Fläche: Collector (Session-Tokens, Identity-Tokens, Dedup-Umgehung, Captcha), Embed im fremden DOM (XSS über Umfrageinhalte, Themes, Markdown), oEmbed, Badges.
  • Infrastruktur: CapRover/nginx, TLS, Header auf allen Subdomains, Mercure (anonyme Subscriber, private Topics), Gotenberg/Webhook-SSRF, Backups.

Grundlage: diese Seite, die OpenAPI-Dokumente und ein Staging-Zugang mit Testkonten.

Partitionssperren (12.10.2026) ​

Umgesetzt (44be27a, Details backend/src/Collect/README.md „Partition pruning and locks“): konstante Schranken auf dem Partitionsschlüssel in allen heißen Abfragen (Pruning schon beim Planen, 1–2 Partitionen je Tabelle statt aller), Mandanten-Lookup per dynamischem SQL, fünf Indizes weniger auf den partitionierten Tabellen, nur noch zwei Monate Vorlauf.

Vergleich: „vorher“ = main (542f150) mit zurückgenommenem Commit 44be27a (Code und Migration; alles andere – asynchrone Commits, getrennte Redis-Instanzen, PostgreSQL-Tuning, Mercure transport local, gebündelte Live-Ergebnisse – identisch), „nachher“ = main. Beide mit WORKER_REPLICAS=4 und HISTORY_MONTHS=12 (ci/load/history.sql: 12 vergangene Monatspartitionen mit je 20 000 Antworten, 40 000 Antworten-Zeilen und 60 000 Events – wie nach einem Jahr Betrieb; damit 16 Partitionen je Tabelle in beiden Varianten), 1 min Rampe

  • 3 min Plateau, Läufe abwechselnd. Wartezustände = Summe aktiver PostgreSQL-Sessions über alle 5-s-Stichproben (pg-activity.tsv, ~43 Stichproben je Lauf); Last = Lastmittel (1 min) des Rechners während des Laufs (Median/Max, inklusive des Stacks selbst; 24 Threads).
LaufZielStandLWLock:LockManager (Stichproben > 0, max. gleichzeitig)CPU (aktiv)andere Waits¹p50p95p99s mit p95 > 500 ms (längste Phase)erreicht²PostgreSQL Kerne³Last (Median/Max)
1500/svorher306 (24, 27)46551281 ms2,09 s3,52 s114 (113 s)94 83210,230,9 / 59,5
2500/snachher26 (7, 10)3266011 ms208 ms277 ms0109 4198,430,4 / 64,2
3500/snachher26 (7, 9)36074835 ms1,87 s⁴2,61 s201 (163 s)⁴92 5737,170,8 / 82,8
4500/svorher362 (25, 26)45237936 ms1,78 s3,29 s208 (212 s)91 5419,640,5 / 59,5
9500/snachher0239614,0 ms17 ms34 ms0109 4998,012,8 / 28,8
10500/svorher335 (26, 26)46948630 ms1,26 s1,88 s124 (124 s)99 90710,632,3 / 55,1
11500/svorher354 (26, 26)47445304 ms708 ms758 ms66 (64 s)107 97211,330,0 / 51,7
12500/snachher0248693,8 ms16 ms31 ms0109 4998,119,6 / 36,5
5250/svorher16 (3, 14)174413,3 ms242 ms526 ms6 (6 s)54 4945,418,8 / 31,9
6250/snachher3 (1, 3)103393,4 ms33 ms71 ms054 7643,817,4 / 56,7
7250/snachher0101202,2 ms7,6 ms9,3 ms054 7643,08,0 / 16,1
8250/svorher0120342,6 ms12 ms14 ms054 7644,67,8 / 10,5

Reihenfolge: 1–8 nacheinander, danach 9–12 (ruhigerer Rechner). ¹ Summe aller übrigen Waits, fast nur Client:ClientRead (17–52, Sessions zwischen zwei Statements einer Transaktion) und Lock:transactionid (0–20, Monatszähler der Organisation); IO:WalSync, WALWrite, BufferContent je ≤ 8. ² Abgeschlossene Iterationen (Stimmen) inkl. Rampe; Soll 109 499 bzw. 54 764, der Rest wurde verworfen, weil die VUs in Stauphasen festhingen. ³ Mittel des oberen Zwei-Drittels der docker stats-Stichproben (≈ Plateau). ⁴ Lauf 3 lief, während andere Prozesse den Rechner auslasteten (Lastmedian 71 statt 13–40; parallel u. a. Desktop-Indexer und Builds anderer Arbeitsverzeichnisse): Stau durch CPU-Mangel, nicht durch Sperren (nur 26 LockManager-Stichproben). Fehlerquote überall 0 %, Rollup-Prüfung in allen 12 Läufen exakt, Rollup-Verzug 7–31 s, keine Ack-Fehler, keine Worker-Neustarts.

Ergebnis bei 500 Stimmen/s: LWLock:LockManager sinkt von 306–362 auf 0–26 (−93 bis −100 %). Vorher warteten in praktisch jeder Plateau-Stichprobe ~26 Sessions auf die Lock-Manager-Partitionen – mehr als die Hälfte aller aktiven Sessions; nachher tritt der Wait nur noch vereinzelt auf. Damit verschwinden die Stauphasen: In allen vier Vorher-Läufen 66–212 s mit p95 > 500 ms (p95 0,7–2,1 s, bis 18 000 verworfene Stimmen), in den Nachher-Läufen auf ruhigem Rechner keine (p95 16–17 ms, p99 31–34 ms, volle Rate). PostgreSQL braucht nachher ~8 statt ~10–11 Kerne, die aktiven Sessions (CPU) halbieren sich etwa (239–360 statt 452–474). Bei 250 Stimmen/s spielt der Lock-Manager kaum eine Rolle (vorher 0–16, nachher 0–3); die Nachher-Läufe sind trotzdem etwas schneller (p95 7,6 statt 12 ms im ruhigen Paar 7/8, PostgreSQL 3,0–3,8 statt 4,6–5,4 Kerne: weniger Partitionen geplant und gesperrt, weniger Indizes gepflegt).

Offene Punkte ​

Als Tickets erfasst:

  • Rollup-Pipeline: Nachrichtenmenge reduzieren, Rollup-Nachrichten bündeln. (erledigt, siehe oben)
  • Live-Ergebnisse: Mercure-Transport ohne fsync pro Update und Ergebnisabfrage pro Publish verbilligen. (erledigt, siehe Live-Ergebnisse und Mercure)
  • 500 Stimmen/s mit Workern auf der Zielhardware (8 vCPU) wiederholen.
  • Eigener Transport mit Priorität für Mails und Webhooks. (erledigt)
  • Worker skalierbar machen: eindeutige Consumer-Namen, Scheduler in eigenem Container. (erledigt)
  • Anomalie-Erkennung für virale Umfragen anpassen (survey_spike).
  • Rate-Limit pro Organisation konfigurierbar/planabhängig.
  • Stauphasen: PostgreSQL-Tuning (WAL), Redis für Queue und Cache trennen. (erledigt, siehe Stauphasen: WAL und Redis)
  • Lock-Manager-Konkurrenz auf den Partitionen bei 500 Stimmen/s: obere Schranke für started_at in Collector-Abfragen bzw. PostgreSQL 18. (erledigt, siehe Partitionssperren)
  • Lasttest auf der Zielhardware (CPX41) wiederholen.