fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker; test alarm, SMTP test, server settings, backups, account and session changes of the shared demo accounts answer 403; the session list only shows the own device. The UI shows the notices and disables the controls. - SMTP password is never sent back (smtpPassSet), empty keeps it. - ?token= on webhook ingest accepts only API keys. - Request bodies capped at 2 MB (sync 8 MB, imports 101 MB). - Deleting an account removes its server backups. - Backup downloads, exports and import uploads get 30 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3709282a4f
commit
9d9698186c
21 files changed
+631
-304
No files matched your search
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
"github.com/sandrodach/mindsift/internal/ws"
|
||||
)
|
||||
|
||||
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
|
||||
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
|
||||
|
||||
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("webhook sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
|
||||
t.Fatalf("discord sender must refuse in demo, got %v", err)
|
||||
}
|
||||
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
|
||||
t.Fatalf("mail sender must refuse in demo, got %v", err)
|
||||
}
|
||||
blocked := []struct{ method, path, body string }{
|
||||
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
|
||||
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
|
||||
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
|
||||
{"POST", "/api/v1/admin/backups", ``},
|
||||
{"POST", "/api/v1/auth/revoke-all", ``},
|
||||
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
|
||||
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
|
||||
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
|
||||
}
|
||||
for _, b := range blocked {
|
||||
rec := sessionCall(router, b.method, b.path, token, b.body, "")
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
|
||||
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// Harmless profile changes still work
|
||||
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("display name in demo: %d", rec.Code)
|
||||
}
|
||||
// Visitors only see their own device, not the others' addresses
|
||||
var list []storage.Session
|
||||
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
|
||||
if len(list) != 1 || !list[0].Current {
|
||||
t.Fatalf("demo session list must only show the own device, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPPasswordNeverReturned(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
|
||||
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
|
||||
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
|
||||
t.Fatalf("GET leaks the password: %s", body)
|
||||
}
|
||||
// Saving again with the empty field keeps it
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
|
||||
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
|
||||
}
|
||||
// Without SMTP user the password is dropped
|
||||
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
|
||||
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
|
||||
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedAccountLosesServerBackups(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
root := srv.backupRoot()
|
||||
gone, _ := newTestUser(t, store, "gone@example.com")
|
||||
stays, _ := newTestUser(t, store, "stays@example.com")
|
||||
for _, id := range []string{gone.ID, stays.ID} {
|
||||
dir := storage.UserBackupDir(root, id)
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
|
||||
}
|
||||
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
|
||||
t.Fatalf("backups of the deleted account must be gone: %v", err)
|
||||
}
|
||||
for _, odd := range []string{"", ".", "/", "..", "../users"} {
|
||||
srv.removeUserBackups(odd)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
|
||||
t.Fatalf("other accounts' backups must stay: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user