fix: improve validation of app config variables

This commit is contained in:
Elias Schneider
2026-08-08 16:37:14 +02:00
parent 76f6b249f2
commit 9e637d5aec
9 changed files with 513 additions and 91 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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)
})
}
})
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)