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>
216 lines
6.3 KiB
Go
216 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"archivmail/internal/audit"
|
|
)
|
|
|
|
// ── Logo handlers (admin: any tenant) ───────────────────────────────────────
|
|
|
|
func (s *Server) handleGetTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
id, err := parseTenantID(r)
|
|
if err != nil {
|
|
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")
|
|
return
|
|
}
|
|
if data == nil {
|
|
writeError(w, http.StatusNotFound, "no logo set")
|
|
return
|
|
}
|
|
if contentType == "" {
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleUploadTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
id, err := parseTenantID(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
|
return
|
|
}
|
|
s.saveTenantLogo(w, r, id)
|
|
}
|
|
|
|
func (s *Server) handleDeleteTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
id, err := parseTenantID(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid tenant id")
|
|
return
|
|
}
|
|
if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete logo")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "tenant_logo_deleted",
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
Detail: "Mandant-Logo gelöscht (tenant " + strconv.FormatInt(id, 10) + ")",
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ── Logo handlers (domain_admin: own tenant) ─────────────────────────────────
|
|
|
|
func (s *Server) handleGetOwnTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusBadRequest, "no tenant context")
|
|
return
|
|
}
|
|
data, contentType, err := s.tenantStore.GetLogo(r.Context(), *sess.TenantID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load logo")
|
|
return
|
|
}
|
|
if data == nil {
|
|
writeError(w, http.StatusNotFound, "no logo set")
|
|
return
|
|
}
|
|
if contentType == "" {
|
|
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)
|
|
}
|
|
|
|
func (s *Server) handleUploadOwnTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusBadRequest, "no tenant context")
|
|
return
|
|
}
|
|
s.saveTenantLogo(w, r, *sess.TenantID)
|
|
}
|
|
|
|
func (s *Server) handleDeleteOwnTenantLogo(w http.ResponseWriter, r *http.Request) {
|
|
if s.tenantStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
|
|
return
|
|
}
|
|
sess := sessionFromCtx(r.Context())
|
|
if sess.TenantID == nil {
|
|
writeError(w, http.StatusBadRequest, "no tenant context")
|
|
return
|
|
}
|
|
if err := s.tenantStore.DeleteLogo(r.Context(), *sess.TenantID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete logo")
|
|
return
|
|
}
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "tenant_logo_deleted",
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
Detail: "Mandant-Logo gelöscht",
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// saveTenantLogo is the shared multipart upload logic for logo handlers.
|
|
func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID int64) {
|
|
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
|
|
writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
|
return
|
|
}
|
|
file, header, err := r.FormFile("logo")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "logo file required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
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,
|
|
}
|
|
if !allowed[contentType] {
|
|
writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp)")
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to read logo")
|
|
return
|
|
}
|
|
if int64(len(data)) > maxLogoSize {
|
|
writeError(w, http.StatusBadRequest, "logo too large (max 2 MB)")
|
|
return
|
|
}
|
|
|
|
if err := s.tenantStore.SetLogo(r.Context(), tenantID, data, contentType); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save logo")
|
|
return
|
|
}
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: "tenant_logo_uploaded",
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
Detail: fmt.Sprintf("Mandant-Logo hochgeladen (%d bytes, %s, tenant %d)", len(data), contentType, tenantID),
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|