diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index 452f384971..659c675eb7 100644 --- a/services/proxy/pkg/config/config.go +++ b/services/proxy/pkg/config/config.go @@ -24,27 +24,28 @@ type Config struct { GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` GrpcClient client.Client `yaml:"-"` - RoleQuotas map[string]uint64 `yaml:"role_quotas"` - Policies []Policy `yaml:"policies"` - AdditionalPolicies []Policy `yaml:"additional_policies"` - OIDC OIDC `yaml:"oidc"` - ServiceAccount ServiceAccount `yaml:"service_account"` - RoleAssignment RoleAssignment `yaml:"role_assignment"` - PolicySelector *PolicySelector `yaml:"policy_selector"` - PreSignedURL PreSignedURL `yaml:"pre_signed_url"` - AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"1.0.0"` - UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"1.0.0"` - UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"1.0.0"` - MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"1.0.0" mask:"password"` - AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"1.0.0"` - AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"` - EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"1.0.0"` - InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"1.0.0"` - BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"1.0.0"` - AuthMiddleware AuthMiddleware `yaml:"auth_middleware"` - PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"` - CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"1.0.0"` - Events Events `yaml:"events"` + RoleQuotas map[string]uint64 `yaml:"role_quotas"` + Policies []Policy `yaml:"policies"` + AdditionalPolicies []Policy `yaml:"additional_policies"` + OIDC OIDC `yaml:"oidc"` + ServiceAccount ServiceAccount `yaml:"service_account"` + RoleAssignment RoleAssignment `yaml:"role_assignment"` + PolicySelector *PolicySelector `yaml:"policy_selector"` + PreSignedURL PreSignedURL `yaml:"pre_signed_url"` + AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"1.0.0"` + UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"1.0.0"` + UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"1.0.0"` + MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"1.0.0" mask:"password"` + AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"1.0.0"` + AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"` + EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"1.0.0"` + InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"1.0.0"` + BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"1.0.0"` + AuthMiddleware AuthMiddleware `yaml:"auth_middleware"` + PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"` + CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"1.0.0"` + CSPConfigFileOverrideLocation string `yaml:"csp_config_file_override_location" env:"PROXY_CSP_CONFIG_FILE_OVERRIDE_LOCATION" desc:"The location of the CSP configuration file override." introductionVersion:"%%NEXT%%"` + Events Events `yaml:"events"` Context context.Context `json:"-" yaml:"-"` } diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index adc27c054e..e0b2bcf5c8 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -92,9 +92,10 @@ func DefaultConfig() *config.Config { DisplayName: "name", Groups: "groups", }, - EnableBasicAuth: false, - InsecureBackends: false, - CSPConfigFileLocation: "", + EnableBasicAuth: false, + InsecureBackends: false, + CSPConfigFileLocation: "", + CSPConfigFileOverrideLocation: "", Events: config.Events{ Endpoint: "127.0.0.1:9233", Cluster: "opencloud-cluster", diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index f769e4fc7c..a536a2ec52 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -3,30 +3,48 @@ package middleware import ( "net/http" "os" + "reflect" gofig "github.com/gookit/config/v2" "github.com/gookit/config/v2/yaml" "github.com/opencloud-eu/opencloud/services/proxy/pkg/config" "github.com/unrolled/secure" "github.com/unrolled/secure/cspbuilder" + yamlv3 "gopkg.in/yaml.v3" ) // LoadCSPConfig loads CSP header configuration from a yaml file. func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) { - yamlContent, err := loadCSPYaml(proxyCfg) + yamlContent, customYamlContent, err := loadCSPYaml(proxyCfg) if err != nil { return nil, err } - return loadCSPConfig(yamlContent) + return loadCSPConfig(yamlContent, customYamlContent) } // LoadCSPConfig loads CSP header configuration from a yaml file. -func loadCSPConfig(yamlContent []byte) (*config.CSP, error) { +func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, error) { // substitute env vars and load to struct gofig.WithOptions(gofig.ParseEnv) gofig.AddDriver(yaml.Driver) - err := gofig.LoadSources("yaml", yamlContent) + presetMap := map[string]interface{}{} + err := yamlv3.Unmarshal(presetYamlContent, &presetMap) + if err != nil { + return nil, err + } + customMap := map[string]interface{}{} + err = yamlv3.Unmarshal(customYamlContent, &customMap) + if err != nil { + return nil, err + } + mergedMap := deepMerge(presetMap, customMap) + mergedYamlContent, err := yamlv3.Marshal(mergedMap) + if err != nil { + return nil, err + } + + err = gofig.LoadSources("yaml", mergedYamlContent) if err != nil { return nil, err } @@ -41,11 +59,78 @@ func loadCSPConfig(yamlContent []byte) (*config.CSP, error) { return &cspConfig, nil } -func loadCSPYaml(proxyCfg *config.Config) ([]byte, error) { - if proxyCfg.CSPConfigFileLocation == "" { - return []byte(config.DefaultCSPConfig), nil +// deepMerge recursively merges map2 into map1. +// - nested maps are merged recursively +// - slices are concatenated, preserving order and avoiding duplicates +// - scalar or type-mismatched values from map2 overwrite map1 +func deepMerge(map1, map2 map[string]interface{}) map[string]interface{} { + if map1 == nil { + out := make(map[string]interface{}, len(map2)) + for k, v := range map2 { + out[k] = v + } + return out } - return os.ReadFile(proxyCfg.CSPConfigFileLocation) + + for k, v2 := range map2 { + if v1, ok := map1[k]; ok { + // both maps -> recurse + if m1, ok1 := v1.(map[string]interface{}); ok1 { + if m2, ok2 := v2.(map[string]interface{}); ok2 { + map1[k] = deepMerge(m1, m2) + continue + } + } + + // both slices -> merge unique + if s1, ok1 := v1.([]interface{}); ok1 { + if s2, ok2 := v2.([]interface{}); ok2 { + merged := append([]interface{}{}, s1...) + for _, item := range s2 { + if !sliceContains(merged, item) { + merged = append(merged, item) + } + } + map1[k] = merged + continue + } + // s1 is slice, v2 single -> append if missing + if !sliceContains(s1, v2) { + map1[k] = append(s1, v2) + } + continue + } + + // default: overwrite + map1[k] = v2 + } else { + // new key -> just set + map1[k] = v2 + } + } + + return map1 +} + +func sliceContains(slice []interface{}, val interface{}) bool { + for _, v := range slice { + if reflect.DeepEqual(v, val) { + return true + } + } + return false +} + +func loadCSPYaml(proxyCfg *config.Config) ([]byte, []byte, error) { + if proxyCfg.CSPConfigFileOverrideLocation != "" { + overrideCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileOverrideLocation) + return overrideCSPYaml, []byte{}, err + } + if proxyCfg.CSPConfigFileLocation == "" { + return []byte(config.DefaultCSPConfig), nil, nil + } + customCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileLocation) + return []byte(config.DefaultCSPConfig), customCSPYaml, err } // Security is a middleware to apply security relevant http headers like CSP. diff --git a/services/proxy/pkg/middleware/security_test.go b/services/proxy/pkg/middleware/security_test.go index 01accf2c29..e83e4f2371 100644 --- a/services/proxy/pkg/middleware/security_test.go +++ b/services/proxy/pkg/middleware/security_test.go @@ -4,11 +4,12 @@ import ( "testing" "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" ) func TestLoadCSPConfig(t *testing.T) { // setup test env - yaml := ` + presetYaml := ` directives: frame-src: - '''self''' @@ -17,12 +18,23 @@ directives: - 'https://${COLLABORA_DOMAIN|collabora.opencloud.test}/' ` - config, err := loadCSPConfig([]byte(yaml)) + customYaml := ` +directives: + img-src: + - '''self''' + - 'data:' + frame-src: + - 'https://some.custom.domain/' +` + config, err := loadCSPConfig([]byte(presetYaml), []byte(customYaml)) if err != nil { t.Error(err) } - assert.Equal(t, config.Directives["frame-src"][0], "'self'") - assert.Equal(t, config.Directives["frame-src"][1], "https://embed.diagrams.net/") - assert.Equal(t, config.Directives["frame-src"][2], "https://onlyoffice.opencloud.test/") - assert.Equal(t, config.Directives["frame-src"][3], "https://collabora.opencloud.test/") + assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "'self'")) + assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://embed.diagrams.net/")) + assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://onlyoffice.opencloud.test/")) + assert.Assert(t, cmp.Contains(config.Directives["frame-src"], "https://collabora.opencloud.test/")) + + assert.Assert(t, cmp.Contains(config.Directives["img-src"], "'self'")) + assert.Assert(t, cmp.Contains(config.Directives["img-src"], "data:")) }