fix(PROJ-61): Cross-Tenant Stored XSS via Mandanten-Logo behoben (Sicherheitsbug)

GET /api/tenants/{id}/logo prüfte nur die Authentifizierung, aber keinen
Tenant-Scope — jeder eingeloggte Nutzer konnte das Logo jedes beliebigen
Tenants lesen. Kombiniert mit dem bisher erlaubten SVG-Upload (kann
eingebettetes JavaScript enthalten) ergab das einen Cross-Tenant Stored-XSS:
ein domain_admin konnte ein bösartiges SVG als eigenes Logo hochladen und
Opfer aus beliebigen anderen Tenants per direktem Link darauf locken.

Fix: tenantAccessAllowed()-Scope-Check beim Logo-Lesepfad (analog PROJ-55),
SVG aus erlaubten Upload-Typen entfernt, X-Content-Type-Options: nosniff
als Defense-in-Depth ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-25 00:31:41 +02:00
co-authored by Claude Sonnet 4.6
parent 5f63bfe8d4
commit 363874767b
4 changed files with 73 additions and 9 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) SetTenants(store *tenantstore.Store) {
s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain)))
s.mux.HandleFunc("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers)))
// Logo routes: any auth can read; admin can write
// Logo routes: read is tenant-scoped inside handleGetTenantLogo (PROJ-61); admin can write
s.mux.HandleFunc("GET /api/tenants/{id}/logo", s.auth(s.handleGetTenantLogo))
s.mux.HandleFunc("POST /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadTenantLogo)))
s.mux.HandleFunc("DELETE /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenantLogo)))
+17 -7
View File
@@ -21,6 +21,13 @@ func (s *Server) handleGetTenantLogo(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
// Tenant-scope enforcement (IDOR fix, PROJ-61): non-global sessions may only
// read their own tenant's logo. Global admins (sess.TenantID == nil) see all.
sess := sessionFromCtx(r.Context())
if !tenantAccessAllowed(sess, &id) {
writeError(w, http.StatusForbidden, "access denied")
return
}
data, contentType, err := s.tenantStore.GetLogo(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load logo")
@@ -34,6 +41,7 @@ func (s *Server) handleGetTenantLogo(w http.ResponseWriter, r *http.Request) {
contentType = "image/png"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
@@ -103,6 +111,7 @@ func (s *Server) handleGetOwnTenantLogo(w http.ResponseWriter, r *http.Request)
contentType = "image/png"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
@@ -163,16 +172,17 @@ func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID
if contentType == "" {
contentType = "image/png"
}
// SVG intentionally NOT allowed (PROJ-61): SVG can carry embedded
// JavaScript and would be served same-origin as image/svg+xml → stored XSS.
allowed := map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/gif": true,
"image/webp": true,
"image/svg+xml": true,
"image/png": true,
"image/jpeg": true,
"image/jpg": true,
"image/gif": true,
"image/webp": true,
}
if !allowed[contentType] {
writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp, svg)")
writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp)")
return
}