From 20b903b32d840ef5a9b1ec213d62cc51baf51c40 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Mon, 6 Oct 2025 15:35:18 +0200 Subject: [PATCH 1/7] load two yaml configs Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index f769e4fc7..1422d927b 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -13,20 +13,26 @@ import ( // LoadCSPConfig loads CSP header configuration from a yaml file. func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) { - yamlContent, err := loadCSPYaml(proxyCfg) + presetYamlContent, customYamlContent, err := loadCSPYaml(proxyCfg) if err != nil { return nil, err } - return loadCSPConfig(yamlContent) + return loadCSPConfig(presetYamlContent, 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) + // TODO: merge all sources into one struct + // ATM it is untested how this merger behaves with multiple sources + // it might be better to load preset and custom separately and then merge structs + // or load preset first and then custom to override values + // especially in hindsight that there will be autoloaded config files from webapps + // in the future + err := gofig.LoadSources("yaml", presetYamlContent, customYamlContent) if err != nil { return nil, err } @@ -41,11 +47,12 @@ func loadCSPConfig(yamlContent []byte) (*config.CSP, error) { return &cspConfig, nil } -func loadCSPYaml(proxyCfg *config.Config) ([]byte, error) { +func loadCSPYaml(proxyCfg *config.Config) ([]byte, []byte, error) { if proxyCfg.CSPConfigFileLocation == "" { - return []byte(config.DefaultCSPConfig), nil + return []byte(config.DefaultCSPConfig), nil, nil } - return os.ReadFile(proxyCfg.CSPConfigFileLocation) + customCSPYaml, err := os.ReadFile(proxyCfg.CSPConfigFileLocation) + return []byte(config.DefaultCSPConfig), customCSPYaml, err } // Security is a middleware to apply security relevant http headers like CSP. From d16524510a7788a1e2016313c991c31d4f3aed69 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Mon, 6 Oct 2025 16:04:35 +0200 Subject: [PATCH 2/7] adapt tests Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 1 + services/proxy/pkg/middleware/security_test.go | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index 1422d927b..ca117c38e 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -32,6 +32,7 @@ func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, er // or load preset first and then custom to override values // especially in hindsight that there will be autoloaded config files from webapps // in the future + // TIL: gofig does not merge, it overwrites values from later sources err := gofig.LoadSources("yaml", presetYamlContent, customYamlContent) if err != nil { return nil, err diff --git a/services/proxy/pkg/middleware/security_test.go b/services/proxy/pkg/middleware/security_test.go index 01accf2c2..18b73308f 100644 --- a/services/proxy/pkg/middleware/security_test.go +++ b/services/proxy/pkg/middleware/security_test.go @@ -8,7 +8,7 @@ import ( func TestLoadCSPConfig(t *testing.T) { // setup test env - yaml := ` + presetYaml := ` directives: frame-src: - '''self''' @@ -17,12 +17,24 @@ 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) } + // TODO: this needs to be reworked into some contains assertion 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.Equal(t, config.Directives["img-src"][0], "'self'") + assert.Equal(t, config.Directives["img-src"][1], "data:") } From 16f9667fe8dae42e0d3cd87f799cb0cb5534a766 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Thu, 9 Oct 2025 13:21:44 +0200 Subject: [PATCH 3/7] adapt tests & deepmerge Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 83 ++++++++++++++++++- .../proxy/pkg/middleware/security_test.go | 14 ++-- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index ca117c38e..0765b3117 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -3,12 +3,14 @@ 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. @@ -33,7 +35,24 @@ func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, er // especially in hindsight that there will be autoloaded config files from webapps // in the future // TIL: gofig does not merge, it overwrites values from later sources - err := gofig.LoadSources("yaml", presetYamlContent, customYamlContent) + + 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 } @@ -48,6 +67,68 @@ func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, er return &cspConfig, 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 + } + + 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.CSPConfigFileLocation == "" { return []byte(config.DefaultCSPConfig), nil, nil diff --git a/services/proxy/pkg/middleware/security_test.go b/services/proxy/pkg/middleware/security_test.go index 18b73308f..e83e4f237 100644 --- a/services/proxy/pkg/middleware/security_test.go +++ b/services/proxy/pkg/middleware/security_test.go @@ -4,6 +4,7 @@ import ( "testing" "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" ) func TestLoadCSPConfig(t *testing.T) { @@ -29,12 +30,11 @@ directives: if err != nil { t.Error(err) } - // TODO: this needs to be reworked into some contains assertion - 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.Equal(t, config.Directives["img-src"][0], "'self'") - assert.Equal(t, config.Directives["img-src"][1], "data:") + assert.Assert(t, cmp.Contains(config.Directives["img-src"], "'self'")) + assert.Assert(t, cmp.Contains(config.Directives["img-src"], "data:")) } From 63603679a59824a17b4cf8abba225af4d7346920 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Mon, 13 Oct 2025 14:50:55 +0200 Subject: [PATCH 4/7] remove obsolete comment Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index 0765b3117..c81c791ee 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -27,15 +27,7 @@ func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, er // substitute env vars and load to struct gofig.WithOptions(gofig.ParseEnv) gofig.AddDriver(yaml.Driver) - - // TODO: merge all sources into one struct - // ATM it is untested how this merger behaves with multiple sources - // it might be better to load preset and custom separately and then merge structs - // or load preset first and then custom to override values - // especially in hindsight that there will be autoloaded config files from webapps - // in the future - // TIL: gofig does not merge, it overwrites values from later sources - + presetMap := map[string]interface{}{} err := yamlv3.Unmarshal(presetYamlContent, &presetMap) if err != nil { From 8007e8a269ae0b03f5bfd052efeccce8851d8c70 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Fri, 14 Nov 2025 13:31:27 +0100 Subject: [PATCH 5/7] add ability to completely override csp config Signed-off-by: Christian Richter --- services/proxy/pkg/config/config.go | 43 ++++++++++--------- .../pkg/config/defaults/defaultconfig.go | 7 +-- services/proxy/pkg/middleware/security.go | 8 +++- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index 452f38497..659c675eb 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 adc27c054..e0b2bcf5c 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 c81c791ee..35d12e1cc 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -19,7 +19,11 @@ func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) { if err != nil { return nil, err } - return loadCSPConfig(presetYamlContent, customYamlContent) + if proxyCfg.CSPConfigFileOverrideLocation == "" { + return loadCSPConfig(presetYamlContent, customYamlContent) + } else { + return loadCSPConfig(presetYamlContent, []byte{}) + } } // LoadCSPConfig loads CSP header configuration from a yaml file. @@ -27,7 +31,7 @@ func loadCSPConfig(presetYamlContent, customYamlContent []byte) (*config.CSP, er // substitute env vars and load to struct gofig.WithOptions(gofig.ParseEnv) gofig.AddDriver(yaml.Driver) - + presetMap := map[string]interface{}{} err := yamlv3.Unmarshal(presetYamlContent, &presetMap) if err != nil { From f9807f9f3a221dbb35ee5eca0620cd4680ce9045 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Tue, 18 Nov 2025 16:04:00 +0100 Subject: [PATCH 6/7] actually load overrideyaml Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index 35d12e1cc..c9d689648 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -15,14 +15,14 @@ import ( // LoadCSPConfig loads CSP header configuration from a yaml file. func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) { - presetYamlContent, customYamlContent, err := loadCSPYaml(proxyCfg) + yamlContent, customYamlContent, err := loadCSPYaml(proxyCfg) if err != nil { return nil, err } if proxyCfg.CSPConfigFileOverrideLocation == "" { - return loadCSPConfig(presetYamlContent, customYamlContent) + return loadCSPConfig(yamlContent, customYamlContent) } else { - return loadCSPConfig(presetYamlContent, []byte{}) + return loadCSPConfig(yamlContent, []byte{}) } } @@ -126,6 +126,10 @@ func sliceContains(slice []interface{}, val interface{}) bool { } 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 } From 97ee9b36a5ec763c7c178b1349c32bdd16c2ac78 Mon Sep 17 00:00:00 2001 From: Christian Richter Date: Tue, 18 Nov 2025 16:41:27 +0100 Subject: [PATCH 7/7] incorporate requested changes Signed-off-by: Christian Richter --- services/proxy/pkg/middleware/security.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/proxy/pkg/middleware/security.go b/services/proxy/pkg/middleware/security.go index c9d689648..a536a2ec5 100644 --- a/services/proxy/pkg/middleware/security.go +++ b/services/proxy/pkg/middleware/security.go @@ -19,11 +19,7 @@ func LoadCSPConfig(proxyCfg *config.Config) (*config.CSP, error) { if err != nil { return nil, err } - if proxyCfg.CSPConfigFileOverrideLocation == "" { - return loadCSPConfig(yamlContent, customYamlContent) - } else { - return loadCSPConfig(yamlContent, []byte{}) - } + return loadCSPConfig(yamlContent, customYamlContent) } // LoadCSPConfig loads CSP header configuration from a yaml file.