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/...`
|
**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
|
## Deine Kernprinzipien
|
||||||
|
|
||||||
@@ -152,6 +154,33 @@ const (
|
|||||||
- **Audit:** Jeder Zugriff (Suche, Export, Lesen) wird geloggt – unveränderbar
|
- **Audit:** Jeder Zugriff (Suche, Export, Lesen) wird geloggt – unveränderbar
|
||||||
- **Integrität:** SHA-256 im Dateinamen + DB für spätere Verifikation
|
- **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
|
## Deine Arbeitsweise
|
||||||
|
|
||||||
### Bei Architektur-Anfragen:
|
### 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'
|
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
|
## Security
|
||||||
|
|
||||||
- Port 9306 NUR auf localhost: `listen = 127.0.0.1:9306:mysql`
|
- 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-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-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-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 -->
|
<!-- 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")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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)
|
domains, err := s.tenantStore.ListDomains(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list domains")
|
writeError(w, http.StatusInternalServerError, "failed to list domains")
|
||||||
@@ -37,6 +42,12 @@ func (s *Server) handleAddTenantDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
var req struct {
|
||||||
Domain string `json:"domain"`
|
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")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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")
|
didStr := r.PathValue("did")
|
||||||
domainID, err := strconv.ParseInt(didStr, 10, 64)
|
domainID, err := strconv.ParseInt(didStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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)
|
tenant, err := s.tenantStore.Get(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "tenant not found")
|
writeError(w, http.StatusNotFound, "tenant not found")
|
||||||
@@ -136,6 +141,12 @@ func (s *Server) handleUpdateTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Active *bool `json:"active"`
|
Active *bool `json:"active"`
|
||||||
@@ -181,6 +192,11 @@ func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sess := sessionFromCtx(r.Context())
|
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 {
|
if err := s.tenantStore.Delete(r.Context(), id); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
|
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
|
||||||
return
|
return
|
||||||
@@ -204,6 +220,11 @@ func (s *Server) handleListTenantUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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)
|
users, err := s.users.ListByTenant(r.Context(), tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list tenant users")
|
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")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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)
|
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")
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
||||||
return
|
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 {
|
if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete logo")
|
writeError(w, http.StatusInternalServerError, "failed to delete logo")
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user