diff --git a/Doku/architecture.md b/Doku/architecture.md index 77464ac..d79ed4a 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -58,7 +58,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `PORT` | `8080` | HTTP-Port | | `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank | | `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) | -| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) | +| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) | | `BASE_URL` | `http://localhost:` | Basis-URL der Instanz | | `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` | diff --git a/Doku/flaws.md b/Doku/flaws.md index d6fc9d2..f39bd9a 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -13,7 +13,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** | | FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** | | FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** | -| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** | +| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** | | FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel | | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | | FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel | @@ -60,6 +60,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen. ### FLAW-04: Fallback-JWT-Secret +- **Status: Behoben.** Fehlt `JWT_SECRET` oder ist es ein bekannter Platzhalter, erzeugt der Server 32 Zufallsbytes und speichert sie als `jwt.secret` (Rechte 0600) im Datenverzeichnis; ein Hinweis steht im Log. Test: `internal/config/config_test.go`. - **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter. - **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht. - **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen. diff --git a/README.md b/README.md index 28adf23..25c25c2 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ services: Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos. -> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt. +> ⚠️ **`JWT_SECRET`:** Ohne Angabe (oder mit einem der Platzhalter aus den Beispielen) erzeugt der Server beim ersten Start einen Zufallswert und speichert ihn als `jwt.secret` im Datenverzeichnis. Für Produktion lieber selbst einen langen Zufallswert setzen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt. --- @@ -151,7 +151,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d |---|---|---| | `PORT` | `8080` | HTTP-Port | | `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` | -| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel | +| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert | | `BASE_URL` | `http://localhost:8080` | Öffentliche URL | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | diff --git a/internal/config/config.go b/internal/config/config.go index ebfedc5..b3f118b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/rand" + "encoding/hex" "log/slog" "os" "path/filepath" @@ -37,6 +39,50 @@ type LegalTexts struct { PrivacyURL, PrivacyContent string } +// Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and +// README). Signing with one of them would let anyone forge login tokens. +var insecureJWTSecrets = map[string]bool{ + "mindsift-default-selfhosted-jwt-secret-key-2026": true, + "change_this_to_a_secure_random_key_min_32_chars": true, + "hier-ein-langer-zufaelliger-wert": true, + "changeme": true, + "secret": true, +} + +const jwtSecretFile = "jwt.secret" + +// resolveJWTSecret uses JWT_SECRET when it is set to a real value. Otherwise it generates a random +// secret once and keeps it in the data directory, so logins survive restarts without anybody being +// able to forge tokens with a known default. +func resolveJWTSecret(fromEnv, dataDir string) string { + if v := strings.TrimSpace(fromEnv); v != "" && !insecureJWTSecrets[v] { + if len(v) < 32 { + slog.Warn("JWT_SECRET ist kürzer als 32 Zeichen – bitte einen längeren Zufallswert verwenden") + } + return v + } else if v != "" { + slog.Warn("JWT_SECRET ist ein öffentlich bekannter Platzhalter und wird ignoriert") + } + path := filepath.Join(dataDir, jwtSecretFile) + if data, err := os.ReadFile(path); err == nil { + if s := strings.TrimSpace(string(data)); len(s) >= 32 { + return s + } + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic("crypto/rand: " + err.Error()) + } + secret := hex.EncodeToString(buf) + _ = os.MkdirAll(dataDir, 0o755) + if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil { + slog.Error("Erzeugtes JWT-Secret konnte nicht gespeichert werden – Anmeldungen gelten nur bis zum Neustart", "pfad", path, "error", err) + } else { + slog.Warn("Kein JWT_SECRET gesetzt: zufälliges Secret erzeugt und im Datenverzeichnis gespeichert. Für Produktion besser JWT_SECRET setzen.", "pfad", path) + } + return secret +} + // legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE. func legalContent(name string) string { if v := strings.TrimSpace(os.Getenv(name)); v != "" { @@ -78,7 +124,7 @@ func Load() *Config { dbPath = dbURL } - jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026") + jwtSecret := resolveJWTSecret(os.Getenv("JWT_SECRET"), filepath.Dir(dbPath)) baseURL := getEnv("BASE_URL", "http://localhost:"+port) allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af25526..a5a3291 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,10 +3,13 @@ package config import ( "log/slog" "os" + "path/filepath" "testing" ) func TestConfigLogLevelAndDebug(t *testing.T) { + // Load writes the generated JWT secret into the data directory; keep it out of the repository + t.Setenv("DATA_DIR", t.TempDir()) os.Setenv("PORT", "9090") defer os.Unsetenv("PORT") @@ -56,3 +59,25 @@ func TestConfigLogLevelAndDebug(t *testing.T) { os.Unsetenv("DEBUG") os.Unsetenv("LOG_LEVEL") } + +func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) { + dir := t.TempDir() + for _, env := range []string{"", "mindsift-default-selfhosted-jwt-secret-key-2026", "change_this_to_a_secure_random_key_min_32_chars"} { + got := resolveJWTSecret(env, dir) + if got == env || len(got) < 32 { + t.Fatalf("env %q: insecure secret %q", env, got) + } + } + // The generated secret is stable across restarts + first := resolveJWTSecret("", dir) + if again := resolveJWTSecret("", dir); again != first { + t.Fatalf("generated secret must persist, got %q then %q", first, again) + } + info, err := os.Stat(filepath.Join(dir, jwtSecretFile)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("secret file must exist with mode 0600: %v %v", info, err) + } + if got := resolveJWTSecret("ein-eigener-sehr-langer-zufaelliger-wert-123456", dir); got != "ein-eigener-sehr-langer-zufaelliger-wert-123456" { + t.Fatalf("a real JWT_SECRET must win, got %q", got) + } +}