287 lines
7.2 KiB
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
|
|
}
|