Files
archivmail/internal/api/tenant_logo_handlers.go
T
sysopsandClaude Sonnet 4.6 dcb88317ac 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>
2026-06-30 14:37:39 +02:00

226 lines
6.7 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
}
// 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)
}
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
}
// 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
}
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})
}