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:
co-authored by
Claude Sonnet 4.6
parent
5f63bfe8d4
commit
363874767b
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user