fix(PROJ-63): Defense-in-Depth Tenant-Scope-Härtung der Admin-Endpunkte
tenantAccessAllowed()-Check in allen {id}-Handlern von tenant_handlers.go,
tenant_domain_handlers.go und tenant_logo_handlers.go ergänzt — No-op für
globale Admins, zweite Verteidigungslinie für hypothetische tenant-gebundene
Admin-Sessions.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c1338f6721
commit
dcb88317ac
@@ -20,7 +20,9 @@ Du entwickelst **archivmail** – ein selbst gehostetes, unternehmenstaugliches
|
||||
|
||||
**Go-Modul: `archivmail`** — Imports sind immer `archivmail/internal/...`, NIEMALS `github.com/archivmail/...`
|
||||
|
||||
**Feature-Tracking:** Alle Features in `features/INDEX.md`. Feature-IDs: PROJ-X. Nächste verfügbare ID: PROJ-44.
|
||||
**Feature-Tracking:** Alle Features in `features/INDEX.md`. Feature-IDs: PROJ-X. Die nächste
|
||||
verfügbare ID steht live am Ende von `features/INDEX.md` — dort nachsehen, nicht aus dem
|
||||
Gedächtnis annehmen (die Datei wird laufend fortgeschrieben).
|
||||
|
||||
## Deine Kernprinzipien
|
||||
|
||||
@@ -152,6 +154,33 @@ const (
|
||||
- **Audit:** Jeder Zugriff (Suche, Export, Lesen) wird geloggt – unveränderbar
|
||||
- **Integrität:** SHA-256 im Dateinamen + DB für spätere Verifikation
|
||||
|
||||
### Architekturprinzip: Symmetrische Tenant-Scope-Checks (Lehre aus PROJ-61)
|
||||
|
||||
Jede neue per-ID-adressierbare Ressource (nicht nur Mails — auch Logos, Anhänge, Exporte,
|
||||
Saved Searches, API-Keys, künftige Ressourcentypen) bekommt beim Entwurf **von Anfang an**
|
||||
ein einheitliches Zugriffsmuster über alle CRUD-Pfade:
|
||||
|
||||
- Rollen-Check (`requireRole`) UND Tenant-Scope-Check (`tenantAccessAllowed()`) sind zwei
|
||||
unabhängige Dimensionen — beide müssen auf JEDEM Pfad (GET, POST, DELETE) geprüft werden.
|
||||
- Bei "global admin sieht alles, domain_admin nur eigenen Tenant"-Ressourcen: Lese- und
|
||||
Schreibpfad MÜSSEN denselben Scope-Check verwenden. PROJ-61 entstand, weil der Lesepfad
|
||||
einer Ressource (Tenant-Logo) nur `s.auth` hatte, während Schreib-/Löschpfad korrekt
|
||||
`requireRole` + Tenant-Scope kombinierten — das Auseinanderlaufen von Geschwister-Endpunkten
|
||||
ist das eigentliche Risiko, nicht ein einzelner fehlender Check.
|
||||
- Beim Architektur-Entwurf eines neuen Ressourcentyps: definiere den Scope-Check EINMAL als
|
||||
gemeinsame Hilfsfunktion, die alle Handler (GET/POST/DELETE) aufrufen — nie pro Handler neu
|
||||
ausformulieren.
|
||||
|
||||
### Architekturprinzip: Neue Hintergrund-Jobs defaulten auf Batch, nicht Dauerbetrieb
|
||||
|
||||
Seit PROJ-56/58 ist die etablierte Linie: rechenintensive oder schreiblastige Hintergrund-
|
||||
Verarbeitung (Indexierung, OCR, künftige ähnliche Jobs) soll beim Entwurf eine
|
||||
`batch_mode`-Option vorsehen (Cron-getrieben, Default ggf. weiter Dauerbetrieb für
|
||||
Abwärtskompatibilität), statt implizit als permanente Goroutine ohne Abschaltmöglichkeit zu
|
||||
laufen. Performance-Budget (<200 MB RAM) gilt besonders für neue Worker-Pools — vor dem
|
||||
Hinzufügen eines weiteren Dauerbetrieb-Workers prüfen, ob ein bestehender Worker den Job
|
||||
mitübernehmen kann.
|
||||
|
||||
## Deine Arbeitsweise
|
||||
|
||||
### Bei Architektur-Anfragen:
|
||||
|
||||
@@ -89,6 +89,15 @@ ssh root@192.168.1.131 'manticore_backup --config /etc/manticoresearch/manticore
|
||||
ssh root@192.168.1.131 'archivmail reindex --config /etc/archivmail/config.yml'
|
||||
```
|
||||
|
||||
## GoBD-Hinweis
|
||||
|
||||
Der Manticore-Index ist **abgeleitete Suchdarstellung**, nicht die rechtlich maßgebliche
|
||||
Quelle (Source of Truth = verschlüsselte Roh-Mails in `/var/archivmail/store/` + PostgreSQL-
|
||||
Metadaten). Einträge aus dem Index löschen/ändern ist erlaubt (Reindex jederzeit möglich),
|
||||
aber NIEMALS als Ersatz für eine echte GoBD-konforme Mail-Löschung verwenden — eine Mail aus
|
||||
dem Index zu entfernen macht sie nicht rechtlich gelöscht, sie bleibt unverändert im Store.
|
||||
Echte Löschungen laufen ausschließlich über `archivmail purge` (Retention + Markierung).
|
||||
|
||||
## Security
|
||||
|
||||
- Port 9306 NUR auf localhost: `listen = 127.0.0.1:9306:mysql`
|
||||
|
||||
+2
-1
@@ -77,7 +77,8 @@
|
||||
| PROJ-58 | Indexierung + OCR als Cron-Batch-Jobs (statt Dauerbetrieb) | Deployed | [PROJ-58](PROJ-58-cron-batch-index-ocr.md) | 2026-06-24 |
|
||||
| PROJ-61 | Fix Cross-Tenant Stored XSS via Mandanten-Logo (Sicherheitsbug) | Deployed | [PROJ-61](PROJ-61-fix-tenant-logo-xss-idor.md) | 2026-06-25 |
|
||||
| PROJ-62 | Fix Cross-Tenant IDOR bei POP3-Konto-Löschung/-Import (Sicherheitsbug) | Deployed | [PROJ-62](PROJ-62-fix-pop3-tenant-idor.md) | 2026-06-25 |
|
||||
| PROJ-63 | Defensive Tenant-Scope-Härtung der Tenant-Verwaltungs-Endpunkte (FUND-2) | Deployed | [PROJ-63](PROJ-63-harden-tenant-admin-scope.md) | 2026-06-25 |
|
||||
|
||||
<!-- Add features above this line -->
|
||||
|
||||
## Next Available ID: PROJ-63
|
||||
## Next Available ID: PROJ-64
|
||||
|
||||
@@ -18,6 +18,11 @@ func (s *Server) handleListTenantDomains(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
domains, err := s.tenantStore.ListDomains(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
@@ -37,6 +42,12 @@ func (s *Server) handleAddTenantDomain(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
@@ -63,6 +74,11 @@ func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &tenantID) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
didStr := r.PathValue("did")
|
||||
domainID, err := strconv.ParseInt(didStr, 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -117,6 +117,11 @@ func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
tenant, err := s.tenantStore.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "tenant not found")
|
||||
@@ -136,6 +141,12 @@ func (s *Server) handleUpdateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Active *bool `json:"active"`
|
||||
@@ -181,6 +192,11 @@ func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sess, &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
if err := s.tenantStore.Delete(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
|
||||
return
|
||||
@@ -204,6 +220,11 @@ func (s *Server) handleListTenantUsers(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &tenantID) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
users, err := s.users.ListByTenant(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list tenant users")
|
||||
|
||||
@@ -57,6 +57,11 @@ func (s *Server) handleUploadTenantLogo(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
s.saveTenantLogo(w, r, id)
|
||||
}
|
||||
|
||||
@@ -70,6 +75,11 @@ func (s *Server) handleDeleteTenantLogo(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||
return
|
||||
}
|
||||
// Defense-in-depth tenant scope check (PROJ-63): no-op for global admins.
|
||||
if !tenantAccessAllowed(sessionFromCtx(r.Context()), &id) {
|
||||
writeError(w, http.StatusForbidden, "access denied")
|
||||
return
|
||||
}
|
||||
if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete logo")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user