learn-platform-deploy/Backend/cmd/api/keycloak_theme_policy.go

287 lines
7.2 KiB
Go

package main
import (
"errors"
"net/url"
"strconv"
"strings"
)
var allowedThemeFontFamilies = map[string]struct{}{
"inter": {},
"system ui": {},
"roboto": {},
"open sans": {},
"lato": {},
"montserrat": {},
}
var allowedThemeAssetMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpeg": {},
"image/webp": {},
"image/svg+xml": {},
"image/x-icon": {},
"image/vnd.microsoft.icon": {},
}
func validateKeycloakThemePolicy(existing, patch Doc, claims *accessClaims) error {
scope := resolveThemeString(existing, patch, "scope", keycloakThemeScopePlatform)
status := resolveThemeString(existing, patch, "status", keycloakThemeStatusDraft)
metadata := resolveThemeMetadata(existing, patch)
if hasUnsafeThemeValue(metadata) {
return errors.New("theme policy violation: unsafe html or javascript content detected")
}
if err := validateThemeURLFields(metadata); err != nil {
return err
}
if err := validateThemeFontAndAssetPolicy(metadata); err != nil {
return err
}
requiresPublishLevelChecks := status == keycloakThemeStatusPublished || isThemeExportRequested(existing, patch, metadata)
if !requiresPublishLevelChecks {
return nil
}
if err := validateThemeLegalAndSecurityRequirements(metadata); err != nil {
return err
}
if scope == keycloakThemeScopeOrganization {
if err := validateOrganizationThemePublicationApproval(claims, metadata); err != nil {
return err
}
}
return nil
}
func resolveThemeString(existing, patch Doc, key, fallback string) string {
if value, ok := patch[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(strings.ToLower(value))
}
if value, ok := existing[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(strings.ToLower(value))
}
return fallback
}
func resolveThemeMetadata(existing, patch Doc) Doc {
if value, ok := patch["metadata"]; ok {
metadata, _ := normalizeThemeMetadata(value)
if metadata != nil {
return metadata
}
}
if value, ok := existing["metadata"]; ok {
metadata, _ := normalizeThemeMetadata(value)
if metadata != nil {
return metadata
}
}
return Doc{}
}
func hasUnsafeThemeValue(value any) bool {
switch v := value.(type) {
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
unsafeTokens := []string{"<script", "</script", "javascript:", "onerror=", "onload=", "<iframe", "srcdoc=", "data:text/html", "vbscript:"}
for _, token := range unsafeTokens {
if strings.Contains(normalized, token) {
return true
}
}
return false
case Doc:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case map[string]any:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case List:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case []any:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case []string:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
default:
return false
}
}
func validateThemeURLFields(metadata Doc) error {
for _, key := range []string{"supportUrl", "privacyUrl", "termsUrl"} {
value := extractThemeString(metadata, key)
if value == "" {
continue
}
if err := validateThemeURL(value); err != nil {
return errors.New("theme policy violation: " + key + " is invalid")
}
}
return nil
}
func validateThemeURL(raw string) error {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("unsupported scheme")
}
if strings.TrimSpace(parsed.Host) == "" {
return errors.New("missing host")
}
return nil
}
func validateThemeFontAndAssetPolicy(metadata Doc) error {
font := strings.ToLower(extractThemeString(metadata, "fontFamily"))
if font != "" {
if _, ok := allowedThemeFontFamilies[font]; !ok {
return errors.New("theme policy violation: fontFamily is not allowed")
}
}
for _, key := range []string{"logoAssetMimeType", "faviconAssetMimeType"} {
mimeType := strings.ToLower(extractThemeString(metadata, key))
if mimeType == "" {
continue
}
if _, ok := allowedThemeAssetMimeTypes[mimeType]; !ok {
return errors.New("theme policy violation: unsupported asset mime type")
}
}
return nil
}
func validateThemeLegalAndSecurityRequirements(metadata Doc) error {
privacyURL := extractThemeString(metadata, "privacyUrl")
termsURL := extractThemeString(metadata, "termsUrl")
if privacyURL == "" || termsURL == "" {
return errors.New("theme policy violation: privacyUrl and termsUrl are required for publish/export")
}
if err := validateThemeURL(privacyURL); err != nil {
return errors.New("theme policy violation: privacyUrl is invalid")
}
if err := validateThemeURL(termsURL); err != nil {
return errors.New("theme policy violation: termsUrl is invalid")
}
footerText := extractThemeString(metadata, "footerText")
securityText := extractThemeString(metadata, "securityText")
if footerText == "" || securityText == "" {
return errors.New("theme policy violation: footerText and securityText are required for publish/export")
}
return nil
}
func validateOrganizationThemePublicationApproval(claims *accessClaims, metadata Doc) error {
approvalValue, ok := metadata["platformApproval"]
if !ok {
return errors.New("theme policy violation: organisation theme publication requires platform approval")
}
approval, ok := normalizeThemeMetadata(approvalValue)
if !ok || approval == nil {
return errors.New("theme policy violation: invalid platformApproval payload")
}
approvedBy := extractThemeString(approval, "approvedBy")
approvedAt := extractThemeInt64(approval, "approvedAt")
if approvedBy == "" || approvedAt <= 0 {
return errors.New("theme policy violation: platformApproval.approvedBy and approvedAt are required")
}
if !hasAnyRole(claims, "platform_owner", "platform_admin", "admin") {
return errors.New("theme policy violation: platform approval must be applied by a platform role")
}
if !hasPermission(claims, permKeycloakThemesPolicyManage) {
return errors.New("theme policy violation: missing permission: keycloak_themes.policy.manage")
}
return nil
}
func isThemeExportRequested(existing, patch, metadata Doc) bool {
if value, ok := patch["exportRequested"].(bool); ok && value {
return true
}
if value, ok := existing["exportRequested"].(bool); ok && value {
return true
}
if value, ok := metadata["exportRequested"].(bool); ok && value {
return true
}
return false
}
func extractThemeString(doc Doc, key string) string {
value, ok := doc[key]
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(str)
}
func extractThemeInt64(doc Doc, key string) int64 {
value, ok := doc[key]
if !ok {
return 0
}
switch v := value.(type) {
case int:
return int64(v)
case int32:
return int64(v)
case int64:
return v
case float64:
return int64(v)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err == nil {
return parsed
}
}
return 0
}