mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-02 09:11:28 +02:00
fix: improve validation of app config variables
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user