Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog/unreleased/enhancement-brute-force-protection-links.md
Original file line number Diff line number Diff line change
@@ -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).
Comment on lines +3 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have questions on the behavior:

  1. One mad user can block the whole PL for the time defined.
  2. If there is another user typing in the wrong pwd, the counter starts from the beginning.
  3. No user can see when the blocking time ends.
  4. No user can login even with a correct password if the treshold has been exeeded before.
  5. Consequently, if a user would be able to login with the correct pwd (which is a violation of the description above), login would be reenabled for all.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Technically yes. However, that could also be considered as an attack, which is what we want to prevent.
    Public links are intended for external anonymous access, and the security team decided that trying to block access by IP could be bypassed, so the access will be blocked for everyone, including regular logged-in users.
  2. There is no counter. We're tracking the timestamp of each failed attempt (no longer useful data will be removed eventually) and ensuring that, given a duration, there are less than the maximum allowed failed attempts in it.
  3. Correct. I'm not entirely sure how useful showing that information will be related to the effort to implement it. If we need to show an accurate information, we'll need to propagate the information from very deep; alternatively, we could show the worst case ("retry after one hour", by default). It could also give information to potential attackers. Definitely something to check with the security team.
  4. Right. However, if a user could login with a correct password during the block, an attacker could keep trying passwords ignoring the block until he succeeds, which would defeat the purpose of the feature.
  5. Point 4 explains it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Expected bahaviour. This is the point of the whole story
  2. The counter should be global and not reset when another user enters a wrong password
  3. Expected behaviour. We do not want attackers to automate waiting time between brute force attacks.
  4. Expected behaviour. As stated above it would render the whole feature useless if we allow access to blocked resources.
  5. NO user should be able to access the link while it is blocked due to an attack.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the insight 😄
Addon question:

  1. If one has entered a valid pwd for the PL and accesses the resource and another user triggers the limit and blocks access, will the first user be kicked out?

Copy link
Collaborator

@kobergj kobergj Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends. I would expect ongoing Office session to not break. But I would expect the user cannot upload/download the file from this point. @jvillafanez is this the actual behaviour?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I would expect the user cannot upload/download the file from this point.

Really? First you successfully login, then you cant do anything without being notified?

As logged in user, I would expect at least a notification on the screen. Then it would be ok.
Would this also affect any running up/download? Means, they will get interrupted and the session closed?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First you successfully login

No login involved - this is about public links.

As logged in user, I would expect at least a notification on the screen.

Ideally yes - but from a security POV not needed.

Would this also affect any running up/download? Means, they will get interrupted and the session closed?

I would expect not. Once the download has started it uses a different sort of access token that is not bound to the user. So download should succeed. Not sure about Upload. Needs to be tested.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to double-check, but I think the office session will work normally. Pretty sure that almost all the operations will target the office server, and when the office server needs to contact oCIS it will use the provided reva access token, which should still be valid (unless it's expired). There shouldn't be any public share authentication needed while in the office app.

I haven't tested all cases, but taking into account that there are no related changes in web, the behavior seems to be fine: you can edit the file (assuming enough permissions), upload the file correctly to the server with the changes, BUT you get a "resource not found" error when you exit the editor.


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
66 changes: 66 additions & 0 deletions ocis-pkg/service/grpc/handler/metadata/metadata.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
9 changes: 6 additions & 3 deletions ocis-pkg/service/grpc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion services/proxy/pkg/middleware/public_share_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -102,7 +103,10 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request,
return nil, false
}

authResp, err := client.Authenticate(r.Context(), &gateway.AuthenticateRequest{
// 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,
Expand Down
29 changes: 29 additions & 0 deletions services/storage-publiclink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.

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.
7 changes: 7 additions & 0 deletions services/storage-publiclink/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"context"
"time"

"github.com/owncloud/ocis/v2/ocis-pkg/shared"
)
Expand All @@ -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"`

Expand Down Expand Up @@ -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:"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"`
}
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
},
}
}

Expand Down
4 changes: 3 additions & 1 deletion services/storage-publiclink/pkg/revaconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
Expand Down