Repository: owncloud/ocis
Pull request #9691 (2024-07-25 14:05:21)

isolated app configuration
## Description
Introduce a new feature which allows configuring applications individually instead of using the global apps.yaml file.
With that, each application can have its own configuration file, which will be loaded by the WEB service.

The local configuration has the highest priority and will override the global configuration.
The Following order of precedence is used: local.config > global.config > manifest.config.

Besides the configuration, the application now be disabled by setting the `disabled` field to `true` in one of the configuration files.

APP/manifest.json
```json
{
  "entrypoint": "index.js",
  "config": {
    "from_manifest": 1,
    "bar": 1
  }
}
```

apps.yaml
```yaml
bar:
  config:
    from_global: 1
    bar: 2
```

APP/config.json
```json
{
  "config": {
    "from_local": 1,
    "bar": 3
  }
}
```

result https://XZY/config.json
```json
{
  "external_apps": [
    {
      "id": "bar",
      "path": "/assets/apps/bar/index.js",
      "config": {
        "bar": 3,
        "from_global": 1,
        "from_local": 1,
        "from_manifest": 1
      }
    }
  ]
}
```

## Related Issue
- Fixes https://github.com/owncloud/ocis/issues/9687

## Motivation and Context
Allow to configure application individually.

## How Has This Been Tested?
- unit tests

## Types of changes
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Technical debt
- [ ] Tests only (no source changes)



Patch:
diff --git a/.drone.star b/.drone.star
index 646670531be..154b1b40267 100644
--- a/.drone.star
+++ b/.drone.star
@@ -999,8 +999,6 @@ def wopiValidatorTests(ctx, storage, wopiServerType, accounts_hash_difficulty =
                     "COLLABORATION_LOG_LEVEL": "debug",
                     "COLLABORATION_HTTP_ADDR": "0.0.0.0:9300",
                     "COLLABORATION_GRPC_ADDR": "0.0.0.0:9301",
-                    # no proof keys available in the FakeOffice
-                    "COLLABORATION_APP_PROOF_DISABLE": "true",
                     "COLLABORATION_APP_NAME": "FakeOffice",
                     "COLLABORATION_APP_ADDR": "http://fakeoffice:8080",
                     "COLLABORATION_APP_INSECURE": "true",
@@ -1027,7 +1025,7 @@ def wopiValidatorTests(ctx, storage, wopiServerType, accounts_hash_difficulty =
                 "export WOPI_SRC=$(cat wopisrc)",
                 "echo $WOPI_SRC",
                 "cd /app",
-                "/app/Microsoft.Office.WopiValidator -t $WOPI_TOKEN -w $WOPI_SRC -l $WOPI_TTL --testgroup %s" % testgroup,
+                "/app/Microsoft.Office.WopiValidator -s -t $WOPI_TOKEN -w $WOPI_SRC -l $WOPI_TTL --testgroup %s" % testgroup,
             ],
         })
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 386b9aa532c..6001d715ea1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,17 +60,16 @@ The following sections list the changes for unreleased.
 * Bugfix - Fixed response code for DELETE file that is in postprocessing: [#9689](https://github.com/owncloud/ocis/pull/9689)
 * Change - Remove unavailable web config options: [#9679](https://github.com/owncloud/ocis/pull/9679)
 * Enhancement - Introduce auth-app service: [#9079](https://github.com/owncloud/ocis/pull/9079)
-* Enhancement - Add support for proof keys for the collaboration service: [#9366](https://github.com/owncloud/ocis/pull/9366)
 * Enhancement - Log user agent and remote addr on auth errors: [#9475](https://github.com/owncloud/ocis/pull/9475)
 * Enhancement - Add missing WOPI features: [#9580](https://github.com/owncloud/ocis/pull/9580)
 * Enhancement - Update web to v9.1.0-alpha.1: [#9585](https://github.com/owncloud/ocis/pull/9585)
 * Enhancement - Bump commitID for web: [#9631](https://github.com/owncloud/ocis/pull/9631)
 * Enhancement - Remove oidc-go dependency: [#9641](https://github.com/owncloud/ocis/pull/9641)
 * Enhancement - Improve the collaboration service logging: [#9653](https://github.com/owncloud/ocis/pull/9653)
+* Enhancement - Bump reva: [#9662](https://github.com/owncloud/ocis/pull/9662)
 * Enhancement - Fix trash command: [#9665](https://github.com/owncloud/ocis/pull/9665)
 * Enhancement - Added the debugging to full ocis docker example: [#9666](https://github.com/owncloud/ocis/pull/9666)
 * Enhancement - Add locking support for MS Office Online Server: [#9685](https://github.com/owncloud/ocis/pull/9685)
-* Enhancement - Bump reva: [#9690](https://github.com/owncloud/ocis/pull/9690)
 
 ## Details
 
@@ -168,15 +167,6 @@ The following sections list the changes for unreleased.
 
    https://github.com/owncloud/ocis/pull/9079
 
-* Enhancement - Add support for proof keys for the collaboration service: [#9366](https://github.com/owncloud/ocis/pull/9366)
-
-   Proof keys support will be enabled by default in order to ensure that all the
-   requests come from a trusted source. Since proof keys must be set in the WOPI
-   app (OnlyOffice, Collabora...), it's possible to disable the verification of the
-   proof keys via configuration.
-
-   https://github.com/owncloud/ocis/pull/9366
-
 * Enhancement - Log user agent and remote addr on auth errors: [#9475](https://github.com/owncloud/ocis/pull/9475)
 
    The proxy will now log `user_agent`, `client.address`, `network.peer.address`
@@ -228,6 +218,13 @@ The following sections list the changes for unreleased.
 
    https://github.com/owncloud/ocis/pull/9653
 
+* Enhancement - Bump reva: [#9662](https://github.com/owncloud/ocis/pull/9662)
+
+   https://github.com/owncloud/ocis/pull/9662
+   https://github.com/owncloud/ocis/pull/9621
+   https://github.com/owncloud/ocis/pull/9677
+   https://github.com/owncloud/ocis/pull/9689
+
 * Enhancement - Fix trash command: [#9665](https://github.com/owncloud/ocis/pull/9665)
 
    The `ocis trash purge-empty-dirs` command should work on any storage provider,
@@ -250,14 +247,6 @@ The following sections list the changes for unreleased.
 
    https://github.com/owncloud/ocis/pull/9685
 
-* Enhancement - Bump reva: [#9690](https://github.com/owncloud/ocis/pull/9690)
-
-   https://github.com/owncloud/ocis/pull/9690
-   https://github.com/owncloud/ocis/pull/9662
-   https://github.com/owncloud/ocis/pull/9621
-   https://github.com/owncloud/ocis/pull/9677
-   https://github.com/owncloud/ocis/pull/9689
-
 # Changelog for [6.1.0] (2024-07-08)
 
 The following sections list the changes for 6.1.0.
diff --git a/changelog/unreleased/bump-reva.md b/changelog/unreleased/bump-reva.md
index c72b812b38b..177a875099b 100644
--- a/changelog/unreleased/bump-reva.md
+++ b/changelog/unreleased/bump-reva.md
@@ -1,6 +1,5 @@
 Enhancement: Bump reva
 
-https://github.com/owncloud/ocis/pull/9690
 https://github.com/owncloud/ocis/pull/9662
 https://github.com/owncloud/ocis/pull/9621
 https://github.com/owncloud/ocis/pull/9677
diff --git a/changelog/unreleased/collaboration-proofkeys.md b/changelog/unreleased/collaboration-proofkeys.md
deleted file mode 100644
index 15975b73be8..00000000000
--- a/changelog/unreleased/collaboration-proofkeys.md
+++ /dev/null
@@ -1,8 +0,0 @@
-Enhancement: Add support for proof keys for the collaboration service
-
-Proof keys support will be enabled by default in order to ensure that all
-the requests come from a trusted source.
-Since proof keys must be set in the WOPI app (OnlyOffice, Collabora...), it's
-possible to disable the verification of the proof keys via configuration.
-
-https://github.com/owncloud/ocis/pull/9366
diff --git a/changelog/unreleased/enhancement-local-app-configuration.md b/changelog/unreleased/enhancement-local-app-configuration.md
new file mode 100644
index 00000000000..86fd1c082ee
--- /dev/null
+++ b/changelog/unreleased/enhancement-local-app-configuration.md
@@ -0,0 +1,12 @@
+Enhancement: Local WEB App configuration
+
+We've added a new feature which allows configuring applications individually instead of using the global apps.yaml file.
+With that, each application can have its own configuration file, which will be loaded by the WEB service.
+
+The local configuration has the highest priority and will override the global configuration.
+The Following order of precedence is used: local.config > global.config > manifest.config.
+
+Besides the configuration, the application now be disabled by setting the `disabled` field to `true` in one of the configuration files.
+
+https://github.com/owncloud/ocis/pull/9691
+https://github.com/owncloud/ocis/issues/9687
diff --git a/go.mod b/go.mod
index b60acd7b850..d85d51cd1e6 100644
--- a/go.mod
+++ b/go.mod
@@ -15,7 +15,7 @@ require (
 	github.com/cenkalti/backoff v2.2.1+incompatible
 	github.com/coreos/go-oidc/v3 v3.10.0
 	github.com/cs3org/go-cs3apis v0.0.0-20240724121416-062c4e3046cb
-	github.com/cs3org/reva/v2 v2.21.1-0.20240725094850-569a027d5cc6
+	github.com/cs3org/reva/v2 v2.21.1-0.20240725083514-19e4d645016b
 	github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25
 	github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e
 	github.com/egirna/icap-client v0.1.1
diff --git a/go.sum b/go.sum
index 2309f36abdf..f215cfd37d9 100644
--- a/go.sum
+++ b/go.sum
@@ -1025,8 +1025,8 @@ github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c=
 github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME=
 github.com/cs3org/go-cs3apis v0.0.0-20240724121416-062c4e3046cb h1:KmYZDReplv/yfwc1LNYpDcVhVujC3Pasv6WjXx1haSU=
 github.com/cs3org/go-cs3apis v0.0.0-20240724121416-062c4e3046cb/go.mod h1:yyP8PRo0EZou3nSH7H4qjlzQwaydPeIRNgX50npQHpE=
-github.com/cs3org/reva/v2 v2.21.1-0.20240725094850-569a027d5cc6 h1:kGRQf90rhI4L2vU+RxPIP8LHmCkGhESV3VpML3Owkh0=
-github.com/cs3org/reva/v2 v2.21.1-0.20240725094850-569a027d5cc6/go.mod h1:vz5FHfuRaLvSO6bscoWWx+29RtyJm0wIWLZciCLN3L0=
+github.com/cs3org/reva/v2 v2.21.1-0.20240725083514-19e4d645016b h1:3pq7bPCy7NUtlIWytzB4+MP8I6V8bgIdHxsrmyKbHzU=
+github.com/cs3org/reva/v2 v2.21.1-0.20240725083514-19e4d645016b/go.mod h1:vz5FHfuRaLvSO6bscoWWx+29RtyJm0wIWLZciCLN3L0=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
 github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
 github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
diff --git a/services/collaboration/pkg/config/app.go b/services/collaboration/pkg/config/app.go
index ee569189a65..e255f21e8ca 100644
--- a/services/collaboration/pkg/config/app.go
+++ b/services/collaboration/pkg/config/app.go
@@ -9,11 +9,4 @@ type App struct {
 
 	Addr     string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"6.0.0"`
 	Insecure bool   `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"6.0.0"`
-
-	ProofKeys ProofKeys `yaml:"proofkeys"`
-}
-
-type ProofKeys struct {
-	Disable  bool   `yaml:"disable" env:"COLLABORATION_APP_PROOF_DISABLE" desc:"Disable the proof keys verification" introductionVersion:"6.0.0"`
-	Duration string `yaml:"duration" env:"COLLABORATION_APP_PROOF_DURATION" desc:"Duration for the proof keys to be cached in memory, using time.ParseDuration format. If the duration can't be parsed, we'll use the default 12h as duration" introductionVersion:"6.0.0"`
 }
diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go
index 19e5618f23f..1fd0e4e7189 100644
--- a/services/collaboration/pkg/config/defaults/defaultconfig.go
+++ b/services/collaboration/pkg/config/defaults/defaultconfig.go
@@ -26,10 +26,6 @@ func DefaultConfig() *config.Config {
 			LockName:    "com.github.owncloud.collaboration",
 			Addr:        "https://127.0.0.1:9980",
 			Insecure:    false,
-			ProofKeys: config.ProofKeys{
-				// they'll be enabled by default
-				Duration: "12h",
-			},
 		},
 		GRPC: config.GRPC{
 			Addr:      "127.0.0.1:9301",
diff --git a/services/collaboration/pkg/middleware/proofkeys.go b/services/collaboration/pkg/middleware/proofkeys.go
deleted file mode 100644
index 064e541318d..00000000000
--- a/services/collaboration/pkg/middleware/proofkeys.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package middleware
-
-import (
-	"net/http"
-	"net/url"
-	"time"
-
-	"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
-	"github.com/owncloud/ocis/v2/services/collaboration/pkg/proofkeys"
-	"github.com/rs/zerolog"
-)
-
-func ProofKeysMiddleware(cfg *config.Config, next http.Handler) http.Handler {
-	wopiDiscovery := cfg.App.Addr + "/hosting/discovery"
-	insecure := cfg.App.Insecure
-	cacheDuration, err := time.ParseDuration(cfg.App.ProofKeys.Duration)
-	if err != nil {
-		cacheDuration = 12 * time.Hour
-	}
-
-	pkHandler := proofkeys.NewVerifyHandler(wopiDiscovery, insecure, cacheDuration)
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		logger := zerolog.Ctx(r.Context())
-
-		// the url we need is the one being requested, but we need the
-		// scheme and host, so we'll get those from the configured WOPISrc
-		wopiSrcURL, _ := url.Parse(cfg.Wopi.WopiSrc)
-		currentURL, _ := url.Parse(r.URL.String())
-		currentURL.Scheme = wopiSrcURL.Scheme
-		currentURL.Host = wopiSrcURL.Host
-
-		accessToken := r.URL.Query().Get("access_token")
-		stamp := r.Header.Get("X-WOPI-TimeStamp")
-
-		err := pkHandler.Verify(
-			accessToken,
-			currentURL.String(),
-			stamp,
-			r.Header.Get("X-WOPI-Proof"),
-			r.Header.Get("X-WOPI-ProofOld"),
-			proofkeys.VerifyWithLogger(logger),
-		)
-
-		if err != nil {
-			logger.Error().Err(err).Msg("ProofKeys verification failed")
-			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
-			return
-		}
-
-		logger.Debug().Msg("ProofKeys verified")
-
-		next.ServeHTTP(w, r)
-	})
-}
diff --git a/services/collaboration/pkg/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go
index d1b73e43711..e9998c0d75c 100644
--- a/services/collaboration/pkg/middleware/wopicontext.go
+++ b/services/collaboration/pkg/middleware/wopicontext.go
@@ -90,9 +90,6 @@ func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler
 		logger := zerolog.Ctx(ctx)
 		ctx = logger.With().
 			Str("WopiOverride", r.Header.Get("X-WOPI-Override")).
-			Str("WopiProof", r.Header.Get("X-WOPI-Proof")).
-			Str("WopiProofOld", r.Header.Get("X-WOPI-ProofOld")).
-			Str("WopiStamp", r.Header.Get("X-WOPI-TimeStamp")).
 			Str("FileReference", claims.WopiContext.FileReference.String()).
 			Str("ViewMode", claims.WopiContext.ViewMode.String()).
 			Str("Requester", claims.WopiContext.User.GetId().String()).
diff --git a/services/collaboration/pkg/proofkeys/handler.go b/services/collaboration/pkg/proofkeys/handler.go
deleted file mode 100644
index 2ecbd6fe50f..00000000000
--- a/services/collaboration/pkg/proofkeys/handler.go
+++ /dev/null
@@ -1,280 +0,0 @@
-package proofkeys
-
-import (
-	"bytes"
-	"crypto"
-	"crypto/rsa"
-	"crypto/sha256"
-	"crypto/tls"
-	"encoding/base64"
-	"errors"
-	"math/big"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/beevik/etree"
-	"github.com/rs/zerolog"
-)
-
-type PubKeys struct {
-	Key        *rsa.PublicKey
-	OldKey     *rsa.PublicKey
-	ExpireTime time.Time
-}
-
-type Verifier interface {
-	Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error
-}
-
-type VerifyHandler struct {
-	discoveryURL string
-	insecure     bool
-	cachedKeys   *PubKeys
-	cachedDur    time.Duration
-}
-
-// NewVerifyHandler will return a new Verifier with the provided parameters
-// The discoveryURL must point to the https://office.wopi/hosting/discovery
-// address, which contains the xml with the proof keys (and more information)
-// The insecure parameter can be used to disable certificate verification when
-// conecting to the provided discoveryURL
-// CachedDur is the duration the keys will be cached in memory. The cached keys
-// will be used for the duration provided, after that new keys will be fetched
-// from the discoveryURL.
-//
-// For WOPI apps whose proof keys rotate after a while, you must ensure that
-// the provided duration is shorter than the rotation time. This should
-// guarantee that we can't fail to verify a request due to obsolete keys.
-func NewVerifyHandler(discoveryURL string, insecure bool, cachedDur time.Duration) Verifier {
-	return &VerifyHandler{
-		discoveryURL: discoveryURL,
-		insecure:     insecure,
-		cachedDur:    cachedDur,
-	}
-}
-
-// Verify the request comes from a trusted source
-// All the provided parameters are strings:
-// * accessToken: The access token used for this request (targeting this collaboration service)
-// * url: The full url for this request, including scheme, host and all query parameters,
-// something like "https://wopiserver.test.private/wopi/file/abcbcbd?access_token=oiuiu" or
-// "http://wopiserver:8888/wopi/file/abcdef?access_token=zzxxyy"
-// * timestamp: The timestamp provided by the WOPI app in the "X-WOPI-TimeStamp" header, as string
-// * sig64: The base64-encoded signature, which should come directly from the "X-WOPI-Proof" header
-// * oldSig64: The base64-encoded previous signature, coming from the "X-WOPI-ProofOld" header
-//
-// The public keys will be obtained from the /hosting/discovery path of the target WOPI app.
-// Note that the method will perform the following checks in that order:
-// * current signature with the current key
-// * old signature with the current key
-// * current signature with the old key
-// If all of those checks are wrong, the method will fail, and the request should be rejected.
-//
-// The method will return an error if something fails, or nil if everything is ok
-func (vh *VerifyHandler) Verify(accessToken, url, timestamp, sig64, oldSig64 string, opts ...VerifyOption) error {
-	verifyOptions := newOptions(opts...)
-
-	// check timestamp
-	if err := vh.checkTimestamp(timestamp); err != nil {
-		return err
-	}
-
-	// need to decode the signatures
-	signature, err := base64.StdEncoding.DecodeString(sig64)
-	if err != nil {
-		return err
-	}
-
-	var oldSignature []byte
-	if oldSig64 != "" {
-		if oldSig, err := base64.StdEncoding.DecodeString(oldSig64); err != nil {
-			return err
-		} else {
-			oldSignature = oldSig
-		}
-	}
-
-	pubkeys := vh.cachedKeys
-	if pubkeys == nil || pubkeys.ExpireTime.Before(time.Now()) {
-		// fetch the public keys
-		newpubkeys, err := vh.fetchPublicKeys(verifyOptions.Logger)
-		if err != nil {
-			return err
-		}
-		pubkeys = newpubkeys
-	}
-
-	// build and hash the expected proof
-	expectedProof := vh.generateProof(accessToken, url, timestamp)
-	hashedProof := sha256.Sum256(expectedProof)
-
-	// verify
-	if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], signature); err != nil {
-		if err := rsa.VerifyPKCS1v15(pubkeys.Key, crypto.SHA256, hashedProof[:], oldSignature); err != nil {
-			if pubkeys.OldKey != nil {
-				return rsa.VerifyPKCS1v15(pubkeys.OldKey, crypto.SHA256, hashedProof[:], signature)
-			} else {
-				return err
-			}
-		}
-	}
-	return nil
-}
-
-// checkTimestamp will check if the provided timestamp is valid.
-// The timestamp is valid if it isn't older than 20 minutes (info from
-// MS WOPI docs).
-//
-// Note: the timestamp is based on C# DateTime.UtcNow.Ticks
-// "One tick equals 100 nanoseconds. The value of this property represents
-// the number of ticks that have elapsed since 12:00:00 midnight, January 1, 0001."
-// It is NOT a unix timestamp (current unix timestamp ~1718123417 secs ;
-// expected timestamp ~638537195321890000 100-nanosecs)
-func (vh *VerifyHandler) checkTimestamp(timestamp string) error {
-	// set the stamp
-	stamp, err := strconv.ParseInt(timestamp, 10, 64)
-	if err != nil {
-		return errors.New("Invalid timestamp")
-	}
-
-	// 62135596800 seconds from "January 1, 1 AD" to "January 1, 1970 12:00:00 AM"
-	// need to convert those secs into 100-nanosecs in order to compare the stamp
-	unixBaseStamp := int64(62135596800 * 1000 * 1000 * 10)
-
-	// stamp - unixBaseStamp gives us the unix-based timestamp we can use
-	unixStamp := stamp - unixBaseStamp
-
-	// divide between 1000*1000*10 to get the seconds and 100-nanoseconds
-	unixStampSec := unixStamp / (1000 * 1000 * 10)
-	unixStampNanoSec := (unixStamp % (1000 * 1000 * 10)) * 100
-
-	// both seconds and nanoseconds should be within int64 range
-	convertedUnixTimestamp := time.Unix(unixStampSec, unixStampNanoSec)
-
-	if time.Now().After(convertedUnixTimestamp.Add(20 * time.Minute)) {
-		return errors.New("Timestamp expired")
-	}
-	return nil
-}
-
-// generateProof will generated a expected proof to be verified later.
-// The method will return a slice of bytes with the proof (consider it binary
-// data).
-// The bytes will need to be hashed later in order to perform the verification
-func (vh *VerifyHandler) generateProof(accessToken, url, timestamp string) []byte {
-	tokenBytes := []byte(accessToken)
-	tokenLen := len(tokenBytes)
-	tokenLenBytes := big.NewInt(int64(tokenLen)).FillBytes(make([]byte, 4))
-
-	// url needs to be uppercase
-	urlBytes := []byte(strings.ToUpper(url))
-	urlLen := len(urlBytes)
-	urlLenBytes := big.NewInt(int64(urlLen)).FillBytes(make([]byte, 4))
-
-	stampBigInt, _ := new(big.Int).SetString(timestamp, 10)
-	stampBytes := stampBigInt.FillBytes(make([]byte, 8))
-	stampLen := len(stampBytes)
-	stampLenBytes := big.NewInt(int64(stampLen)).FillBytes(make([]byte, 4))
-
-	proof := new(bytes.Buffer)
-	proof.Write(tokenLenBytes)
-	proof.Write(tokenBytes)
-	proof.Write(urlLenBytes)
-	proof.Write(urlBytes)
-	proof.Write(stampLenBytes)
-	proof.Write(stampBytes)
-	return proof.Bytes()
-}
-
-// fetchPublicKeys will fetch the public keys from the /hosting/discovery URL
-// of the provided WOPI app.
-// It will return a PubKeys struct to hold the public keys based on the modulus
-// and exponent found.
-// The PubKeys returned might be either nil (with the non-nil error), or might
-// contain only a PubKeys.Key field (the PubKeys.OldKey might be nil)
-func (vh *VerifyHandler) fetchPublicKeys(logger *zerolog.Logger) (*PubKeys, error) {
-	httpClient := http.Client{
-		Transport: &http.Transport{
-			TLSClientConfig: &tls.Config{
-				InsecureSkipVerify: vh.insecure,
-			},
-		},
-	}
-
-	httpResp, err := httpClient.Get(vh.discoveryURL)
-	if err != nil {
-		logger.Error().
-			Err(err).
-			Str("WopiAppUrl", vh.discoveryURL).
-			Msg("WopiDiscovery: failed to access wopi app url")
-		return nil, err
-	}
-
-	defer httpResp.Body.Close()
-
-	if httpResp.StatusCode != http.StatusOK {
-		logger.Error().
-			Str("WopiAppUrl", vh.discoveryURL).
-			Int("HttpCode", httpResp.StatusCode).
-			Msg("WopiDiscovery: wopi app url failed with unexpected code")
-		return nil, err
-	}
-
-	doc := etree.NewDocument()
-	if _, err := doc.ReadFrom(httpResp.Body); err != nil {
-		return nil, err
-	}
-
-	root := doc.SelectElement("wopi-discovery")
-	if root == nil {
-		return nil, errors.New("wopi-discovery element not found in the XML body")
-	}
-
-	proofKey := root.SelectElement("proof-key")
-	if proofKey == nil {
-		return nil, errors.New("proof-key element not found in the XML body")
-	}
-
-	mod64 := proofKey.SelectAttrValue("modulus", "")
-	exp64 := proofKey.SelectAttrValue("exponent", "")
-	oldMod64 := proofKey.SelectAttrValue("oldmodulus", "")
-	oldExp64 := proofKey.SelectAttrValue("oldexponent", "")
-
-	if mod64 == "" || exp64 == "" {
-		return nil, errors.New("modulus or exponent not found in the proof-key element")
-	}
-
-	keys := &PubKeys{
-		Key:        vh.keyFromBase64(mod64, exp64),
-		ExpireTime: time.Now().Add(vh.cachedDur),
-	}
-
-	if oldMod64 != "" && oldExp64 != "" {
-		keys.OldKey = vh.keyFromBase64(oldMod64, oldExp64)
-	}
-
-	return keys, nil
-}
-
-// keyFromBase64 will create a rsa public key from the provided modulus and
-// exponent, both encoded with base64.
-// If any of the provided strings can't be decoded, nil will be returned.
-func (vh *VerifyHandler) keyFromBase64(mod64, exp64 string) *rsa.PublicKey {
-	dataMod, err := base64.StdEncoding.DecodeString(mod64)
-	if err != nil {
-		return nil
-	}
-
-	dataE, err := base64.StdEncoding.DecodeString(exp64)
-	if err != nil {
-		return nil
-	}
-
-	pub := &rsa.PublicKey{
-		N: new(big.Int).SetBytes(dataMod),
-		E: int(new(big.Int).SetBytes(dataE).Int64()),
-	}
-	return pub
-}
diff --git a/services/collaboration/pkg/proofkeys/option.go b/services/collaboration/pkg/proofkeys/option.go
deleted file mode 100644
index 86dacefaef7..00000000000
--- a/services/collaboration/pkg/proofkeys/option.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package proofkeys
-
-import (
-	"github.com/rs/zerolog"
-)
-
-// VerifyOption defines a single option function.
-type VerifyOption func(o *VerifyOptions)
-
-// VerifyOptions defines the available options for the Verify function.
-type VerifyOptions struct {
-	Logger *zerolog.Logger
-}
-
-// newOptions initializes the available default options.
-func newOptions(opts ...VerifyOption) VerifyOptions {
-	defaultLog := zerolog.Nop()
-	opt := VerifyOptions{
-		//Logger: log.NopLogger(), // use a NopLogger by default
-		Logger: &defaultLog, // use a NopLogger by default
-	}
-
-	for _, o := range opts {
-		o(&opt)
-	}
-
-	return opt
-}
-
-// VerifyWithLogger provides a function to set the Logger option.
-func VerifyWithLogger(val *zerolog.Logger) VerifyOption {
-	return func(o *VerifyOptions) {
-		o.Logger = val
-	}
-}
diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go
index c331d6b6714..6218d038392 100644
--- a/services/collaboration/pkg/server/http/server.go
+++ b/services/collaboration/pkg/server/http/server.go
@@ -120,13 +120,6 @@ func prepareRoutes(r *chi.Mux, options Options) {
 				return colabmiddleware.WopiContextAuthMiddleware(options.Config.Wopi.Secret, h)
 			})
 
-			// check whether we should check for proof keys
-			if !options.Config.App.ProofKeys.Disable {
-				r.Use(func(h stdhttp.Handler) stdhttp.Handler {
-					return colabmiddleware.ProofKeysMiddleware(options.Config, h)
-				})
-			}
-
 			r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
 				adapter.CheckFileInfo(w, r)
 			})
diff --git a/services/web/README.md b/services/web/README.md
index 426a9c53c2a..80aa8fc4999 100644
--- a/services/web/README.md
+++ b/services/web/README.md
@@ -88,6 +88,7 @@ applications from the WebUI.
 Everything else is skipped and not considered as an application.
    *   Each application must be in its own directory accessed via `WEB_ASSET_APPS_PATH`.
    *   Each application directory must contain a `manifest.json` file.
+   *   Each application directory can contain a `config.json` file.
 
 * The `manifest.json` file contains the following fields:
    *   `entrypoint` - required\
@@ -122,7 +123,18 @@ image-viewer-obj:
     maxSize: 512
 ```
 
-The final configuration for web will be:
+optional each application can have its own configuration file, which will be loaded by the WEB service.
+
+```json
+{
+  "config": {
+    "maxWidth": 320
+  }
+}
+```
+
+The Merge order is as follows: local.config overwrites > global.config overwrites > manifest.config.
+The result will be:
 
 ```json
 {
@@ -131,7 +143,7 @@ The final configuration for web will be:
       "id": "image-viewer-obj",
       "path": "index.js",
       "config": {
-        "maxWidth": 1280,
+        "maxWidth": 320,
         "maxHeight": 640,
         "maxSize": 512
       }
@@ -140,7 +152,8 @@ The final configuration for web will be:
 }
 ```
 
-Besides the configuration from the `manifest.json` file, the `apps.yaml` file can also contain the following fields:
+Besides the configuration from the `manifest.json` file,
+the `apps.yaml` or the `config.json` file can also contain the following fields:
 
 *   `disabled` - optional\
     Defaults to `false`. If set to `true`, the application will not be loaded.
@@ -149,7 +162,7 @@ Besides the configuration from the `manifest.json` file, the `apps.yaml` file ca
 
 Besides the configuration and application registration, in the process of loading the application assets, the system uses a mechanism to load custom assets.
 
-This is very useful for cases where just a single asset should be overwritten, like a logo or similar.
+This is useful for cases where just a single asset should be overwritten, like a logo or similar.
 
 Consider the following: Infinite Scale is shipped with a default web app named `image-viewer-dfx` which contains a logo,
 but the administrator wants to provide a custom logo for that application.
diff --git a/services/web/pkg/apps/apps.go b/services/web/pkg/apps/apps.go
index 1c617b36c9f..79e3775ac0c 100644
--- a/services/web/pkg/apps/apps.go
+++ b/services/web/pkg/apps/apps.go
@@ -34,6 +34,9 @@ var (
 const (
 	// _manifest is the name of the manifest file for an application
 	_manifest = "manifest.json"
+
+	// _config contains the dedicated app configuration
+	_config = "config.json"
 )
 
 // Application contains the metadata of an application
@@ -41,6 +44,9 @@ type Application struct {
 	// ID is the unique identifier of the application
 	ID string `json:"-"`
 
+	// Disabled is a flag to disable the application
+	Disabled bool `json:"disabled,omitempty"`
+
 	// Entrypoint is the entrypoint of the application within the bundle
 	Entrypoint string `json:"entrypoint" validate:"required"`
 
@@ -83,18 +89,18 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap
 				appData = data
 			}
 
-			if appData.Disabled {
-				// if the app is disabled, skip it
-				continue
-			}
-
-			application, err := build(fSystem, name, appData.Config)
+			application, err := build(fSystem, name, appData)
 			if err != nil {
 				// if app creation fails, log the error and continue with the next app
 				logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application")
 				continue
 			}
 
+			if application.Disabled {
+				// if the app is disabled, skip it
+				continue
+			}
+
 			// everything is fine, add the application to the list of applications
 			registry[name] = application
 		}
@@ -103,36 +109,72 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap
 	return maps.Values(registry)
 }
 
-func build(fSystem fs.FS, id string, conf map[string]any) (Application, error) {
+func build(fSystem fs.FS, id string, globalConfig config.App) (Application, error) {
 	// skip non-directory listings, every app needs to be contained inside a directory
 	entry, err := fs.Stat(fSystem, id)
 	if err != nil || !entry.IsDir() {
 		return Application{}, ErrInvalidApp
 	}
 
-	// read the manifest.json from the app directory.
-	manifest := path.Join(id, _manifest)
-	reader, err := fSystem.Open(manifest)
-	if err != nil {
-		// manifest.json is required
-		return Application{}, errors.Join(err, ErrMissingManifest)
+	var application Application
+	// build the application
+	{
+		r, err := fSystem.Open(path.Join(id, _manifest))
+		if err != nil {
+			return Application{}, errors.Join(err, ErrMissingManifest)
+		}
+		defer r.Close()
+
+		if json.NewDecoder(r).Decode(&application) != nil {
+			return Application{}, errors.Join(err, ErrInvalidManifest)
+		}
+
+		if err := validate.Struct(application); err != nil {
+			return Application{}, errors.Join(err, ErrInvalidManifest)
+		}
 	}
-	defer reader.Close()
 
-	var application Application
-	if json.NewDecoder(reader).Decode(&application) != nil {
-		// a valid manifest.json is required
-		return Application{}, errors.Join(err, ErrInvalidManifest)
+	var localConfig config.App
+	// build the local configuration
+	{
+		r, err := fSystem.Open(path.Join(id, _config))
+		if err == nil {
+			defer r.Close()
+		}
+
+		// as soon as we have a local configuration, we expect it to be valid
+		if err == nil && json.NewDecoder(r).Decode(&localConfig) != nil {
+			return Application{}, errors.Join(err, ErrInvalidManifest)
+		}
 	}
 
-	if err := validate.Struct(application); err != nil {
-		// the application is required to be valid
-		return Application{}, errors.Join(err, ErrInvalidManifest)
+	// apply overloads the application with the source configuration,
+	// not all fields are considered secure, therefore, the allowed values are hand-picked
+	overloadApplication := func(source config.App) error {
+		// overload the configuration,
+		// configuration options are considered secure and can be overloaded
+		err = mergo.Merge(&application.Config, source.Config, mergo.WithOverride)
+		if err != nil {
+			return err
+		}
+
+		// overload the disabled state, consider it secure and allow overloading
+		application.Disabled = source.Disabled
+
+		return nil
 	}
 
-	// overload the default configuration with the application-specific configuration,
-	// the application-specific configuration has priority, and failing is fine here
-	_ = mergo.Merge(&application.Config, conf, mergo.WithOverride)
+	// overload the application configuration from the manifest with the local and global configuration
+	// priority is local.config > global.config > manifest.config
+	{
+		// overload the default configuration with the global application-specific configuration,
+		// that configuration has priority, and failing is fine here
+		_ = overloadApplication(globalConfig)
+
+		// overload the default configuration with the local application-specific configuration,
+		// that configuration has priority, and failing is fine here
+		_ = overloadApplication(localConfig)
+	}
 
 	// the entrypoint is jailed to the app directory
 	application.Entrypoint = filepathx.JailJoin(id, application.Entrypoint)
diff --git a/vendor/github.com/cs3org/reva/v2/pkg/storage/utils/decomposedfs/upload/upload.go b/vendor/github.com/cs3org/reva/v2/pkg/storage/utils/decomposedfs/upload/upload.go
index 92c9438f7e1..d540dbf1420 100644
--- a/vendor/github.com/cs3org/reva/v2/pkg/storage/utils/decomposedfs/upload/upload.go
+++ b/vendor/github.com/cs3org/reva/v2/pkg/storage/utils/decomposedfs/upload/upload.go
@@ -171,7 +171,7 @@ func (session *OcisSession) FinishUpload(ctx context.Context) error {
 	metrics.UploadProcessing.Inc()
 	metrics.UploadSessionsBytesReceived.Inc()
 
-	if session.store.pub != nil && session.info.Size > 0 {
+	if session.store.pub != nil {
 		u, _ := ctxpkg.ContextGetUser(ctx)
 		s, err := session.URL(ctx)
 		if err != nil {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 047f7b61b2e..29c8285e91a 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -366,7 +366,7 @@ github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1
 github.com/cs3org/go-cs3apis/cs3/storage/registry/v1beta1
 github.com/cs3org/go-cs3apis/cs3/tx/v1beta1
 github.com/cs3org/go-cs3apis/cs3/types/v1beta1
-# github.com/cs3org/reva/v2 v2.21.1-0.20240725094850-569a027d5cc6
+# github.com/cs3org/reva/v2 v2.21.1-0.20240725083514-19e4d645016b
 ## explicit; go 1.21
 github.com/cs3org/reva/v2/cmd/revad/internal/grace
 github.com/cs3org/reva/v2/cmd/revad/runtime


Test patch:
diff --git a/services/web/pkg/apps/apps_test.go b/services/web/pkg/apps/apps_test.go
index 0f226ed9b70..4b27e027dc8 100644
--- a/services/web/pkg/apps/apps_test.go
+++ b/services/web/pkg/apps/apps_test.go
@@ -35,69 +35,89 @@ func TestBuild(t *testing.T) {
 		Mode: fs.ModeDir,
 	}
 
-	_, err := apps.Build(fstest.MapFS{
-		"app": &fstest.MapFile{},
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp))
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app": &fstest.MapFile{},
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrInvalidApp))
+	}
 
-	_, err = apps.Build(fstest.MapFS{
-		"app": dir,
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest))
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app": dir,
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrMissingManifest))
+	}
 
-	_, err = apps.Build(fstest.MapFS{
-		"app":               dir,
-		"app/manifest.json": dir,
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app":               dir,
+			"app/manifest.json": dir,
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
+	}
 
-	_, err = apps.Build(fstest.MapFS{
-		"app": dir,
-		"app/manifest.json": &fstest.MapFile{
-			Data: []byte("{}"),
-		},
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
-
-	_, err = apps.Build(fstest.MapFS{
-		"app":               dir,
-		"app/entrypoint.js": &fstest.MapFile{},
-		"app/manifest.json": &fstest.MapFile{
-			Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
-		},
-	}, "app", map[string]any{})
-	g.Expect(err).ToNot(gomega.HaveOccurred())
-
-	_, err = apps.Build(fstest.MapFS{
-		"app":               dir,
-		"app/entrypoint.js": dir,
-		"app/manifest.json": &fstest.MapFile{
-			Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
-		},
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app": dir,
+			"app/manifest.json": &fstest.MapFile{
+				Data: []byte("{}"),
+			},
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrInvalidManifest))
 
-	_, err = apps.Build(fstest.MapFS{
-		"app": dir,
-		"app/manifest.json": &fstest.MapFile{
-			Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
-		},
-	}, "app", map[string]any{})
-	g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
-
-	application, err := apps.Build(fstest.MapFS{
-		"app":               dir,
-		"app/entrypoint.js": &fstest.MapFile{},
-		"app/manifest.json": &fstest.MapFile{
-			Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"foo": "1", "bar": "2"}}`),
-		},
-	}, "app", map[string]any{"foo": "overwritten-1", "baz": "injected-1"})
-	g.Expect(err).ToNot(gomega.HaveOccurred())
+	}
 
-	g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js"))
-	g.Expect(application.Config).To(gomega.Equal(map[string]interface{}{
-		"foo": "overwritten-1", "baz": "injected-1", "bar": "2",
-	}))
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app":               dir,
+			"app/entrypoint.js": &fstest.MapFile{},
+			"app/manifest.json": &fstest.MapFile{
+				Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
+			},
+		}, "app", config.App{})
+		g.Expect(err).ToNot(gomega.HaveOccurred())
+	}
+
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app":               dir,
+			"app/entrypoint.js": dir,
+			"app/manifest.json": &fstest.MapFile{
+				Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
+			},
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
+	}
+
+	{
+		_, err := apps.Build(fstest.MapFS{
+			"app": dir,
+			"app/manifest.json": &fstest.MapFile{
+				Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js"}`),
+			},
+		}, "app", config.App{})
+		g.Expect(err).To(gomega.MatchError(apps.ErrEntrypointDoesNotExist))
+	}
+
+	{
+		application, err := apps.Build(fstest.MapFS{
+			"app":               dir,
+			"app/entrypoint.js": &fstest.MapFile{},
+			"app/manifest.json": &fstest.MapFile{
+				Data: []byte(`{"id":"app", "entrypoint":"entrypoint.js", "config": {"k1": "1", "k2": "2", "k3": "3"}}`),
+			},
+			"app/config.json": &fstest.MapFile{
+				Data: []byte(`{"config": {"k2": "overwritten-from-config.json", "injected_from_config_json": "11"}}`),
+			},
+		}, "app", config.App{Config: map[string]any{"k2": "overwritten-from-apps.yaml", "k3": "overwritten-from-apps.yaml", "injected_from_apps_yaml": "22"}})
+		g.Expect(err).ToNot(gomega.HaveOccurred())
+
+		g.Expect(application.Entrypoint).To(gomega.Equal("app/entrypoint.js"))
+		g.Expect(application.Config).To(gomega.Equal(map[string]interface{}{
+			"k1": "1", "k2": "overwritten-from-config.json", "k3": "overwritten-from-apps.yaml", "injected_from_config_json": "11", "injected_from_apps_yaml": "22",
+		}))
+	}
 }
 
 func TestList(t *testing.T) {