From 4ed6545a58f23facad85f45182cd9adf835d5996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Fri, 5 Dec 2025 15:43:43 +0100 Subject: [PATCH 1/5] feat: implement brute force protection for public shares --- .../proxy/pkg/middleware/public_share_auth.go | 29 ------------------- .../storage-publiclink/pkg/config/config.go | 7 +++++ .../pkg/config/defaults/defaultconfig.go | 6 ++++ .../pkg/revaconfig/config.go | 4 ++- 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/services/proxy/pkg/middleware/public_share_auth.go b/services/proxy/pkg/middleware/public_share_auth.go index 2380b8a8c2f..484fa6784bb 100644 --- a/services/proxy/pkg/middleware/public_share_auth.go +++ b/services/proxy/pkg/middleware/public_share_auth.go @@ -91,35 +91,6 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, } } - client, err := a.RevaGatewaySelector.Next() - if err != nil { - a.Logger.Error(). - Err(err). - Str("authenticator", "public_share"). - Str("public_share_token", shareToken). - Str("path", r.URL.Path). - Msg("could not select next gateway client") - return nil, false - } - - authResp, err := client.Authenticate(r.Context(), &gateway.AuthenticateRequest{ - Type: authenticationType, - ClientId: shareToken, - ClientSecret: sharePassword, - }) - - if err != nil { - a.Logger.Error(). - Err(err). - Str("authenticator", "public_share"). - Str("public_share_token", shareToken). - Str("path", r.URL.Path). - Msg("failed to authenticate request") - return nil, false - } - - r.Header.Add(_headerRevaAccessToken, authResp.Token) - a.Logger.Debug(). Str("authenticator", "public_share"). Str("path", r.URL.Path). diff --git a/services/storage-publiclink/pkg/config/config.go b/services/storage-publiclink/pkg/config/config.go index c6cab3d609e..356abcc0229 100644 --- a/services/storage-publiclink/pkg/config/config.go +++ b/services/storage-publiclink/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "context" + "time" "github.com/owncloud/ocis/v2/ocis-pkg/shared" ) @@ -17,6 +18,7 @@ type Config struct { TokenManager *TokenManager `yaml:"token_manager"` Reva *shared.Reva `yaml:"reva"` + BruteForce BruteForce `yaml:"brute_force"` SkipUserGroupsInToken bool `yaml:"skip_user_groups_in_token" env:"STORAGE_PUBLICLINK_SKIP_USER_GROUPS_IN_TOKEN" desc:"Disables the loading of user's group memberships from the reva access token." introductionVersion:"pre5.0"` @@ -53,3 +55,8 @@ type GRPCConfig struct { type StorageProvider struct { MountID string `yaml:"mount_id" env:"STORAGE_PUBLICLINK_STORAGE_PROVIDER_MOUNT_ID" desc:"Mount ID of this storage. Admins can set the ID for the storage in this config option manually which is then used to reference the storage. Any reasonable long string is possible, preferably this would be an UUIDv4 format." introductionVersion:"pre5.0"` } + +type BruteForce struct { + TimeGap time.Duration `yaml:"time_gap" env:"STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP" desc:"The duration of the time gap computed for the brute force protection." introductionVersion:"%%NEXT%%"` + MaxAttempts int `yaml:"max_attempts" env:"STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS" desc:"The maximum number of failed attempts allowed in the time gap above." introductionVersion:"%%NEXT%%"` +} diff --git a/services/storage-publiclink/pkg/config/defaults/defaultconfig.go b/services/storage-publiclink/pkg/config/defaults/defaultconfig.go index c69e97616e0..36031d4f034 100644 --- a/services/storage-publiclink/pkg/config/defaults/defaultconfig.go +++ b/services/storage-publiclink/pkg/config/defaults/defaultconfig.go @@ -1,6 +1,8 @@ package defaults import ( + "time" + "github.com/owncloud/ocis/v2/ocis-pkg/shared" "github.com/owncloud/ocis/v2/ocis-pkg/structs" "github.com/owncloud/ocis/v2/services/storage-publiclink/pkg/config" @@ -35,6 +37,10 @@ func DefaultConfig() *config.Config { StorageProvider: config.StorageProvider{ MountID: "7993447f-687f-490d-875c-ac95e89a62a4", }, + BruteForce: config.BruteForce{ + TimeGap: 1 * time.Hour, + MaxAttempts: 5, + }, } } diff --git a/services/storage-publiclink/pkg/revaconfig/config.go b/services/storage-publiclink/pkg/revaconfig/config.go index 2b63e5d9e6e..553ea165a3b 100644 --- a/services/storage-publiclink/pkg/revaconfig/config.go +++ b/services/storage-publiclink/pkg/revaconfig/config.go @@ -37,7 +37,9 @@ func StoragePublicLinkConfigFromStruct(cfg *config.Config) map[string]interface{ "auth_manager": "publicshares", "auth_managers": map[string]interface{}{ "publicshares": map[string]interface{}{ - "gateway_addr": cfg.Reva.Address, + "gateway_addr": cfg.Reva.Address, + "brute_force_time_gap": cfg.BruteForce.TimeGap, + "brute_force_max_attempts": cfg.BruteForce.MaxAttempts, }, }, }, From 83e70efb4164435e53b03373dd7ccdf341f06a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 9 Dec 2025 14:19:34 +0100 Subject: [PATCH 2/5] chore: adjust config description and add changelog entry --- .../enhancement-brute-force-protection-links.md | 15 +++++++++++++++ services/storage-publiclink/pkg/config/config.go | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 changelog/unreleased/enhancement-brute-force-protection-links.md diff --git a/changelog/unreleased/enhancement-brute-force-protection-links.md b/changelog/unreleased/enhancement-brute-force-protection-links.md new file mode 100644 index 00000000000..af48db5ea8b --- /dev/null +++ b/changelog/unreleased/enhancement-brute-force-protection-links.md @@ -0,0 +1,15 @@ +Enhancement: Implement brute force protection for public links + +Public links will be protected by default, allowing up to 5 wrong password +attempts per hour. If such rate is exceeded, the link will be blocked for +all the users until the failure rate goes below the configured threshold +(5 failures per hour by default, as said). + +The failure rate is configurable, so it can be 10 failures each 2 hours +or 3 failures per minute. + +Note that the protection will apply per service replica, so one replica +might be blocked while another replica is fully functional. + +https://github.com/owncloud/ocis/pull/11864 +https://github.com/owncloud/reva/pull/460 diff --git a/services/storage-publiclink/pkg/config/config.go b/services/storage-publiclink/pkg/config/config.go index 356abcc0229..23d8382786f 100644 --- a/services/storage-publiclink/pkg/config/config.go +++ b/services/storage-publiclink/pkg/config/config.go @@ -57,6 +57,6 @@ type StorageProvider struct { } type BruteForce struct { - TimeGap time.Duration `yaml:"time_gap" env:"STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP" desc:"The duration of the time gap computed for the brute force protection." introductionVersion:"%%NEXT%%"` - MaxAttempts int `yaml:"max_attempts" env:"STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS" desc:"The maximum number of failed attempts allowed in the time gap above." introductionVersion:"%%NEXT%%"` + TimeGap time.Duration `yaml:"time_gap" env:"STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP" desc:"The duration of the time gap computed for the brute force protection." introductionVersion:"Curie"` + MaxAttempts int `yaml:"max_attempts" env:"STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS" desc:"The maximum number of failed attempts allowed in the time gap defined in STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP." introductionVersion:"Curie"` } From 8370cfa824b73e5b57ce77ffba62e937e459b9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 9 Dec 2025 15:01:37 +0100 Subject: [PATCH 3/5] chore: add readme explaining the feature --- services/storage-publiclink/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 services/storage-publiclink/README.md diff --git a/services/storage-publiclink/README.md b/services/storage-publiclink/README.md new file mode 100644 index 00000000000..d16922719e4 --- /dev/null +++ b/services/storage-publiclink/README.md @@ -0,0 +1,13 @@ +## Brute Force Protection + +The brute force protection will prevent access to public links if wrong passwords are entered. The implementation is very similar to a rate limiter, but taking into account only wrong password attempts. + +By default, you're allowed a maximum of 5 failed attempts (`STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS=5`) in 1 hour (`STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP=1h`). You can adjust those values to your liking in order to define the failure rate threshold (5 failures per hour, by default). + +If the failure rate threshold is exceeded, the public link will be blocked until such rate goes below the threshold. This means that it will remain blocked for an undefined time: a couple of seconds in the best case, or up to `STORAGE_PUBLICLINK_BRUTEFORCE_TIME` in the worst case. + +If the public link is blocked by the brute force protection, it will be blocked for all the users. + +In case of multiple service replicas, the brute force protection won't share any data among the replicas and the failure rate will apply per replica. This means that a replica might be blocked due to high failure rate while the rest work fine. + +As said, this feature is enabled by default, with a 5 failures per hour rate. If you want to disable this feature, set the related configuration values to 0. From 27d7cfa05559a9bdafb29639dd3c8fc537fedd76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 9 Dec 2025 17:57:54 +0100 Subject: [PATCH 4/5] chore: adjust formatting for codacy --- services/storage-publiclink/README.md | 28 +++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/services/storage-publiclink/README.md b/services/storage-publiclink/README.md index d16922719e4..1396a0cfb74 100644 --- a/services/storage-publiclink/README.md +++ b/services/storage-publiclink/README.md @@ -1,13 +1,29 @@ +# storage-publiclink + ## Brute Force Protection -The brute force protection will prevent access to public links if wrong passwords are entered. The implementation is very similar to a rate limiter, but taking into account only wrong password attempts. +The brute force protection will prevent access to public links if wrong +passwords are entered. The implementation is very similar to a rate limiter, +but taking into account only wrong password attempts. -By default, you're allowed a maximum of 5 failed attempts (`STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS=5`) in 1 hour (`STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP=1h`). You can adjust those values to your liking in order to define the failure rate threshold (5 failures per hour, by default). +By default, you're allowed a maximum of 5 failed attempts +(`STORAGE_PUBLICLINK_BRUTEFORCE_MAXATTEMPTS=5`) in 1 hour +(`STORAGE_PUBLICLINK_BRUTEFORCE_TIMEGAP=1h`). You can adjust those values to +your liking in order to define the failure rate threshold (5 failures per +hour, by default). -If the failure rate threshold is exceeded, the public link will be blocked until such rate goes below the threshold. This means that it will remain blocked for an undefined time: a couple of seconds in the best case, or up to `STORAGE_PUBLICLINK_BRUTEFORCE_TIME` in the worst case. +If the failure rate threshold is exceeded, the public link will be blocked +until such rate goes below the threshold. This means that it will remain +blocked for an undefined time: a couple of seconds in the best case, or up to +`STORAGE_PUBLICLINK_BRUTEFORCE_TIME` in the worst case. -If the public link is blocked by the brute force protection, it will be blocked for all the users. +If the public link is blocked by the brute force protection, it will be +blocked for all the users. -In case of multiple service replicas, the brute force protection won't share any data among the replicas and the failure rate will apply per replica. This means that a replica might be blocked due to high failure rate while the rest work fine. +In case of multiple service replicas, the brute force protection won't share +any data among the replicas and the failure rate will apply per replica. This +means that a replica might be blocked due to high failure rate while the rest +work fine. -As said, this feature is enabled by default, with a 5 failures per hour rate. If you want to disable this feature, set the related configuration values to 0. +As said, this feature is enabled by default, with a 5 failures per hour rate. +If you want to disable this feature, set the related configuration values to 0. From 5fee98ce706d63a2d022366bb031b8d3d85fc261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 16 Dec 2025 14:18:58 +0100 Subject: [PATCH 5/5] feat: propagate GRPC metadata across go-micro services The server handlers for go-micro reuse the data from reva so the propagation also works with reva services. Both reva and go-micro services will propagate the data correctly. --- .../service/grpc/handler/metadata/metadata.go | 66 +++++++++++++++++++ ocis-pkg/service/grpc/service.go | 9 ++- .../proxy/pkg/middleware/public_share_auth.go | 33 ++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 ocis-pkg/service/grpc/handler/metadata/metadata.go diff --git a/ocis-pkg/service/grpc/handler/metadata/metadata.go b/ocis-pkg/service/grpc/handler/metadata/metadata.go new file mode 100644 index 00000000000..4b41de6c137 --- /dev/null +++ b/ocis-pkg/service/grpc/handler/metadata/metadata.go @@ -0,0 +1,66 @@ +package metadata + +import ( + "context" + "strings" + + "github.com/owncloud/reva/v2/pkg/rgrpc" + "go-micro.dev/v4/server" + "google.golang.org/grpc/metadata" +) + +const ( + // Prefix used to auto propagate GRPC metadata keys across servers. + // This is used in the NewHandlerWrapper and NewSubscriberWrapper, and + // it's expected to work in go-micro services. + // It needs to match the prefix used in reva for the same purpose. + AutoPropPrefix = rgrpc.AutoPropPrefix +) + +// NewHandlerWrapper propagates the grpc metadata. +func NewHandlerWrapper() server.HandlerWrapper { + return func(h server.HandlerFunc) server.HandlerFunc { + return func(ctx context.Context, req server.Request, rsp interface{}) error { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + md = metadata.MD{} + } + + pairs := make([]string, 0, md.Len()*2) + for key, values := range md { + if strings.HasPrefix(key, AutoPropPrefix) { + for _, value := range values { + pairs = append(pairs, key, value) + } + } + } + + newctx := metadata.AppendToOutgoingContext(ctx, pairs...) + return h(newctx, req, rsp) + } + } +} + +// NewSubscriberWrapper propagates the grpc metadata +func NewSubscriberWrapper() server.SubscriberWrapper { + return func(next server.SubscriberFunc) server.SubscriberFunc { + return func(ctx context.Context, msg server.Message) error { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + md = metadata.MD{} + } + + pairs := make([]string, 0, md.Len()*2) + for key, values := range md { + if strings.HasPrefix(key, AutoPropPrefix) { + for _, value := range values { + pairs = append(pairs, key, value) + } + } + } + + newctx := metadata.AppendToOutgoingContext(ctx, pairs...) + return next(newctx, msg) + } + } +} diff --git a/ocis-pkg/service/grpc/service.go b/ocis-pkg/service/grpc/service.go index f9d53612b08..077a7aa742c 100644 --- a/ocis-pkg/service/grpc/service.go +++ b/ocis-pkg/service/grpc/service.go @@ -21,6 +21,7 @@ import ( ociscrypto "github.com/owncloud/ocis/v2/ocis-pkg/crypto" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/registry" + ocismetadata "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc/handler/metadata" ) // Service simply wraps the go-micro grpc service. @@ -64,6 +65,7 @@ func NewServiceWithClient(client client.Client, opts ...Option) (Service, error) mtracer.NewHandlerWrapper( mtracer.WithTraceProvider(sopts.TraceProvider), ), + ocismetadata.NewHandlerWrapper(), } if sopts.Logger.GetLevel() == zerolog.DebugLevel { handlerWrappers = append(handlerWrappers, LogHandler(&sopts.Logger)) @@ -87,9 +89,10 @@ func NewServiceWithClient(client client.Client, opts ...Option) (Service, error) mtracer.WithTraceProvider(sopts.TraceProvider), )), micro.WrapHandler(handlerWrappers...), - micro.WrapSubscriber(mtracer.NewSubscriberWrapper( - mtracer.WithTraceProvider(sopts.TraceProvider), - )), + micro.WrapSubscriber( + mtracer.NewSubscriberWrapper(mtracer.WithTraceProvider(sopts.TraceProvider)), + ocismetadata.NewSubscriberWrapper(), + ), } return Service{micro.NewService(mopts...)}, nil diff --git a/services/proxy/pkg/middleware/public_share_auth.go b/services/proxy/pkg/middleware/public_share_auth.go index 484fa6784bb..bc69e36ef19 100644 --- a/services/proxy/pkg/middleware/public_share_auth.go +++ b/services/proxy/pkg/middleware/public_share_auth.go @@ -6,6 +6,7 @@ import ( gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/reva/v2/pkg/auth/manager/publicshares" "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" ) @@ -91,6 +92,38 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, } } + client, err := a.RevaGatewaySelector.Next() + if err != nil { + a.Logger.Error(). + Err(err). + Str("authenticator", "public_share"). + Str("public_share_token", shareToken). + Str("path", r.URL.Path). + Msg("could not select next gateway client") + return nil, false + } + + // we just need the reva access token, so we want to skip the brute force + // protection in this case + ctx := publicshares.MarkSkipAttemptContext(r.Context(), shareToken) + authResp, err := client.Authenticate(ctx, &gateway.AuthenticateRequest{ + Type: authenticationType, + ClientId: shareToken, + ClientSecret: sharePassword, + }) + + if err != nil { + a.Logger.Error(). + Err(err). + Str("authenticator", "public_share"). + Str("public_share_token", shareToken). + Str("path", r.URL.Path). + Msg("failed to authenticate request") + return nil, false + } + + r.Header.Add(_headerRevaAccessToken, authResp.Token) + a.Logger.Debug(). Str("authenticator", "public_share"). Str("path", r.URL.Path).