github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/middlewares/permissions.go (about)

     1  // Package middlewares is used for the HTTP middlewares, ie functions that
     2  // takes an echo context to do stuff like checking permissions or caching
     3  // requests.
     4  package middlewares
     5  
     6  import (
     7  	"crypto/subtle"
     8  	"encoding/hex"
     9  	"errors"
    10  	"fmt"
    11  	"net/http"
    12  	"regexp"
    13  	"strings"
    14  
    15  	"github.com/cozy/cozy-stack/model/app"
    16  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    17  	"github.com/cozy/cozy-stack/model/instance"
    18  	"github.com/cozy/cozy-stack/model/oauth"
    19  	"github.com/cozy/cozy-stack/model/permission"
    20  	"github.com/cozy/cozy-stack/model/sharing"
    21  	"github.com/cozy/cozy-stack/model/vfs"
    22  	"github.com/cozy/cozy-stack/pkg/config/config"
    23  	"github.com/cozy/cozy-stack/pkg/consts"
    24  	"github.com/cozy/cozy-stack/pkg/couchdb"
    25  	"github.com/cozy/cozy-stack/pkg/crypto"
    26  	"github.com/cozy/cozy-stack/pkg/logger"
    27  	jwt "github.com/golang-jwt/jwt/v5"
    28  	"github.com/labstack/echo/v4"
    29  )
    30  
    31  const bearerAuthScheme = "Bearer "
    32  const basicAuthScheme = "Basic "
    33  const contextPermissionDoc = "permissions_doc"
    34  
    35  // ErrForbidden is used to send a forbidden response when the request does not
    36  // have the right permissions.
    37  var ErrForbidden = echo.NewHTTPError(http.StatusForbidden)
    38  
    39  // ErrMissingSource is used to send a bad request when the SourceURL is missing
    40  // from the request
    41  var ErrMissingSource = echo.NewHTTPError(http.StatusBadRequest, "No Source in request")
    42  
    43  var errNoToken = echo.NewHTTPError(http.StatusUnauthorized, "No token in request")
    44  
    45  // CheckRegisterToken returns true if the registerToken is set and match the
    46  // one from the instance.
    47  func CheckRegisterToken(c echo.Context, i *instance.Instance) bool {
    48  	if len(i.RegisterToken) == 0 {
    49  		return false
    50  	}
    51  	hexToken := c.QueryParam("registerToken")
    52  	if hexToken == "" {
    53  		return false
    54  	}
    55  	tok, err := hex.DecodeString(hexToken)
    56  	if err != nil {
    57  		return false
    58  	}
    59  	return subtle.ConstantTimeCompare(tok, i.RegisterToken) == 1
    60  }
    61  
    62  // GetRequestToken retrieves the token from the incoming request.
    63  func GetRequestToken(c echo.Context) string {
    64  	req := c.Request()
    65  	if header := req.Header.Get(echo.HeaderAuthorization); header != "" {
    66  		if strings.HasPrefix(header, bearerAuthScheme) {
    67  			return header[len(bearerAuthScheme):]
    68  		}
    69  		if strings.HasPrefix(header, basicAuthScheme) {
    70  			_, pass, _ := req.BasicAuth()
    71  			return pass
    72  		}
    73  	}
    74  	return c.QueryParam("bearer_token")
    75  }
    76  
    77  type linkedAppScope struct {
    78  	Doctype string
    79  	Slug    string
    80  }
    81  
    82  func parseLinkedAppScope(scope string) (*linkedAppScope, error) {
    83  	if !strings.HasPrefix(scope, "@") {
    84  		return nil, fmt.Errorf("Scope %s is not a linked-app", scope)
    85  	}
    86  	splitted := strings.Split(strings.TrimPrefix(scope, "@"), "/")
    87  
    88  	return &linkedAppScope{
    89  		Doctype: splitted[0],
    90  		Slug:    splitted[1],
    91  	}, nil
    92  }
    93  
    94  // GetForOauth create a non-persisted permissions doc from a oauth token scopes
    95  func GetForOauth(instance *instance.Instance, claims *permission.Claims, client *oauth.Client) (*permission.Permission, error) {
    96  	var set permission.Set
    97  	linkedAppScope, err := parseLinkedAppScope(claims.Scope)
    98  
    99  	if claims.Scope == "*" {
   100  		context := instance.ContextName
   101  		if context == "" {
   102  			context = config.DefaultInstanceContext
   103  		}
   104  		cfg := config.GetConfig().Flagship.Contexts[context]
   105  		skipCertification := false
   106  		if cfg, ok := cfg.(map[string]interface{}); ok {
   107  			skipCertification = cfg["skip_certification"] == true
   108  		}
   109  		if !skipCertification && !client.Flagship {
   110  			return nil, permission.ErrInvalidToken
   111  		}
   112  		set = permission.MaximalSet()
   113  	} else if err == nil && linkedAppScope != nil {
   114  		// Translate to a real scope
   115  		at := consts.NewAppType(linkedAppScope.Doctype)
   116  		manifest, err := app.GetBySlug(instance, linkedAppScope.Slug, at)
   117  		if err != nil {
   118  			return nil, err
   119  		}
   120  		set = manifest.Permissions()
   121  	} else {
   122  		set, err = permission.UnmarshalScopeString(claims.Scope)
   123  		if err != nil {
   124  			return nil, err
   125  		}
   126  	}
   127  
   128  	pdoc := &permission.Permission{
   129  		Type:        permission.TypeOauth,
   130  		Permissions: set,
   131  		SourceID:    claims.Subject,
   132  		Client:      client,
   133  	}
   134  	return pdoc, nil
   135  }
   136  
   137  var shortCodeRegexp = regexp.MustCompile(`^(\d{6}|(\w|\d){12})\.?$`)
   138  
   139  // ExtractClaims parse a JWT, and extracts its claims (if valid).
   140  func ExtractClaims(c echo.Context, instance *instance.Instance, token string) (*permission.Claims, error) {
   141  	var fullClaims permission.BitwardenClaims
   142  	var audience string
   143  
   144  	err := crypto.ParseJWT(token, func(token *jwt.Token) (interface{}, error) {
   145  		audiences := token.Claims.(*permission.BitwardenClaims).Claims.Audience
   146  		if len(audiences) != 1 {
   147  			return nil, permission.ErrInvalidAudience
   148  		}
   149  		audience = audiences[0]
   150  		return instance.PickKey(audience)
   151  	}, &fullClaims)
   152  
   153  	// XXX: bitwarden clients have the OAuth client ID in client_id, not subject
   154  	claims := fullClaims.Claims
   155  	if audience == consts.AccessTokenAudience && fullClaims.ClientID != "" && claims.Subject == instance.ID() {
   156  		claims.Subject = fullClaims.ClientID
   157  	}
   158  
   159  	c.Set("claims", claims)
   160  
   161  	if err != nil {
   162  		logger.WithNamespace("permissions").Debugf("invalid token: %s", err)
   163  		return nil, permission.ErrInvalidToken
   164  	}
   165  
   166  	// check if the claim is valid
   167  	if claims.Issuer != instance.Domain {
   168  		logger.WithNamespace("permissions").
   169  			Debugf("invalid token: bad domain %s != %s", claims.Issuer, instance.Domain)
   170  		return nil, permission.ErrInvalidToken
   171  	}
   172  
   173  	if claims.Expired() {
   174  		logger.WithNamespace("permissions").Debugf("invalid token: expired")
   175  		return nil, permission.ErrExpiredToken
   176  	}
   177  
   178  	// If claims contains a SessionID, we check that we are actually authorized
   179  	// with the corresponding session.
   180  	if claims.SessionID != "" {
   181  		s, ok := GetSession(c)
   182  		if !ok || s.ID() != claims.SessionID {
   183  			if ok {
   184  				logger.WithNamespace("permissions").
   185  					Debugf("invalid token: bad session %s != %s", s.ID(), claims.SessionID)
   186  			} else {
   187  				logger.WithNamespace("permissions").
   188  					Debugf("invalid token: no session")
   189  			}
   190  			return nil, permission.ErrInvalidToken
   191  		}
   192  	}
   193  
   194  	// If claims contains a security stamp, we check that the stamp is still
   195  	// the same.
   196  	if claims.SStamp != "" {
   197  		settings, err := settings.Get(instance)
   198  		if err != nil || claims.SStamp != settings.SecurityStamp {
   199  			if err != nil {
   200  				logger.WithNamespace("permissions").
   201  					Debugf("could not get instance settings: %s", err)
   202  			} else {
   203  				logger.WithNamespace("permissions").
   204  					Debugf("invalid token: bad security stamp %s != %s", claims.SStamp, settings.SecurityStamp)
   205  			}
   206  			return nil, permission.ErrInvalidToken
   207  		}
   208  	}
   209  
   210  	return &claims, nil
   211  }
   212  
   213  // HasCookieForPassword returns true if a cookie has been set for the
   214  // permission with a given ID if its password has been given by the user, and a
   215  // cookie has been put for that.
   216  func HasCookieForPassword(c echo.Context, inst *instance.Instance, permID string) bool {
   217  	cookieName := "pass" + permID
   218  	cookie, err := c.Cookie(cookieName)
   219  	if err != nil || cookie.Value == "" {
   220  		return false
   221  	}
   222  
   223  	cfg := crypto.MACConfig{Name: cookieName, MaxLen: 256}
   224  	id, err := crypto.DecodeAuthMessage(cfg, inst.SessionSecret(), []byte(cookie.Value), nil)
   225  	if err != nil {
   226  		return false
   227  	}
   228  
   229  	return string(id) == permID
   230  }
   231  
   232  // TransformShortcodeToJWT takes a token. If it is a short code, it transforms
   233  // it to a JWT by using the associated permission. Else, it just returns the
   234  // token.
   235  func TransformShortcodeToJWT(inst *instance.Instance, token string) (string, error) {
   236  	if !shortCodeRegexp.MatchString(token) {
   237  		return token, nil
   238  	}
   239  
   240  	// XXX in theory, the shortcode is exactly 12 characters. But
   241  	// somethimes, when people shares a public link with this token, they
   242  	// can put a "." just after the link to finish their sentence, and this
   243  	// "." can be added to the token. So, it's better to accept a shortcode
   244  	// with a final ".", and clean it.
   245  	token = strings.TrimSuffix(token, ".")
   246  	return permission.GetTokenFromShortcode(inst, token)
   247  }
   248  
   249  // ParseJWT parses a JSON Web Token, and returns the associated permissions.
   250  func ParseJWT(c echo.Context, instance *instance.Instance, token string) (*permission.Permission, error) {
   251  	token, err := TransformShortcodeToJWT(instance, token)
   252  	if err != nil {
   253  		return nil, err
   254  	}
   255  
   256  	claims, err := ExtractClaims(c, instance, token)
   257  	if err != nil {
   258  		if errors.Is(err, permission.ErrExpiredToken) {
   259  			c.Response().Header().Set(echo.HeaderWWWAuthenticate,
   260  				`Bearer error="invalid_token" error_description="The access token expired"`)
   261  		} else {
   262  			c.Response().Header().Set(echo.HeaderWWWAuthenticate, `Bearer error="invalid_token"`)
   263  		}
   264  		return nil, err
   265  	}
   266  
   267  	switch claims.AudienceString() {
   268  	case consts.AccessTokenAudience:
   269  		if err := instance.MovedError(); err != nil {
   270  			return nil, err
   271  		}
   272  		// An OAuth2 token is only valid if the client has not been revoked
   273  		client, err := oauth.FindClient(instance, claims.Subject)
   274  		if err != nil {
   275  			if couchdb.IsInternalServerError(err) {
   276  				return nil, err
   277  			}
   278  			logger.WithNamespace("permissions").
   279  				Debugf("invalid token: no client for OAuth - %s", err)
   280  			c.Response().Header().Set(echo.HeaderWWWAuthenticate, `Bearer error="invalid_token"`)
   281  			return nil, permission.ErrInvalidToken
   282  		}
   283  		return GetForOauth(instance, claims, client)
   284  
   285  	case consts.CLIAudience:
   286  		// do not check client existence
   287  		return permission.GetForCLI(claims)
   288  
   289  	case consts.AppAudience:
   290  		pdoc, err := permission.GetForWebapp(instance, claims.Subject)
   291  		if err != nil {
   292  			logger.WithNamespace("permissions").
   293  				Debugf("invalid token: no permission for webapp - %s", err)
   294  			return nil, err
   295  		}
   296  		return pdoc, nil
   297  
   298  	case consts.KonnectorAudience:
   299  		pdoc, err := permission.GetForKonnector(instance, claims.Subject)
   300  		if err != nil {
   301  			logger.WithNamespace("permissions").
   302  				Debugf("invalid token: no permission for konnector - %s", err)
   303  			return nil, err
   304  		}
   305  		return pdoc, nil
   306  
   307  	case consts.ShareAudience:
   308  		pdoc, err := permission.GetForShareCode(instance, token)
   309  		if err != nil {
   310  			return nil, err
   311  		}
   312  
   313  		// Check that the password has been given for password protected share by link
   314  		if pdoc.Password != nil && !HasCookieForPassword(c, instance, pdoc.ID()) {
   315  			return nil, permission.ErrInvalidToken
   316  		}
   317  
   318  		// A share token is only valid if the user has not been revoked
   319  		if pdoc.Type == permission.TypeSharePreview || pdoc.Type == permission.TypeShareInteract {
   320  			sharingID := strings.Split(pdoc.SourceID, "/")
   321  			sharingDoc, err := sharing.FindSharing(instance, sharingID[1])
   322  			if err != nil {
   323  				return nil, err
   324  			}
   325  
   326  			var member *sharing.Member
   327  			if pdoc.Type == permission.TypeSharePreview {
   328  				member, err = sharingDoc.FindMemberBySharecode(instance, token)
   329  			} else {
   330  				member, err = sharingDoc.FindMemberByInteractCode(instance, token)
   331  			}
   332  			if err != nil {
   333  				return nil, err
   334  			}
   335  
   336  			if member.Status == sharing.MemberStatusRevoked {
   337  				return nil, permission.ErrInvalidToken
   338  			}
   339  
   340  			if member.Status == sharing.MemberStatusMailNotSent ||
   341  				member.Status == sharing.MemberStatusPendingInvitation {
   342  				member.Status = sharing.MemberStatusSeen
   343  				_ = couchdb.UpdateDoc(instance, sharingDoc)
   344  			}
   345  		}
   346  
   347  		return pdoc, nil
   348  
   349  	default:
   350  		return nil, echo.NewHTTPError(http.StatusBadRequest,
   351  			fmt.Sprintf("Unrecognized token audience %v", claims.Audience))
   352  	}
   353  }
   354  
   355  // GetCLIPermission tries to extract a CLI permission from the echo context
   356  // without tampering with the response headers in case the token is invalid.
   357  func GetCLIPermission(c echo.Context) (*permission.Permission, bool) {
   358  	var err error
   359  
   360  	pdoc, ok := c.Get(contextPermissionDoc).(*permission.Permission)
   361  	if ok && pdoc != nil && pdoc.Type == permission.TypeCLI {
   362  		return pdoc, true
   363  	}
   364  
   365  	instance := GetInstance(c)
   366  
   367  	token := GetRequestToken(c)
   368  	if token == "" {
   369  		return nil, false
   370  	}
   371  
   372  	claims, err := ExtractClaims(c, instance, token)
   373  	if err != nil {
   374  		return nil, false
   375  	}
   376  
   377  	if claims.AudienceString() == consts.CLIAudience {
   378  		if pdoc, err := permission.GetForCLI(claims); err != nil {
   379  			c.Set(contextPermissionDoc, pdoc)
   380  			return pdoc, true
   381  		}
   382  	}
   383  
   384  	return nil, false
   385  }
   386  
   387  // GetPermission extracts the permission from the echo context and checks their validity
   388  func GetPermission(c echo.Context) (*permission.Permission, error) {
   389  	var err error
   390  
   391  	pdoc, ok := c.Get(contextPermissionDoc).(*permission.Permission)
   392  	if ok && pdoc != nil {
   393  		return pdoc, nil
   394  	}
   395  
   396  	inst := GetInstance(c)
   397  	if CheckRegisterToken(c, inst) {
   398  		return permission.GetForRegisterToken(), nil
   399  	}
   400  
   401  	tok := GetRequestToken(c)
   402  	if tok == "" {
   403  		return nil, errNoToken
   404  	}
   405  
   406  	pdoc, err = ParseJWT(c, inst, tok)
   407  	if err != nil {
   408  		return nil, err
   409  	}
   410  
   411  	c.Set(contextPermissionDoc, pdoc)
   412  	return pdoc, nil
   413  }
   414  
   415  // AllowWholeType validates that the context permission set can use a verb on
   416  // the whold doctype
   417  func AllowWholeType(c echo.Context, v permission.Verb, doctype string) error {
   418  	pdoc, err := GetPermission(c)
   419  	if err != nil {
   420  		return err
   421  	}
   422  	if !pdoc.Permissions.AllowWholeType(v, doctype) {
   423  		return ErrForbidden
   424  	}
   425  	return nil
   426  }
   427  
   428  // Allow validates the validable object against the context permission set
   429  func Allow(c echo.Context, v permission.Verb, o permission.Fetcher) error {
   430  	pdoc, err := GetPermission(c)
   431  	if err != nil {
   432  		return err
   433  	}
   434  	if !pdoc.Permissions.Allow(v, o) {
   435  		return ErrForbidden
   436  	}
   437  	return nil
   438  }
   439  
   440  // AllowOnFields validates the validable object againt the context permission
   441  // set and ensure the selector validates the given fields.
   442  func AllowOnFields(c echo.Context, v permission.Verb, o permission.Fetcher, fields ...string) error {
   443  	pdoc, err := GetPermission(c)
   444  	if err != nil {
   445  		return err
   446  	}
   447  	if !pdoc.Permissions.AllowOnFields(v, o, fields...) {
   448  		return ErrForbidden
   449  	}
   450  	return nil
   451  }
   452  
   453  // AllowTypeAndID validates a type & ID against the context permission set
   454  func AllowTypeAndID(c echo.Context, v permission.Verb, doctype, id string) error {
   455  	pdoc, err := GetPermission(c)
   456  	if err != nil {
   457  		return err
   458  	}
   459  	if !pdoc.Permissions.AllowID(v, doctype, id) {
   460  		return ErrForbidden
   461  	}
   462  	return nil
   463  }
   464  
   465  // AllowVFS validates a vfs.Fetcher against the context permission set
   466  func AllowVFS(c echo.Context, v permission.Verb, o vfs.Fetcher) error {
   467  	instance := GetInstance(c)
   468  	pdoc, err := GetPermission(c)
   469  	if err != nil {
   470  		return err
   471  	}
   472  	if pdoc.Permissions.IsMaximal() {
   473  		return nil
   474  	}
   475  	err = vfs.Allows(instance.VFS(), pdoc.Permissions, v, o)
   476  	if err != nil {
   477  		return ErrForbidden
   478  	}
   479  	return nil
   480  }
   481  
   482  // CanWriteToAnyDirectory checks that the context permission allows to write to
   483  // a directory on the VFS.
   484  func CanWriteToAnyDirectory(c echo.Context) error {
   485  	pdoc, err := GetPermission(c)
   486  	if err != nil {
   487  		return err
   488  	}
   489  	for _, rule := range pdoc.Permissions {
   490  		if permission.MatchType(rule, consts.Files) && rule.Verbs.Contains(permission.POST) {
   491  			return nil
   492  		}
   493  	}
   494  	return ErrForbidden
   495  }
   496  
   497  // AllowInstallApp checks that the current context is tied to the store app,
   498  // which is the only app authorized to install or update other apps.
   499  // It also allow the cozy-stack apps commands to work (CLI).
   500  func AllowInstallApp(c echo.Context, appType consts.AppType, sourceURL string, v permission.Verb) error {
   501  	pdoc, err := GetPermission(c)
   502  	if err != nil {
   503  		return err
   504  	}
   505  
   506  	if pdoc.Permissions.IsMaximal() {
   507  		return nil
   508  	}
   509  
   510  	var docType string
   511  	switch appType {
   512  	case consts.KonnectorType:
   513  		docType = consts.Konnectors
   514  	case consts.WebappType:
   515  		docType = consts.Apps
   516  	}
   517  
   518  	if docType == "" {
   519  		return fmt.Errorf("unknown application type %s", appType.String())
   520  	}
   521  	switch pdoc.Type {
   522  	case permission.TypeCLI:
   523  		// OK
   524  	case permission.TypeWebapp, permission.TypeKonnector:
   525  		if pdoc.SourceID != consts.Apps+"/"+consts.StoreSlug {
   526  			inst := GetInstance(c)
   527  			ctxSettings, ok := inst.SettingsContext()
   528  			if !ok || ctxSettings["allow_install_via_a_permission"] != true {
   529  				return ErrForbidden
   530  			}
   531  		}
   532  		// The store can only install apps and konnectors from the registry
   533  		if !strings.HasPrefix(sourceURL, "registry://") {
   534  			return ErrForbidden
   535  		}
   536  	case permission.TypeOauth:
   537  		// If the context allows to install an app via a permission, this
   538  		// permission can also be used by mobile apps to install apps from the
   539  		// registry.
   540  		inst := GetInstance(c)
   541  		ctxSettings, ok := inst.SettingsContext()
   542  		if !ok || ctxSettings["allow_install_via_a_permission"] != true {
   543  			return ErrForbidden
   544  		}
   545  		if !strings.HasPrefix(sourceURL, "registry://") {
   546  			return ErrForbidden
   547  		}
   548  	default:
   549  		return ErrForbidden
   550  	}
   551  	if !pdoc.Permissions.AllowWholeType(v, docType) {
   552  		return ErrForbidden
   553  	}
   554  	return nil
   555  }
   556  
   557  // AllowForKonnector checks that the permissions is valid and comes from the
   558  // konnector with the given slug.
   559  func AllowForKonnector(c echo.Context, slug string) error {
   560  	if slug == "" {
   561  		return ErrForbidden
   562  	}
   563  	pdoc, err := GetPermission(c)
   564  	if err != nil {
   565  		return err
   566  	}
   567  	if pdoc.Type != permission.TypeKonnector {
   568  		return ErrForbidden
   569  	}
   570  	permSlug := strings.TrimPrefix(pdoc.SourceID, consts.Konnectors+"/")
   571  	if permSlug != slug {
   572  		return ErrForbidden
   573  	}
   574  	return nil
   575  }
   576  
   577  // AllowLogout checks if the current permission allows logging out.
   578  // all apps can trigger a logout.
   579  func AllowLogout(c echo.Context) bool {
   580  	return HasWebAppToken(c)
   581  }
   582  
   583  // AllowMaximal checks that the permission is for the flagship app.
   584  func AllowMaximal(c echo.Context) error {
   585  	pdoc, err := GetPermission(c)
   586  	if err != nil {
   587  		return err
   588  	}
   589  	if !pdoc.Permissions.IsMaximal() {
   590  		return ErrForbidden
   591  	}
   592  	return nil
   593  }
   594  
   595  // RequireSettingsApp checks that the permission is for the settings app.
   596  func RequireSettingsApp(c echo.Context) error {
   597  	pdoc, err := GetPermission(c)
   598  	if err != nil {
   599  		return err
   600  	}
   601  	settingsSourceID := consts.Apps + "/" + consts.SettingsSlug
   602  	if pdoc.Type != permission.TypeWebapp || pdoc.SourceID != settingsSourceID {
   603  		return ErrForbidden
   604  	}
   605  	return nil
   606  }
   607  
   608  // HasWebAppToken returns true if the request comes from a web app (with a token).
   609  func HasWebAppToken(c echo.Context) bool {
   610  	pdoc, err := GetPermission(c)
   611  	if err != nil {
   612  		return false
   613  	}
   614  	return pdoc.Type == permission.TypeWebapp
   615  }
   616  
   617  // GetOAuthClient returns the OAuth client used for making the HTTP request.
   618  func GetOAuthClient(c echo.Context) (*oauth.Client, bool) {
   619  	perm, err := GetPermission(c)
   620  	if err != nil || perm.Type != permission.TypeOauth || perm.Client == nil {
   621  		return nil, false
   622  	}
   623  	return perm.Client.(*oauth.Client), true
   624  }