diff --git a/backend/internal/appconfig/model.go b/backend/internal/appconfig/model.go index ff74e66b..ba2ca62c 100644 --- a/backend/internal/appconfig/model.go +++ b/backend/internal/appconfig/model.go @@ -15,58 +15,73 @@ import ( type AppConfigModel struct { // General - AppName AppConfigValue `json:"appName" public:"true"` - SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes - HomePageURL AppConfigValue `json:"homePageUrl" public:"true"` - EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"` - AccentColor AppConfigValue `json:"accentColor" public:"true"` - DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"` - AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"` - AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"` + AppName AppConfigValue `json:"appName" env:"APP_NAME" public:"true"` + SessionDuration AppConfigValue `json:"sessionDuration" env:"SESSION_DURATION" type:"int"` // In minutes + HomePageURL AppConfigValue `json:"homePageUrl" env:"HOME_PAGE_URL" public:"true"` + EmailsVerified AppConfigValue `json:"emailsVerified" env:"EMAILS_VERIFIED" type:"bool"` + AccentColor AppConfigValue `json:"accentColor" env:"ACCENT_COLOR" public:"true"` + DisableAnimations AppConfigValue `json:"disableAnimations" env:"DISABLE_ANIMATIONS" type:"bool" public:"true"` + AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" env:"ALLOW_OWN_ACCOUNT_EDIT" type:"bool" public:"true"` + AllowUserSignups AppConfigValue `json:"allowUserSignups" env:"ALLOW_USER_SIGNUPS" public:"true"` - SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings - SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string} + SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs" env:"SIGNUP_DEFAULT_USER_GROUP_IDS"` // JSON-encoded array of strings + SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims" env:"SIGNUP_DEFAULT_CUSTOM_CLAIMS"` // JSON-encoded array of {key:string,value:string} // Email - RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"` - SmtpHost AppConfigValue `json:"smtpHost"` - SmtpPort AppConfigValue `json:"smtpPort"` - SmtpFrom AppConfigValue `json:"smtpFrom"` - SmtpUser AppConfigValue `json:"smtpUser"` - SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"` - SmtpTls AppConfigValue `json:"smtpTls"` - SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"` - EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"` - EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"` - EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"` - EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"` - EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"` + RequireUserEmail AppConfigValue `json:"requireUserEmail" env:"REQUIRE_USER_EMAIL" type:"bool" public:"true"` + SmtpHost AppConfigValue `json:"smtpHost" env:"SMTP_HOST"` + SmtpPort AppConfigValue `json:"smtpPort" env:"SMTP_PORT"` + SmtpFrom AppConfigValue `json:"smtpFrom" env:"SMTP_FROM"` + SmtpUser AppConfigValue `json:"smtpUser" env:"SMTP_USER"` + SmtpPassword AppConfigValue `json:"smtpPassword" env:"SMTP_PASSWORD" sensitive:"true"` + SmtpTls AppConfigValue `json:"smtpTls" env:"SMTP_TLS"` + SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" env:"SMTP_SKIP_CERT_VERIFY" type:"bool"` + EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" env:"EMAIL_LOGIN_NOTIFICATION_ENABLED" type:"bool"` + EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED" type:"bool" public:"true"` + EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED" type:"bool" public:"true"` + EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" env:"EMAIL_API_KEY_EXPIRATION_ENABLED" type:"bool"` + EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" env:"EMAIL_VERIFICATION_ENABLED" type:"bool" public:"true"` // LDAP - LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"` - LdapUrl AppConfigValue `json:"ldapUrl"` - LdapBindDn AppConfigValue `json:"ldapBindDn"` - LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"` - LdapBase AppConfigValue `json:"ldapBase"` - LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"` - LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"` - LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"` - LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"` - LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"` - LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"` - LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"` + LdapEnabled AppConfigValue `json:"ldapEnabled" env:"LDAP_ENABLED" type:"bool" public:"true"` + LdapUrl AppConfigValue `json:"ldapUrl" env:"LDAP_URL"` + LdapBindDn AppConfigValue `json:"ldapBindDn" env:"LDAP_BIND_DN"` + LdapBindPassword AppConfigValue `json:"ldapBindPassword" env:"LDAP_BIND_PASSWORD" sensitive:"true"` + LdapBase AppConfigValue `json:"ldapBase" env:"LDAP_BASE"` + LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter" env:"LDAP_USER_SEARCH_FILTER"` + LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter" env:"LDAP_USER_GROUP_SEARCH_FILTER"` + LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" env:"LDAP_SKIP_CERT_VERIFY" type:"bool"` + LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier" env:"LDAP_ATTRIBUTE_USER_UNIQUE_IDENTIFIER"` + LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername" env:"LDAP_ATTRIBUTE_USER_USERNAME"` + LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail" env:"LDAP_ATTRIBUTE_USER_EMAIL"` + LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName" env:"LDAP_ATTRIBUTE_USER_FIRST_NAME"` + LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName" env:"LDAP_ATTRIBUTE_USER_LAST_NAME"` + LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName" env:"LDAP_ATTRIBUTE_USER_DISPLAY_NAME"` + LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture" env:"LDAP_ATTRIBUTE_USER_PROFILE_PICTURE"` + LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember" env:"LDAP_ATTRIBUTE_GROUP_MEMBER"` + LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier" env:"LDAP_ATTRIBUTE_GROUP_UNIQUE_IDENTIFIER"` + LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName" env:"LDAP_ATTRIBUTE_GROUP_NAME"` + LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName" env:"LDAP_ADMIN_GROUP_NAME"` + LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" env:"LDAP_SOFT_DELETE_USERS" type:"bool"` // WebAuthn - WebauthnUserVerification AppConfigValue `json:"webauthnUserVerification"` - WebauthnAllowSyncedPasskeys AppConfigValue `json:"webauthnAllowSyncedPasskeys" type:"bool"` - WebauthnAuthenticatorAttachment AppConfigValue `json:"webauthnAuthenticatorAttachment"` + WebauthnUserVerification AppConfigValue `json:"webauthnUserVerification" env:"WEBAUTHN_USER_VERIFICATION"` + WebauthnAllowSyncedPasskeys AppConfigValue `json:"webauthnAllowSyncedPasskeys" env:"WEBAUTHN_ALLOW_SYNCED_PASSKEYS" type:"bool"` + WebauthnAuthenticatorAttachment AppConfigValue `json:"webauthnAuthenticatorAttachment" env:"WEBAUTHN_AUTHENTICATOR_ATTACHMENT"` // OIDC - CIMDURLAllowlist AppConfigValue `json:"cimdUrlAllowlist"` // JSON-encoded array of strings + CIMDURLAllowlist AppConfigValue `json:"cimdUrlAllowlist" env:"CIMD_URL_ALLOWLIST"` // JSON-encoded array of strings +} + +// appConfigEnvName returns the explicit environment variable name for a JSON configuration field +func appConfigEnvName(jsonName string) (string, bool) { + modelType := reflect.TypeFor[AppConfigModel]() + for i := range modelType.NumField() { + field := modelType.Field(i) + fieldJSONName, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if fieldJSONName == jsonName { + envName := field.Tag.Get("env") + return envName, envName != "" + } + } + + return "", false } // Clone returns a deep copy of the AppConfigModel. diff --git a/backend/internal/appconfig/model_test.go b/backend/internal/appconfig/model_test.go index 267ed753..19d3b9a3 100644 --- a/backend/internal/appconfig/model_test.go +++ b/backend/internal/appconfig/model_test.go @@ -24,6 +24,64 @@ func dtoWithMarkerValues() dto.AppConfigUpdateDto { return input } +func TestAppConfigModel_FieldTags(t *testing.T) { + modelType := reflect.TypeFor[AppConfigModel]() + jsonNames := make(map[string]string, modelType.NumField()) + envNames := make(map[string]string, modelType.NumField()) + for i := range modelType.NumField() { + field := modelType.Field(i) + t.Run(field.Name, func(t *testing.T) { + jsonTag, ok := field.Tag.Lookup("json") + require.True(t, ok) + jsonName, _, _ := strings.Cut(jsonTag, ",") + require.NotEmpty(t, jsonName) + require.NotEqual(t, "-", jsonName) + require.NotContains(t, jsonNames, jsonName) + jsonNames[jsonName] = field.Name + + envName, ok := field.Tag.Lookup("env") + require.True(t, ok) + require.NotEmpty(t, envName) + require.NotContains(t, envNames, envName) + envNames[envName] = field.Name + }) + } +} + +func TestAppConfigModel_TypeTagsHaveDTOValidation(t *testing.T) { + validatorsByType := map[string]string{ + "bool": "boolean_string", + "int": "integer_string", + } + + dtoType := reflect.TypeFor[dto.AppConfigUpdateDto]() + dtoBindings := make(map[string][]string, dtoType.NumField()) + for i := range dtoType.NumField() { + field := dtoType.Field(i) + jsonName, _, _ := strings.Cut(field.Tag.Get("json"), ",") + dtoBindings[jsonName] = strings.Split(field.Tag.Get("binding"), ",") + } + + modelType := reflect.TypeFor[AppConfigModel]() + for i := range modelType.NumField() { + field := modelType.Field(i) + configType := field.Tag.Get("type") + if configType == "" { + continue + } + + t.Run(field.Name, func(t *testing.T) { + expectedValidator, ok := validatorsByType[configType] + require.True(t, ok) + + jsonName, _, _ := strings.Cut(field.Tag.Get("json"), ",") + bindings, ok := dtoBindings[jsonName] + require.True(t, ok) + require.Contains(t, bindings, expectedValidator) + }) + } +} + func TestAppConfigModel_Replace(t *testing.T) { t.Run("populates every property from the DTO", func(t *testing.T) { input := dtoWithMarkerValues() diff --git a/backend/internal/appconfig/service.go b/backend/internal/appconfig/service.go index 4e367b6c..7412b7ae 100644 --- a/backend/internal/appconfig/service.go +++ b/backend/internal/appconfig/service.go @@ -7,9 +7,9 @@ import ( "fmt" "os" "reflect" - "strings" "time" + "github.com/go-playground/validator/v10" "github.com/italypaleale/francis/actor" "github.com/italypaleale/francis/host/local" "gorm.io/gorm" @@ -212,9 +212,10 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) { for i := range rt.NumField() { field := rt.Field(i) - // Derive the environment variable name from the configuration's JSON key - key, _, _ := strings.Cut(field.Tag.Get("json"), ",") - envVarName := utils.CamelCaseToScreamingSnakeCase(key) + envVarName := field.Tag.Get("env") + if envVarName == "" { + return nil, fmt.Errorf("app configuration field %s is missing its environment variable name", field.Name) + } // Set the value if it's set value, ok := os.LookupEnv(envVarName) @@ -239,5 +240,42 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) { } } + // Validate the resolved configuration before exposing values to the rest of the application + if err := validateEnvConfig(dest); err != nil { + return nil, err + } + return dest, nil } + +// validateEnvConfig applies the HTTP configuration rules and reports failures using environment variable names +func validateEnvConfig(config *AppConfigModel) error { + // Map the resolved model to the canonical update DTO so both configuration paths share validation rules + var input dto.AppConfigUpdateDto + if err := dto.MapStruct(config, &input); err != nil { + return fmt.Errorf("failed to prepare environment app configuration for validation: %w", err) + } + + // Collect every invalid environment variable + err := input.Validate() + if err != nil { + validationErrors, ok := errors.AsType[validator.ValidationErrors](err) + if !ok { + return fmt.Errorf("failed to validate environment app configuration: %w", err) + } + + failures := make([]error, 0, len(validationErrors)) + for _, validationError := range validationErrors { + envName, ok := appConfigEnvName(validationError.Field()) + if !ok { + return fmt.Errorf("failed to find the environment variable for app configuration field %s", validationError.Field()) + } + _, message := dto.ValidationErrorDetails(validationError) + failures = append(failures, fmt.Errorf("%s %s", envName, message)) + } + + return fmt.Errorf("invalid environment app configuration: %w", errors.Join(failures...)) + } + + return nil +} diff --git a/backend/internal/appconfig/service_test.go b/backend/internal/appconfig/service_test.go index 83f34090..d53863b0 100644 --- a/backend/internal/appconfig/service_test.go +++ b/backend/internal/appconfig/service_test.go @@ -106,6 +106,7 @@ func TestService_NewService(t *testing.T) { t.Run("loads config from the environment when the UI config is disabled", func(t *testing.T) { setUIConfigDisabled(t, true) t.Setenv("APP_NAME", "Environment App") + t.Setenv("SIGNUP_DEFAULT_CUSTOM_CLAIMS", `[{"key":"role","value":"user"}]`) // No actor host or database is needed when the UI config is disabled svc, err := NewService(t.Context(), nil, nil) @@ -115,6 +116,72 @@ func TestService_NewService(t *testing.T) { cfg, err := svc.GetConfig(t.Context()) require.NoError(t, err) assert.Equal(t, AppConfigValue("Environment App"), cfg.AppName) + assert.JSONEq(t, `[{"key":"role","value":"user"}]`, string(cfg.SignupDefaultCustomClaims)) + }) + + t.Run("rejects invalid environment application configuration", func(t *testing.T) { + tests := []struct { + name string + envName string + value string + wantDetail string + }{ + { + name: "malformed custom claims JSON", + envName: "SIGNUP_DEFAULT_CUSTOM_CLAIMS", + value: `["immich_role": "user"]`, + wantDetail: `JSON array of objects with string "key" and "value" properties`, + }, + { + name: "custom claim missing value", + envName: "SIGNUP_DEFAULT_CUSTOM_CLAIMS", + value: `[{"key":"role"}]`, + wantDetail: `JSON array of objects with string "key" and "value" properties`, + }, + { + name: "user group IDs with the wrong shape", + envName: "SIGNUP_DEFAULT_USER_GROUP_IDS", + value: `{"group":"id"}`, + wantDetail: "JSON array of strings", + }, + { + name: "unsafe CIMD URL pattern", + envName: "CIMD_URL_ALLOWLIST", + value: `["javascript:alert(1)"]`, + wantDetail: "JSON array of valid callback URL patterns", + }, + { + name: "invalid enum", + envName: "WEBAUTHN_USER_VERIFICATION", + value: "sometimes", + wantDetail: "is invalid", + }, + { + name: "invalid boolean type", + envName: "REQUIRE_USER_EMAIL", + value: "hello", + wantDetail: "must be either true or false", + }, + { + name: "invalid integer type", + envName: "SESSION_DURATION", + value: "hello", + wantDetail: "must be an integer", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setUIConfigDisabled(t, true) + t.Setenv(test.envName, test.value) + + svc, err := NewService(t.Context(), nil, nil) + require.Error(t, err) + assert.Nil(t, svc) + require.ErrorContains(t, err, test.envName) + require.ErrorContains(t, err, test.wantDetail) + }) + } }) } diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 6cebfec2..35b94c77 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -1,5 +1,7 @@ package dto +import "github.com/gin-gonic/gin/binding" + type PublicAppConfigVariableDto struct { Key string `json:"key"` Type string `json:"type"` @@ -13,31 +15,31 @@ type AppConfigVariableDto struct { type AppConfigUpdateDto struct { AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"` - SessionDuration string `json:"sessionDuration" binding:"required"` + SessionDuration string `json:"sessionDuration" binding:"required,integer_string"` HomePageURL string `json:"homePageUrl" binding:"required"` - EmailsVerified string `json:"emailsVerified" binding:"required"` - DisableAnimations string `json:"disableAnimations" binding:"required"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` + EmailsVerified string `json:"emailsVerified" binding:"required,boolean_string"` + DisableAnimations string `json:"disableAnimations" binding:"required,boolean_string"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required,boolean_string"` AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"` - SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"` - SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"` + SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json_string_array"` + SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json_custom_claims"` AccentColor string `json:"accentColor"` - RequireUserEmail string `json:"requireUserEmail" binding:"required"` + RequireUserEmail string `json:"requireUserEmail" binding:"required,boolean_string"` SmtpHost string `json:"smtpHost"` SmtpPort string `json:"smtpPort"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpUser string `json:"smtpUser"` SmtpPassword string `json:"smtpPassword"` SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` - LdapEnabled string `json:"ldapEnabled" binding:"required"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify" binding:"required,boolean_string"` + LdapEnabled string `json:"ldapEnabled" binding:"required,boolean_string"` LdapUrl string `json:"ldapUrl"` LdapBindDn string `json:"ldapBindDn"` LdapBindPassword string `json:"ldapBindPassword"` LdapBase string `json:"ldapBase"` LdapUserSearchFilter string `json:"ldapUserSearchFilter"` LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify string `json:"ldapSkipCertVerify"` + LdapSkipCertVerify string `json:"ldapSkipCertVerify" binding:"required,boolean_string"` LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` @@ -49,14 +51,18 @@ type AppConfigUpdateDto struct { LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAdminGroupName string `json:"ldapAdminGroupName"` - LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` + LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers" binding:"required,boolean_string"` WebauthnUserVerification string `json:"webauthnUserVerification" binding:"required,oneof=required preferred"` - WebauthnAllowSyncedPasskeys string `json:"webauthnAllowSyncedPasskeys" binding:"required,oneof=true false"` + WebauthnAllowSyncedPasskeys string `json:"webauthnAllowSyncedPasskeys" binding:"required,boolean_string"` WebauthnAuthenticatorAttachment string `json:"webauthnAuthenticatorAttachment" binding:"required,oneof=any platform cross-platform"` - EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"` - EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` - EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` - EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"` - EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"` - CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,json"` + EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required,boolean_string"` + EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required,boolean_string"` + EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required,boolean_string"` + EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required,boolean_string"` + EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required,boolean_string"` + CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,cimd_url_allowlist"` +} + +func (a AppConfigUpdateDto) Validate() error { + return binding.Validator.ValidateStruct(a) } diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go index 2637c1ab..b2917d67 100644 --- a/backend/internal/dto/validations.go +++ b/backend/internal/dto/validations.go @@ -1,9 +1,12 @@ package dto import ( + "encoding/json" + "fmt" "net/url" "reflect" "regexp" + "strconv" "strings" "time" @@ -65,6 +68,21 @@ func init() { "token_duration": func(fl validator.FieldLevel) bool { return model.IsValidTokenDurationMinutes(fl.Field().Int()) }, + "json_string_array": func(fl validator.FieldLevel) bool { + return validateJSONStringArray(fl.Field().String()) + }, + "json_custom_claims": func(fl validator.FieldLevel) bool { + return validateJSONCustomClaims(fl.Field().String()) + }, + "cimd_url_allowlist": func(fl validator.FieldLevel) bool { + return validateCIMDURLAllowlist(fl.Field().String()) + }, + "boolean_string": func(fl validator.FieldLevel) bool { + return validateBooleanString(fl.Field().String()) + }, + "integer_string": func(fl validator.FieldLevel) bool { + return validateIntegerString(fl.Field().String()) + }, } for k, v := range validators { err := engine.RegisterValidation(k, v) @@ -74,6 +92,90 @@ func init() { } } +// validateJSONStringArray requires an array so downstream consumers never receive another valid JSON type +func validateJSONStringArray(value string) bool { + var items []string + return json.Unmarshal([]byte(value), &items) == nil && items != nil +} + +// validateJSONCustomClaims requires every claim to contain string key and value properties +func validateJSONCustomClaims(value string) bool { + var claims []*struct { + Key *string `json:"key"` + Value *string `json:"value"` + } + if err := json.Unmarshal([]byte(value), &claims); err != nil || claims == nil { + return false + } + + for _, claim := range claims { + if claim == nil || claim.Key == nil || claim.Value == nil { + return false + } + } + + return true +} + +// validateCIMDURLAllowlist requires an array of safe callback URL patterns +func validateCIMDURLAllowlist(value string) bool { + var patterns []string + if err := json.Unmarshal([]byte(value), &patterns); err != nil || patterns == nil { + return false + } + + for _, pattern := range patterns { + if !ValidateCallbackURLPattern(pattern) { + return false + } + } + + return true +} + +// validateBooleanString accepts the exact values understood consistently by the backend and frontend +func validateBooleanString(value string) bool { + return value == "true" || value == "false" +} + +// validateIntegerString accepts the same integer representation used by AppConfigValue.AsDurationMinutes +func validateIntegerString(value string) bool { + _, err := strconv.Atoi(value) + return err == nil +} + +// ValidationErrorDetails returns the stable code and human-readable message for a validation failure +func ValidationErrorDetails(validationError validator.FieldError) (string, string) { + switch validationError.Tag() { + case "required": + return "required", "is required" + case "email": + return "invalid_format", "must be a valid email address" + case "username": + return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character" + case "url": + return "invalid_format", "must be a valid URL" + case "resource_uri": + return "invalid_format", "must be an absolute URI without whitespace or a fragment" + case "min": + return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param()) + case "max": + return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param()) + case "json_string_array": + return "invalid_format", "must be a JSON array of strings" + case "json_custom_claims": + return "invalid_format", `must be a JSON array of objects with string "key" and "value" properties` + case "cimd_url_allowlist": + return "invalid_format", "must be a JSON array of valid callback URL patterns" + case "boolean_string": + return "invalid_format", "must be either true or false" + case "integer_string": + return "invalid_format", "must be an integer" + default: + return validationError.Tag(), "is invalid" + } +} + // ValidateUsername validates username inputs func ValidateUsername(username string) bool { return validateUsernameRegex.MatchString(username) diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go index 59ba8fdb..6047b088 100644 --- a/backend/internal/dto/validations_test.go +++ b/backend/internal/dto/validations_test.go @@ -36,6 +36,72 @@ func TestTokenDurationValidation(t *testing.T) { } } +func TestAppConfigJSONValidation(t *testing.T) { + tests := []struct { + name string + valid bool + }{ + { + name: "valid string array", + valid: validateJSONStringArray(`["group-id"]`), + }, + { + name: "valid custom claims", + valid: validateJSONCustomClaims(`[{"key":"role","value":"user"}]`), + }, + { + name: "valid CIMD URL allowlist", + valid: validateCIMDURLAllowlist(`["https://app.example.com/**"]`), + }, + { + name: "custom claims object instead of array", + valid: !validateJSONCustomClaims(`{"key":"role","value":"user"}`), + }, + { + name: "custom claim missing key", + valid: !validateJSONCustomClaims(`[{"value":"user"}]`), + }, + { + name: "non-string group ID", + valid: !validateJSONStringArray(`[42]`), + }, + { + name: "null array", + valid: !validateJSONCustomClaims(`null`), + }, + { + name: "unsafe CIMD pattern", + valid: !validateCIMDURLAllowlist(`["data:text/html,test"]`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.True(t, test.valid) + }) + } +} + +func TestAppConfigValueTypeValidation(t *testing.T) { + for _, test := range []struct { + name string + valid bool + }{ + {name: "true boolean", valid: validateBooleanString("true")}, + {name: "false boolean", valid: validateBooleanString("false")}, + {name: "non-boolean word", valid: !validateBooleanString("hello")}, + {name: "numeric boolean", valid: !validateBooleanString("1")}, + {name: "integer", valid: validateIntegerString("60")}, + {name: "negative integer", valid: validateIntegerString("-1")}, + {name: "decimal", valid: !validateIntegerString("1.5")}, + {name: "non-integer word", valid: !validateIntegerString("hello")}, + } { + t.Run(test.name, func(t *testing.T) { + assert.True(t, test.valid) + }) + } +} + func TestValidateUsername(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/middleware/error_handler.go b/backend/internal/middleware/error_handler.go index f2acb9b6..bf447a6f 100644 --- a/backend/internal/middleware/error_handler.go +++ b/backend/internal/middleware/error_handler.go @@ -16,6 +16,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/google/uuid" "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/dto" "go.opentelemetry.io/otel/trace" ) @@ -158,7 +159,7 @@ func classifiedValidationError(validationErrors validator.ValidationErrors) clas for _, validationError := range validationErrors { fieldName := validationError.Field() - code, message := validationFieldError(validationError) + code, message := dto.ValidationErrorDetails(validationError) fields = append(fields, apperror.FieldError{ Field: fieldName, Code: code, @@ -175,27 +176,6 @@ func classifiedValidationError(validationErrors validator.ValidationErrors) clas } } -func validationFieldError(validationError validator.FieldError) (string, string) { - switch validationError.Tag() { - case "required": - return "required", "is required" - case "email": - return "invalid_format", "must be a valid email address" - case "username": - return "invalid_format", "must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character" - case "url": - return "invalid_format", "must be a valid URL" - case "resource_uri": - return "invalid_format", "must be an absolute URI without whitespace or a fragment" - case "min": - return "too_short", fmt.Sprintf("must be at least %s characters long", validationError.Param()) - case "max": - return "too_long", fmt.Sprintf("must be at most %s characters long", validationError.Param()) - default: - return validationError.Tag(), "is invalid" - } -} - func writeErrorResponse(c *gin.Context, classified classifiedError, requestID string) { details := make(map[string]any, len(classified.details)+1) for key, value := range classified.details { diff --git a/backend/internal/middleware/error_handler_test.go b/backend/internal/middleware/error_handler_test.go index 2ff97db9..7b2165f5 100644 --- a/backend/internal/middleware/error_handler_test.go +++ b/backend/internal/middleware/error_handler_test.go @@ -164,6 +164,96 @@ func TestValidationResponseUsesJSONFieldNames(t *testing.T) { }) } +func TestValidationResponseUsesSpecificCustomMessages(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.POST("/config", httpserver.Handle(func(c *gin.Context) error { + var input struct { + SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"json_custom_claims"` + } + return httpserver.BindJSON(c, &input) + })) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/config", strings.NewReader(`{"signupDefaultCustomClaims":"[\"immich_role\": \"user\"]"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + var body struct { + Error string `json:"error"` + Details struct { + Fields []apperror.FieldError `json:"fields"` + } `json:"details"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, `SignupDefaultCustomClaims must be a JSON array of objects with string "key" and "value" properties`, body.Error) + require.Equal(t, []apperror.FieldError{{ + Field: "signupDefaultCustomClaims", + Code: "invalid_format", + Message: `must be a JSON array of objects with string "key" and "value" properties`, + }}, body.Details.Fields) +} + +func TestValidationResponseUsesAppConfigTypeMessages(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(NewErrorHandlerMiddleware().Add()) + router.POST("/config", httpserver.Handle(func(c *gin.Context) error { + var input dto.AppConfigUpdateDto + return httpserver.BindJSON(c, &input) + })) + + input := dto.AppConfigUpdateDto{ + AppName: "Pocket ID", + SessionDuration: "60", + HomePageURL: "/settings/account", + EmailsVerified: "false", + DisableAnimations: "false", + AllowOwnAccountEdit: "true", + AllowUserSignups: "disabled", + RequireUserEmail: "hello", + SmtpTls: "none", + SmtpSkipCertVerify: "false", + LdapEnabled: "false", + LdapSkipCertVerify: "false", + LdapSoftDeleteUsers: "true", + WebauthnUserVerification: "preferred", + WebauthnAllowSyncedPasskeys: "true", + WebauthnAuthenticatorAttachment: "any", + EmailOneTimeAccessAsAdminEnabled: "false", + EmailOneTimeAccessAsUnauthenticatedEnabled: "false", + EmailLoginNotificationEnabled: "false", + EmailApiKeyExpirationEnabled: "false", + EmailVerificationEnabled: "false", + } + payload, err := json.Marshal(input) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/config", strings.NewReader(string(payload))) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + + var body struct { + Error string `json:"error"` + Details struct { + Fields []apperror.FieldError `json:"fields"` + } `json:"details"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "RequireUserEmail must be either true or false", body.Error) + require.Equal(t, []apperror.FieldError{{ + Field: "requireUserEmail", + Code: "invalid_format", + Message: "must be either true or false", + }}, body.Details.Fields) +} + func TestClassifyUnmappedPersistenceErrorAsInternal(t *testing.T) { classified := classifyError(gorm.ErrRecordNotFound)