# Sicherheitsaudit sharely – Stand nach Behebung **Stand:** 23.10.2026, `main` @ `daddb0b` (Merge von PR #203), CI auf `main` grün (Unit + E2E). Live-Prüfung auf der eigenen Instanz abgeschlossen (siehe unten). **Ursprüngliches Audit:** 22.10.2026, `main` @ `d9ae6b5`. **Methode der Nachprüfung:** - Jeder Fix wurde einzeln gegen den Code geprüft. - Unit-Tests (`npm test`: 50 Prüfungen, davon 30 zur Verschlüsselung) und E2E-Tests (Playwright, 21 Tests mit MongoDB in GitHub Actions) bestehen. - Zusätzliche lokale Integrationstests: - Datei-Auslieferung, Verschlüsselung und Download-Limits mit echtem Express-Server, echtem ffmpeg und einem Datenbank-Ersatz im Speicher (23 Prüfungen). - WebSocket-Limits und Origin-Prüfung mit echter Session-Middleware. - Setup-Token. - Updater-Rechte als Nicht-root-Nutzer. - XSS-Nachstellung und Header-Verhalten in Chromium 1194. **Nicht prüfbar:** - Die Live-Instanz `sharely.christian.pizza` war aus der Audit-Umgebung nicht erreichbar. Die Live-Prüfung hat der Betreiber nach Anleitung selbst durchgeführt (Abschnitt „Live-Prüfung“). - Die Upload-Routen und das Migrationsskript liefen in der Audit-Umgebung nicht gegen eine echte MongoDB. Auf der Live-Instanz hat der Betreiber die Migration inzwischen ausgeführt (Abschnitt „Live-Prüfung“). --- ## Zusammenfassung | | Anzahl | |---|---| | Funde im ursprünglichen Audit | 22 (2 kritisch, 4 hoch, 8 mittel, 1 DSGVO-mittel, 5 niedrig, 2 Info) | | Vollständig behoben | 19 | | Behoben mit verbleibendem Restrisiko | 3 (H2, H4, D3) | | Zusätzliche Funde beim Beheben | 5 (alle behoben) | | Neu eingeführter und wieder behobener Fehler | 1 (E2E-Timeout nach Session-Rotation) | | Betriebsvorfall durch einen Fix | 1 (502 nach Port-Bindung an Loopback, behoben über Konfiguration und Doku) | Alle kritischen und hohen Funde sind geschlossen. Offen sind nur bewusst zurückgestellte Restrisiken (siehe „Offene Punkte“). --- ## Status je Fund ### KRITISCH #### K1 – Stored XSS über manipulierten Content-Type — **behoben** - **Ursprung:** `mimeType` aus dem Client wurde ungeprüft gespeichert. Ein Wert wie `image/png, text/html` umging den Active-Content-Filter, Chromium renderte HTML im App-Origin. Die CSP war über eine hochgeladene JS-Datei umgehbar. - **Umsetzung (PR #189, `cae9424`):** - `src/utils/mime.js`: `normalizeMime()` reduziert jeden Wert auf genau einen `typ/subtyp`. Inline ausgeliefert wird nur noch eine Whitelist passiver Formate (`SAFE_INLINE`), alles andere als `application/octet-stream` mit `attachment` und `CSP: sandbox`. - Normalisierung beim Speichern (alle Upload-Wege und Import) und zusätzlich beim Ausliefern, damit auch Altbestände geschützt sind. - **Nachweis:** Normalisierung mit Angriffswerten getestet; Integrationstest der Auslieferung. - **Restrisiko:** keins bekannt. Langfristig wäre eine eigene Domain für Nutzerinhalte die grundsätzliche Absicherung gegen diese Fehlerklasse. #### K2 – `deleteToken`-Leak und NoSQL-Operator-Injection — **behoben** - **Ursprung:** `GET /api/file/:shortId` lieferte ohne Login den `deleteToken`. Über den WebSocket ließ sich `shortId` als Objekt (`{"$regex": …}`) übergeben, damit waren alle Dateien enumerierbar und löschbar. - **Umsetzung (PR #189, `cae9424`):** - `deleteToken` mit `select: false`, er erscheint in keiner API- oder WebSocket-Antwort mehr. Geladen wird er nur in der Lösch-Route. - `file:get` und `file:delete` im WebSocket verlangen einen String. - **Restrisiko:** Das Löschen per `GET /f/:id/delete/:token` bleibt bestehen (ShareX-Kompatibilität). Mit geheimem Token ist das unkritisch. ### HOCH #### H1 – Share-Link-Schutz und Download-Limit umgehbar — **behoben** - **Umsetzung:** - PR #189 (`cae9424`): `GET /api/share-links/:token` liefert keine `shortId` mehr. - PR #195 (`f31ae8a`): `maxDownloads` gilt auch für `/f/:id/raw`. Gezählt werden vollständige Abrufe (ohne `Range` oder ab Byte 0), einmal pro Besucher-Session. Eigentümer und Admins zählen nie. Die Zählung ist atomar. - **Nachweis:** Integrationstest (Vorschau plus Download eines Besuchers ergibt einen Download, ein weiterer Besucher bekommt 410, Teil-Ranges zählen nicht). - **Bekannte Eigenschaft:** Ein Besucher kann innerhalb seiner Session beliebig oft erneut herunterladen. Das Limit begrenzt die Zahl der Besucher, nicht der Abrufe. #### H2 – Erratbare Datei-IDs und `file:view`-Broadcast an alle — **behoben, Restrisiko** - **Umsetzung:** - PR #190 (`12dece5`): Neue IDs für Dateien, Pastes und Collections bestehen aus 96 Bit Zufall (24 Hex-Zeichen). `file:view` geht nur noch an Eigentümer und Admins. - PR #195 (`f31ae8a`): gemeinsames Rate-Limit für fehlgeschlagene Abrufe per ID oder Token (100 pro IP in 15 Minuten) auf `/f`, `/s`, `/api/file`, Share-Links, Collections und Pastes. - **Restrisiko:** Bereits vergebene 8-stellige IDs (Zeitstempel plus 16 Bit) bleiben gültig, damit geteilte Links nicht brechen. Sie sind nur noch durch das Rate-Limit geschützt. Import-IDs aus XBackBone behalten ihr ursprüngliches Format. - **Betreiberentscheidung (23.10.2026):** Die alten IDs bleiben bestehen, das Restrisiko ist bewusst akzeptiert. Ihr Bestand wächst nicht mehr, da neue Dateien nur noch zufällige IDs erhalten. #### H3 – Rechte nur beim Verbindungsaufbau geprüft — **behoben** - **Umsetzung:** - PR #190 (`9814219`): Der WebSocket prüft Session, Aktiv-Status und Rolle bei jeder Nachricht und alle 30 Sekunden für alle Verbindungen. Dazu kommt ein Ping/Pong-Heartbeat. - PR #191 (`628d2cc`): Die HTTP-Seite liest Rolle, Aktiv-Status und `sessionVersion` bei jedem Request aus der Datenbank. - **Wirkung:** Logout, Deaktivierung, Löschung, Herabstufung und Passwortwechsel wirken sofort, über HTTP und WebSocket. #### H4 – Platte ohne Login vollschreibbar, Quota umgehbar — **behoben, Restrisiko** - **Umsetzung (PR #190, `9814219`):** - Abgelehnte Requests an den ShareX-Endpunkt `/upload` löschen die bereits geschriebene Datei sofort. - Chunk-Upload: Offene Uploads zählen gegen die Quota. Teile über der angekündigten Größe werden abgelehnt (413). Die Endgröße muss exakt stimmen (400). - Der Retention-Job räumt verwaiste Chunks nach 24 Stunden ab. - **Betrieb:** Auf `sharely.christian.pizza` gilt seit dem 23.10.2026 eine Standard-Quota von 1 GB pro Nutzer; einzelne Konten haben ein eigenes Kontingent. Kein bestehendes Konto lag darüber. - **Restrisiko:** Bei offener Registrierung lässt sich die Quota über mehrere Konten umgehen. Die Registrierung zu schließen (`ALLOW_REGISTRATION=false`) beseitigt das vollständig. ### MITTEL #### M1 – WebSocket ohne Limits — **behoben** (PR #193, `cbf3a33`) - Höchstens 64 KiB pro Nachricht (Code 1009), 30 Nachrichten am Stück und danach 5 pro Sekunde (429), höchstens 20 Verbindungen pro IP (proxy-bewusst über `proxy-addr`). - Passwort- und Mail-Aktionen sind auf 10 pro Nutzer in 15 Minuten begrenzt, über WebSocket und HTTP (getrennte Zähler, schlimmstenfalls 20 Versuche). - **Nachweis:** Integrationstest (403, 429, 1009 und Limit pro IP ausgelöst). #### M2 – Keine Origin-Prüfung beim WebSocket-Handshake — **behoben** (PR #193, `cbf3a33`) - Der Handshake wird abgelehnt, wenn `Origin` und `Host` nicht übereinstimmen. Das setzt voraus, dass der Proxy den originalen `Host` weiterreicht. Das ist dokumentiert. #### M3 – `Secure`-Cookie und HSTS abhängig von `NODE_ENV` — **behoben** (PR #192, `163888e`) - `secure: 'auto'`: Das Cookie bekommt `Secure`, sobald der Request per HTTPS ankommt. HSTS wird bei HTTPS gesetzt. Plain-HTTP-Setups (lokal) funktionieren weiter. #### M4 – Port öffentlich, Rate-Limits über `X-Forwarded-For` umgehbar — **behoben** (PR #192, `163888e`; Doku PR #194, #195) - Der Port wird standardmäßig nur auf `127.0.0.1` veröffentlicht (`BIND_ADDRESS`). `TRUST_PROXY` ist konfigurierbar. - **Vorfall:** Setups, deren Reverse Proxy oder Tunnel in einem eigenen Container läuft, erreichen die App nach der Loopback-Bindung nicht mehr und antworten mit 502. Lösung und Upgrade-Hinweis stehen in README und `DOCUMENTATION.md` („Cloudflare Tunnel / Proxy im Container“). - **Betreiber-Prüfung:** Im Audit-Log müssen echte Client-IPs stehen; sonst passt `TRUST_PROXY` nicht zur Proxy-Kette. #### M5 – Brute-Force auf Passwörter von Inhalten — **behoben** (PR #192, `163888e`) - Höchstens 10 Fehlversuche pro IP in 15 Minuten auf die Passwort-Prüfung von Share-Links, Collections und Pastes. #### M6 – Keine Session-Rotation und -Invalidierung — **behoben** (PR #191, `628d2cc`) - Neue Session-ID bei Login, Registrierung und Installation (`establishSession`). - `User.sessionVersion` macht bei Passwortwechsel und -reset alle anderen Sessions ungültig, auch offene WebSockets. - **Folgefehler:** Ein E2E-Test teilte sich unbeabsichtigt die Admin-Session mit einem Nutzer-Login. Seit der Rotation lief die E2E-Suite auf `main` (ab PR #191) in den Timeout. Behoben in PR #195 (`8e8915b`), CI ist wieder grün. #### M7 – Secrets in Access-Logs — **behoben** (PR #192, `163888e`) - `api_key=`, `token=`, Delete-Tokens und Share-Tokens werden im Access-Log geschwärzt. - **Hinweis:** Bereits geschriebene Logs enthalten sie weiterhin, bis die Rotation sie verdrängt. Betroffene API-Keys sollten bei Bedarf neu erzeugt werden. #### M8 – Verwundbare Abhängigkeiten — **behoben** (PR #193, `e6a2237`) - nodemailer 10.2.1, mongoose 8.24.6, express 4.22.3, express-rate-limit 8.7.1, multer 2.5.0. `npm audit --omit=dev` meldet 0 Schwachstellen. Node ≥ 20 wird vorausgesetzt. - Major-Sprünge wurden nicht mitgenommen (mongoose 9, express 5). Für das Client-Build-Tooling (postcss u. a.) gibt es weiterhin Meldungen; die betreffen nicht die Laufzeit. #### D2 – Kontolöschung unvollständig (DSGVO Art. 17) — **behoben** (PR #193, `cbf3a33`) - Eine gemeinsame Funktion `deleteUserData` für alle vier Löschwege. Gelöscht werden Dateien, Thumbnails, Share-Links, Pastes, Collections und der Avatar. Gelöschte Dateien werden auch aus fremden Collections entfernt, das Audit-Log wird anonymisiert. - **Bewusst so gelassen:** Der Audit-Eintrag der Löschung selbst bleibt als Nachweis bestehen (90 Tage). ### NIEDRIG #### N1 – Ghostscript/ffmpeg im App-Container — **behoben** (PR #195, `1789be6`; PR #199, `00055f1`) - Beide laufen jetzt mit leerer Umgebung (nur `PATH`). - Mit Docker Compose laufen sie im eigenen Container `thumbnailer`: ohne Netzwerk, ohne Secrets, als `nobody`, mit schreibgeschütztem Dateisystem und ohne Zugriff auf `uploads/`. Die App reicht Dateien über das Volume `thumbjobs` weiter und übernimmt Ergebnisse nur als reguläre Datei bis 2 MB (keine Symlinks oder FIFOs). - **Nachweis:** Isolation im Container geprüft (kein Netz, keine Secrets, kein Schreibzugriff auf `in/` und das Queue-Verzeichnis); Symlink-Ergebnis wird abgelehnt. - **Bewusst so gelassen:** Ein kompromittierter Worker sieht die Dateien, die gerade in der Warteschlange liegen, und kann deren Thumbnails verfälschen. Ohne Docker (lokale Entwicklung) laufen die Tools weiterhin im App-Prozess. #### N2 – Updater mit Docker-Socket — **behoben** (PR #195, `7b8aea6`) - Das Control-Verzeichnis gehört jetzt root und hat das Sticky-Bit (`1777`). Die App kann nur `update.request` anlegen und `update.state` schreiben. Untergeschobene Symlinks werden beim Start entfernt. - Eine feste UID wurde bewusst nicht eingeführt, weil sie die Rechte bestehender `uploads`-Volumes brechen würde. - **Nachweis:** lokal als Nicht-root-Nutzer getestet. - **Unverändert:** Der Updater zieht ungeprüft den konfigurierten Branch (keine Signaturprüfung). Das ist nur relevant, wenn der Updater aktiv ist. #### N3 – Unvollständige Security-Header und CSP — **behoben** (PR #195, `f31ae8a`) - Neu: `Referrer-Policy: same-origin`, `Permissions-Policy`, sowie `base-uri 'none'`, `form-action 'self'` und `object-src 'none'` in der CSP. - `same-origin` statt `no-referrer`, weil Letzteres bei Formular-Posts `Origin: null` erzeugen kann und die CSRF-Prüfung diese Requests dann ablehnt (in Chromium getestet). #### N4 – Fehlerbehandlung — **behoben** (PR #195, `f31ae8a`) - `asyncRouter` leitet async-Fehler an den Error-Handler weiter. Bei `uncaughtException` beendet sich der Prozess, Docker startet ihn neu. Datei-Streams laufen über `pipeline()`. #### N5 – Installations-Race — **behoben** (PR #195, `f31ae8a`) - Die Installation verlangt ein Setup-Token (im Log oder über `INSTALL_TOKEN`). Die normale Registrierung erzeugt nie einen Admin. - **Nachweis:** lokal getestet, E2E-Setup nutzt das Token. ### DATENSCHUTZ / INFO #### D3 – Keine Verschlüsselung der Inhalte — **behoben (opt-in), Restrisiko** (PR #195, `f31ae8a`) - Mit `ENCRYPTION_KEY` werden Uploads und Thumbnails mit AES-256-GCM in 64-KiB-Segmenten verschlüsselt, mit einem Schlüssel pro Datei (HKDF) und Erkennung von Manipulation. Range-Requests und ZIP funktionieren. Das Migrationsskript `npm run migrate:encrypt` verschlüsselt Bestandsdaten. - **Restrisiko:** - Ohne gesetzten Schlüssel bleibt alles unverschlüsselt. - Nicht abgedeckt sind Pastes, Dateinamen und andere Metadaten in MongoDB sowie Avatare. - Während der Verarbeitung liegt eine Datei kurz im Klartext auf der Platte. Liegengebliebene Klartext-Kopien entfernt der Retention-Job nach 1 Stunde. - Ein Schlüsselwechsel wird nicht unterstützt, und ein verlorener Schlüssel bedeutet Datenverlust. - Keine Ende-zu-Ende-Verschlüsselung: Der Server sieht die Inhalte beim Upload und Ausliefern. - **Betrieb:** Auf `sharely.christian.pizza` seit dem 23.10.2026 aktiv. Der gesamte Bestand (16.151 Datei-Einträge, rund 6 GB) wurde mit `migrate:encrypt` ohne Fehler migriert; Schlüssel getrennt gesichert, `.env` nur für root lesbar. #### D4 – Logs zeitlich unbegrenzt mit vollen IPs — **behoben** (PR #195, `f31ae8a`) - Das Access-Log speichert IPs gekürzt (IPv4 auf /24, IPv6 auf /48). Das Audit-Log behält volle IPs, mit 90 Tagen TTL. - Die Rotation der Docker-Logs bleibt größenbasiert. --- ## Zusätzlich beim Beheben gefunden | Fund | Schwere | Status | |---|---|---| | Path Traversal in `/f/:id/thumb`: Express dekodiert `%2F`, damit waren `.jpg`-Dateien außerhalb des Thumbnail-Ordners lesbar | Mittel | Behoben in PR #195 (ID-Prüfung `[\w-]+`) | | Updater: Eine kompromittierte App konnte über einen Symlink im `777`-Control-Verzeichnis root-Schreibzugriffe umlenken (mit Docker-Socket entspricht das root auf dem Host) | Hoch (nur mit aktivem Updater) | Behoben in PR #195 | | Suffix-Ranges (`bytes=-N`) lieferten immer 416 | Bug | Behoben in PR #195 | | E2E-Test teilte die Admin-Session mit einem Nutzer-Login, ab PR #191 Timeout der Suite | CI | Behoben in PR #195 | | Upload-Dateien blieben bei einem Serverfehler nach dem Speichern (z. B. Datenbank nicht erreichbar) ohne Datenbank-Eintrag auf der Platte liegen, bei aktiver Verschlüsselung samt Klartext-Kopie. Bei der Live-Prüfung fanden sich 56 solche Dateien im Klartext (55 abgelehnte ShareX-Uploads von vor dem H4-Fix, 1 Web-Upload), die gelöscht wurden | Niedrig (DSGVO) | Behoben: alle Upload-Wege löschen Dateien ohne Eintrag bei Fehlern | --- ## Live-Prüfung auf `sharely.christian.pizza` Am 23.10.2026 nach dem Deploy von `af044e8` durch den Betreiber durchgeführt (Cloudflare Tunnel, `cloudflared` im Container, Hetzner-Firewall davor). | Prüfung | Ergebnis | |---|---| | Thumbnail-Container | Läuft als `nobody`, Heartbeat aktuell, keine Warnung im App-Log. `migrate:thumbnails` erzeugte 9 PDF- und 1 Video-Thumbnail über den Container, danach `failed=0` | | HSTS | Cloudflare überschrieb den Header der App mit `max-age=0` (HSTS faktisch abgeschaltet). In Cloudflare auf `max-age=31536000` ohne Preload und ohne Subdomains umgestellt | | HSTS der App (direkt am Origin mit `X-Forwarded-Proto: https`) | `max-age=31536000; includeSubDomains`, HTTPS-Erkennung hinter dem Proxy funktioniert | | `Referrer-Policy`, `X-Content-Type-Options` | `same-origin`, `nosniff` | | Client-IPs im Audit-Log | Echte öffentliche IPs, `TRUST_PROXY=1` passt zum Tunnel | | Session-Cookie `connect.sid` | `HttpOnly`, `Secure`, `SameSite=Strict` | | Standard-Quota (H4) | Vorher unbegrenzt bei offener Registrierung (48 Nutzerkonten, zusammen rund 3,2 GB). Jetzt 1 GB Standard, Einzelkontingente für Admin und Vielnutzer; kein Konto über dem Limit | | Verschlüsselung (D3) | Vor der Migration Backup von Uploads (`gzip -t` geprüft) und Datenbank. Schlüssel nach dem Laden in der App bestätigt. Migration mit 0 Fehlern; die Zusammenfassungszeile des Skripts wurde nicht festgehalten. Stichproben bestanden: altes Bild, Video mit Spulen, PDF, Thumbnails, ZIP-Download (enthält entschlüsselte Dateien). Nachprüfung: 16.152 von 16.152 Datei-Einträgen verschlüsselt; auf der Platte beginnen nach Entfernen von 56 verwaisten Dateien alle Uploads und Thumbnails mit der Kennung `SHLYENC1` (Avatare bewusst ausgenommen) | | WebSocket über den Tunnel | `101 Switching Protocols` mit `Origin: https://sharely.christian.pizza`, Origin-Prüfung lässt die eigene Seite durch | --- ## Offene Punkte 1. **Klartext-Backups löschen:** Die vor der Verschlüsselung angelegten Backups (`uploads-before-encrypt.tgz`, `mongo-before-encrypt.archive`) enthalten alle Dateien unverschlüsselt und sollen nach einigen Tagen fehlerfreien Betriebs gelöscht werden. 2. **`.env.example`:** um die neuen Variablen ergänzt (`fbb0c53`). --- ## Übersicht der Änderungen auf `main` | PR | Commit(s) | Inhalt | |---|---|---| | #189 | `cae9424` | K1, K2, H1 (Share-Link-ID) | | #190 | `12dece5`, `9814219` | H2, H3 (WebSocket), H4 | | #191 | `628d2cc` | H3 (HTTP), M6 | | #192 | `163888e` | M3, M4, M5, M7 | | #193 | `e6a2237`, `cbf3a33` | M8, M1, M2, D2 | | #194 | `bbac3e3` | Doku Cloudflare Tunnel / Proxy im Container | | #195 | `7b8aea6`, `1789be6`, `f31ae8a`, `7ea07d5`, `0bc991e`, `1fe1802`, `8e8915b` | N2, N1, D3, N5, H1 (`/raw`), H2 (Rate-Limit), N3, N4, D4, Zusatzfunde, Doku, E2E-Fix | | #199 | `00055f1` | N1: ffmpeg/Ghostscript im eigenen Container |