fix(security): never sign tokens with a known JWT secret (FLAW-04)
Without JWT_SECRET, or with one of the published placeholders, the server generates 32 random bytes once and keeps them in the data directory (jwt.secret, mode 0600), so tokens cannot be forged and logins survive restarts. Instances that ran on the old default log in once more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beeacf8943
commit
4cdbcbd6b5
5 files changed
+77
-5
No files matched your search
@@ -58,7 +58,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
| `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) |
|
| `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:<PORT>` | Basis-URL der Instanz |
|
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `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` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
|
|||||||
+2
-1
@@ -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-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
|
||||||
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
||||||
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **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-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-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 |
|
| 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.
|
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
||||||
|
|
||||||
### FLAW-04: Fallback-JWT-Secret
|
### 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.
|
- **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.
|
- **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.
|
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ services:
|
|||||||
|
|
||||||
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
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 |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
| `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 |
|
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -37,6 +39,50 @@ type LegalTexts struct {
|
|||||||
PrivacyURL, PrivacyContent string
|
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.
|
// legalContent reads Markdown from NAME or, for longer texts, from the file named in NAME_FILE.
|
||||||
func legalContent(name string) string {
|
func legalContent(name string) string {
|
||||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||||
@@ -78,7 +124,7 @@ func Load() *Config {
|
|||||||
dbPath = dbURL
|
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)
|
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package config
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfigLogLevelAndDebug(t *testing.T) {
|
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")
|
os.Setenv("PORT", "9090")
|
||||||
defer os.Unsetenv("PORT")
|
defer os.Unsetenv("PORT")
|
||||||
|
|
||||||
@@ -56,3 +59,25 @@ func TestConfigLogLevelAndDebug(t *testing.T) {
|
|||||||
os.Unsetenv("DEBUG")
|
os.Unsetenv("DEBUG")
|
||||||
os.Unsetenv("LOG_LEVEL")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user