Compare commits

...
Author SHA1 Message Date
Dominik Schmidt 359dd2f267 feat(graph): expose the sharer as the owner of a public link drive
The public share mountpoint carries no space owner, so GET /drives/{pub}
came back without one and the drop page had no name to show. The request
runs as the share creator (publicshares auth), so the context user is who
shared the link; fill drive.Owner from it, id and display name, the same
source webdav fills oc:owner-display-name from. Deliberate disclosure to the
anonymous visitor, matching webdav and MS Graph's sharedDriveItem.owner.
2026-09-08 11:21:02 +02:00
Dominik Schmidt b392f43662 feat(graph): tell password-required from wrong-password on public links
A public link stat that fails for the password came back as a generic 401
"Access token is empty", so a client could not tell "show the password
field" from "the password was wrong". The proxy now marks the two cases
(it holds the auth result) and the graph service renders them as distinct
odata codes, publicLinkPasswordRequired and publicLinkPasswordInvalid, the
way webdav distinguishes ERR_MISSING_BASIC_AUTH from ERR_INVALID_CREDENTIALS.

The distinction rides in the body, never a WWW-Authenticate: Basic header,
which would pop the browser's native auth dialog instead of the app's
password field. The shared header/token contract lives in pkg/middleware.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 1e6d2ec168 test(acceptance): pin that writes through the public link surface are rejected
createLink, delete, rename and listing permissions on the beta routes all
fail even for an edit link, and the file survives; measured, no 500s.
2026-09-08 11:21:02 +02:00
Dominik Schmidt d9613a068f fix(proxy): log a token hint, not the token 2026-09-08 11:21:02 +02:00
Dominik Schmidt 6727cec526 test(acceptance): pin the closed surface around a public link
Drives collection, the owner's personal drive, real drive id addressing, a
foreign link's drive and share type disclosure all answer 401 or omit the
data.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 6d09a83adc fix(graph): keep owner data out of public link responses
The owner's favorite flag travelled as @libre.graph.meFollowing, the share
root exposed its out-of-share parent id, and $select=@libre.graph.shareTypes
disclosed collaborative grants; found by review.
2026-09-08 11:21:02 +02:00
Dominik Schmidt a0e1d7cbab fix(scope): pin the public share scope to the link's own graph drive
The prefix opened the whole /drives subtree, and the scope allows
ListStorageSpacesRequest unconditionally: any link token could enumerate the
owner's spaces via GET /drives/, read any space by id, and address in-share
items through their real drive id, skipping the response reduction. The path
is cleaned and must now name the token's public drive; found by review.
2026-09-08 11:21:02 +02:00
Dominik Schmidt f70d2c45b5 docs: say why the prefix check does not apply below the public drive 2026-09-08 11:21:02 +02:00
Dominik Schmidt 56bee0a339 docs: shorter comments 2026-09-08 11:21:02 +02:00
Dominik Schmidt bcfccc4dd2 chore: drop the local compose override again 2026-09-08 11:21:02 +02:00
Dominik Schmidt 5b260877dc test(acceptance): pin the upload session authorization on public links
An editable link grants an upload session, a view only link answers 404. The
returned endpoint is not reachable from outside: CreateUploadSession hands
out the internal data server url, for authenticated callers just the same,
so the byte transfer stays with the graph upload work.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 84f9621659 Revert "fix(graph): keep CreateUploadSession strict below the public drive"
This reverts commit 1f51d786d80480df68275dc5fcaf8141556e4c07.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 8733428aed fix(graph): keep CreateUploadSession strict below the public drive
The relaxed item addressing is a read feature; writing through it is untested
and not part of this surface.
2026-09-08 11:21:02 +02:00
Dominik Schmidt a9822a7e24 style: satisfy the gherkin and php linters 2026-09-08 11:21:02 +02:00
Dominik Schmidt f640858427 test(acceptance): pin the item anchored colon path in a public link 2026-09-08 11:21:02 +02:00
Dominik Schmidt 1e29eb9504 refactor: keep the provider's filter naming 2026-09-08 11:21:02 +02:00
Dominik Schmidt da421b6747 refactor(graph): fail when the public link does not resolve
The fallback reduced by hand, base name paths and a blank permission set,
a second copy of the reduction in degraded form. If the link of the request
cannot be resolved there is nothing to serve.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 6eb235ab1c chore: drop a local compose override that slipped in 2026-09-08 11:21:02 +02:00
Dominik Schmidt adb7327779 docs: trim comments 2026-09-08 11:21:02 +02:00
Dominik Schmidt af999345a6 refactor: the public link reduction lives once, in reva
publicshare.ReduceResourceInfo/ReducePermissions replace both copies: the
publicstorageprovider's private filterPermissions plus its augment path
rewrite, and graph's reimplementation. Graph resolves the link of the request
(share root and grant, one GetPublicShare and one Stat) and applies the same
reduction the provider applies to its own responses. Paths below the public
drive are share-root relative now, on every route.

Vendored reva change, goes into the reva PR later.
2026-09-08 11:21:02 +02:00
Dominik Schmidt b045b2be86 fix(vendor): filterPermissions misses DenyGrant
The publicstorageprovider reduces child permissions to what the link grants,
field by field, but DenyGrant is not in the list: a child of a view-only link
could advertise deny. Same vendored-reva caveat as checkGraphDrivesPath, goes
into the reva PR later.
2026-09-08 11:21:02 +02:00
Dominik Schmidt d18c1a1edb fix(graph): advertise only what the public link grants
Navigating by id bypasses the publicstorageprovider, so the permission sets
are the owner's: a view-only link advertised delete and upload on items below
the root. Intersect with the link's permissions, the same reduction the
publicstorageprovider applies on its own responses; enforcement was always
intact through the token scope.
2026-09-08 11:21:02 +02:00
Dominik Schmidt aa03c6c742 test(acceptance): list public links via the graph api 2026-09-08 11:21:02 +02:00
Dominik Schmidt 1d65ce7052 feat(graph): address drive items below the public share drive
Items inside a public link keep their real ids, so the storage/space equality
check cannot hold below the public drive; the token scope guards access
instead. Paths in responses are cut to their base name there: they are
anchored at the owner's space root, and everything above the share root is
the owner's directory structure.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 40f7aaa327 fix(graph): verify token scopes against the url path
The scope handlers know CS3 request types and url paths; handing them the
*http.Request made every restricted scope fail with a type assertion error.
Pass the path, like reva's own http auth interceptor, and allow the graph
drives surface in the public share scope.

The vendored reva change (checkGraphDrivesPath) needs a reva PR before this
can go anywhere.
2026-09-08 11:21:02 +02:00
Dominik Schmidt 46a7dbfb47 feat(proxy): authenticate graph requests carrying a public link token 2026-09-08 11:21:01 +02:00
13 changed files with 1035 additions and 48 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
package middleware
import "net/http"
const (
// PublicLinkTokenName is the query parameter and header carrying a public
// link token on a request.
PublicLinkTokenName = "public-token"
// PublicLinkAuthHeader marks the outcome of a failed public link
// authentication so a downstream service can tell "password required" from
// "wrong password" when it renders the 401. The proxy sets it, the graph
// service reads it.
PublicLinkAuthHeader = "X-Public-Link-Auth"
// PublicLinkPasswordRequired means the link is password protected and no
// password was provided.
PublicLinkPasswordRequired = "password-required"
// PublicLinkInvalidPassword means a password was provided but rejected.
PublicLinkInvalidPassword = "invalid-password"
)
// HasPublicLinkToken reports whether a public link token rides on the request,
// as a query parameter or header.
func HasPublicLinkToken(r *http.Request) bool {
return r.URL.Query().Get(PublicLinkTokenName) != "" || r.Header.Get(PublicLinkTokenName) != ""
}
@@ -76,6 +76,10 @@ const (
PreconditionFailed
// ItemIsLocked The item is locked by another process. Try again later.
ItemIsLocked
// PublicLinkPasswordRequired the public link is password protected and no password was provided.
PublicLinkPasswordRequired
// PublicLinkPasswordInvalid a password was provided for the public link but it was rejected.
PublicLinkPasswordInvalid
)
var errorCodes = [...]string{
@@ -99,6 +103,8 @@ var errorCodes = [...]string{
"unauthenticated",
"preconditionFailed",
"itemIsLocked",
"publicLinkPasswordRequired",
"publicLinkPasswordInvalid",
}
// New constructs a new errorcode.Error
@@ -151,6 +157,8 @@ func (e Error) Render(w http.ResponseWriter, r *http.Request) {
status = http.StatusMethodNotAllowed
case ItemIsLocked:
status = http.StatusLocked
case PublicLinkPasswordRequired, PublicLinkPasswordInvalid:
status = http.StatusUnauthorized
case PreconditionFailed:
status = http.StatusPreconditionFailed
default:
+19 -1
View File
@@ -44,6 +44,23 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
ctx := r.Context()
t := r.Header.Get(revactx.TokenHeader)
if t == "" {
// a public link request that failed the share auth carries a
// hint (set by the proxy) so we can tell the two cases apart;
// only trust it when a share token is actually on the request
if hint := r.Header.Get(opkgm.PublicLinkAuthHeader); hint != "" && opkgm.HasPublicLinkToken(r) {
switch hint {
// distinguish via the body only, never WWW-Authenticate: a
// Basic challenge would pop the browser's native auth dialog
// instead of the app's password field (the proxy strips it
// on public paths for the same reason)
case opkgm.PublicLinkPasswordRequired:
errorcode.PublicLinkPasswordRequired.Render(w, r, http.StatusUnauthorized, "This public link is password protected.")
return
case opkgm.PublicLinkInvalidPassword:
errorcode.PublicLinkPasswordInvalid.Render(w, r, http.StatusUnauthorized, "The password is incorrect.")
return
}
}
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "Access token is empty.")
/* msgraph error for GET https://graph.microsoft.com/v1.0/me
{
@@ -67,7 +84,8 @@ func Auth(opts ...account.Option) func(http.Handler) http.Handler {
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "invalid token")
return
}
if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok {
// scope handlers judge CS3 requests and url paths, not *http.Request
if ok, err := scope.VerifyScope(ctx, tokenScope, r.URL.Path); err != nil || !ok {
opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msg("verifying scope failed")
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "verifying scope failed")
return
+3 -1
View File
@@ -203,7 +203,9 @@ func rewriteColonPath(
logger.Debug().Err(err).Str("driveID", driveID).Msg("invalid drive id in colon path")
return "", errInvalidRequest
}
if drive.GetStorageId() != anchor.GetStorageId() || drive.GetSpaceId() != anchor.GetSpaceId() {
// items below the public share drive keep their real ids; the scope guards access
isPublicDrive := drive.GetStorageId() == utils.PublicStorageProviderID && drive.GetSpaceId() == utils.PublicStorageSpaceID
if !isPublicDrive && (drive.GetStorageId() != anchor.GetStorageId() || drive.GetSpaceId() != anchor.GetSpaceId()) {
logger.Debug().
Str("driveID", driveID).
Str("itemID", anchorIDStr).
+90 -7
View File
@@ -61,6 +61,76 @@ func odataListContains(r *http.Request, parameter, value string) bool {
return false
}
// driveItemInDrive reports whether an item id may be addressed below a drive.
// Below the public share drive items keep their real storage ids, so the
// prefix can never match; the token scope checks containment instead.
func driveItemInDrive(driveID, driveItemID *storageprovider.ResourceId) bool {
if driveID.GetStorageId() == utils.PublicStorageProviderID && driveID.GetSpaceId() == utils.PublicStorageSpaceID {
return true
}
return driveID.GetStorageId() == driveItemID.GetStorageId() && driveID.GetSpaceId() == driveItemID.GetSpaceId()
}
// publicDriveRequest reports whether the request addresses the public share drive.
func publicDriveRequest(r *http.Request) bool {
driveID, err := parseIDParam(r, "driveID")
return err == nil &&
driveID.GetStorageId() == utils.PublicStorageProviderID &&
driveID.GetSpaceId() == utils.PublicStorageSpaceID
}
// sanitizePublicDriveInfos applies the publicstorageprovider's reduction to
// infos that bypassed it (navigation by id).
func (g Graph) sanitizePublicDriveInfos(ctx context.Context, r *http.Request, infos ...*storageprovider.ResourceInfo) error {
shareRoot, grant, err := g.publicLinkOfRequest(ctx, r)
if err != nil {
return err
}
for _, info := range infos {
if info == nil {
continue
}
publicshare.FilterResourceInfo(info, shareRoot, grant)
// the favorite flag is the owner's, not the visitor's
delete(info.GetArbitraryMetadata().GetMetadata(), _favoriteMetadataKey)
// the share root's parent lies outside the share
if utils.ResourceIDEqual(info.GetId(), shareRoot.GetId()) {
info.ParentId = nil
}
}
return nil
}
// publicLinkOfRequest resolves the link the request runs in; the token is the
// public drive's opaque id.
func (g Graph) publicLinkOfRequest(ctx context.Context, r *http.Request) (*storageprovider.ResourceInfo, *storageprovider.ResourcePermissions, error) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
return nil, nil, err
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return nil, nil, err
}
shareResp, err := gatewayClient.GetPublicShare(ctx, &link.GetPublicShareRequest{
Ref: &link.PublicShareReference{
Spec: &link.PublicShareReference_Token{Token: driveID.GetOpaqueId()},
},
})
if err := errorcode.FromCS3Status(shareResp.GetStatus(), err); err != nil {
g.logger.Error().Err(err).Msg("could not resolve the public link of the request")
return nil, nil, err
}
statResp, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{
Ref: &storageprovider.Reference{ResourceId: shareResp.GetShare().GetResourceId()},
})
if err := errorcode.FromCS3Status(statResp.GetStatus(), err); err != nil {
g.logger.Error().Err(err).Msg("could not stat the public link root")
return nil, nil, err
}
return statResp.GetInfo(), shareResp.GetShare().GetPermissions().GetPermissions(), nil
}
// driveItemPropertySelected reports whether the given opt-in property was requested via $select
func driveItemPropertySelected(r *http.Request, property string) bool {
return odataListContains(r, "$select", property)
@@ -98,7 +168,7 @@ func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
if !driveItemInDrive(&driveID, &driveItemID) {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
@@ -287,7 +357,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
if !driveItemInDrive(&driveID, &driveItemID) {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
@@ -312,7 +382,12 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
if publicDriveRequest(r) {
if err := g.sanitizePublicDriveInfos(ctx, r, res.GetInfo()); err != nil {
errorcode.RenderError(w, r, err)
return
}
}
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
@@ -345,7 +420,7 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
driveItem.Children = children
}
if driveItemPropertySelected(r, _selectShareTypes) {
if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) {
infos := []*storageprovider.ResourceInfo{res.GetInfo()}
driveItem.LibreGraphShareTypes = shareTypesOf(res.GetInfo(), g.listLinkShares(ctx, infos))
}
@@ -372,7 +447,7 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
if !driveItemInDrive(&driveID, &driveItemID) {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
@@ -405,7 +480,7 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri
childrenRequest := &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: driveItemID},
}
if driveItemPropertySelected(r, _selectShareTypes) {
if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) {
childrenRequest.FieldMask = shareTypesFieldMask
}
@@ -430,13 +505,21 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri
return nil, false
}
if publicDriveRequest(r) {
if err := g.sanitizePublicDriveInfos(r.Context(), r, res.GetInfos()...); err != nil {
errorcode.RenderError(w, r, err)
return nil, false
}
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return nil, false
}
if driveItemPropertySelected(r, _selectShareTypes) {
// collaborative grants are not for public link visitors
if driveItemPropertySelected(r, _selectShareTypes) && !publicDriveRequest(r) {
g.addShareTypes(r.Context(), files, res.GetInfos())
}
+13
View File
@@ -881,6 +881,19 @@ func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, spa
// DisplayName: , TODO read and cache from users provider
},
}
} else if space.GetRoot().GetStorageId() == utils.PublicStorageProviderID {
// a public share mountpoint carries no space owner; the request runs as
// the share creator (publicshares auth), so the context user is who
// shared it, the same source webdav fills oc:owner-display-name from.
if u, ok := revactx.ContextGetUser(ctx); ok && u.GetId().GetOpaqueId() != "" {
id := u.GetId().GetOpaqueId()
drive.Owner = &libregraph.IdentitySet{
User: &libregraph.Identity{
Id: &id,
DisplayName: u.GetDisplayName(),
},
}
}
}
if space.Mtime != nil {
lastModified := cs3TimestampToTime(space.Mtime)
+1 -1
View File
@@ -18,7 +18,7 @@ type BasicAuthenticator struct {
// Authenticate implements the authenticator interface to authenticate requests via basic auth.
func (m BasicAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if isPublicPath(r.URL.Path) && isPublicWithShareToken(r) {
if (isPublicPath(r.URL.Path) && isPublicWithShareToken(r)) || isPublicShareGraphRequest(r) {
// The authentication of public path requests is handled by another authenticator.
// Since we can't guarantee the order of execution of the authenticators, we better
// implement an early return here for paths we can't authenticate in this authenticator.
@@ -5,7 +5,9 @@ import (
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/opencloud-eu/opencloud/pkg/log"
ocmw "github.com/opencloud-eu/opencloud/pkg/middleware"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go.opentelemetry.io/otel/attribute"
@@ -14,7 +16,7 @@ import (
const (
headerRevaAccessToken = revactx.TokenHeader
headerShareToken = "public-token"
headerShareToken = ocmw.PublicLinkTokenName
basicAuthPasswordPrefix = "password|"
authenticationType = "publicshares"
@@ -55,12 +57,30 @@ func isPublicShareAppOpen(r *http.Request) bool {
// the BasicAuthenticator needs to ignore the request when the headerShareToken exist.
func isPublicWithShareToken(r *http.Request) bool {
return (strings.HasPrefix(r.URL.Path, "/dav/public-files") || strings.HasPrefix(r.URL.Path, "/remote.php/dav/public-files")) &&
(r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != "")
hasShareToken(r)
}
// A graph request carrying a share token runs in the public share context,
// like public-files.
func isPublicShareGraphRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/graph/") && hasShareToken(r)
}
func hasShareToken(r *http.Request) bool {
return r.URL.Query().Get(headerShareToken) != "" || r.Header.Get(headerShareToken) != ""
}
// shareTokenHint identifies a token in logs without spelling it out.
func shareTokenHint(token string) string {
if len(token) <= 4 {
return token
}
return token[:4] + "..."
}
// Authenticate implements the authenticator interface to authenticate requests via public share auth.
func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) {
if !isPublicPath(r.URL.Path) && !isPublicShareArchive(r) && !isPublicShareAppOpen(r) && !isPublicShareGraphRequest(r) {
return nil, false
}
@@ -99,7 +119,7 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request,
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("public_share_token", shareTokenHint(shareToken)).
Str("path", r.URL.Path).
Msg("could not select next gateway client")
return nil, false
@@ -115,12 +135,29 @@ func (a PublicShareAuthenticator) Authenticate(r *http.Request) (*http.Request,
a.Logger.Error().
Err(err).
Str("authenticator", "public_share").
Str("public_share_token", shareToken).
Str("public_share_token", shareTokenHint(shareToken)).
Str("path", r.URL.Path).
Msg("failed to authenticate request")
return nil, false
}
if authResp.GetStatus().GetCode() != rpc.Code_CODE_OK {
// A graph request cannot render its own 401 from here (no writer), and
// the generic one cannot tell the two password cases apart. Mark the
// outcome and let the graph auth middleware render it. Other surfaces
// (webdav) are handled by their own backend, so they just fail here.
if isPublicShareGraphRequest(r) {
_, password, ok := r.BasicAuth()
if ok && password != "" {
r.Header.Set(ocmw.PublicLinkAuthHeader, ocmw.PublicLinkInvalidPassword)
} else {
r.Header.Set(ocmw.PublicLinkAuthHeader, ocmw.PublicLinkPasswordRequired)
}
return r, true
}
return nil, false
}
r.Header.Add(headerRevaAccessToken, authResp.Token)
trace.SpanFromContext(r.Context()).SetAttributes(attribute.String("enduser.id", "public"))
+376
View File
@@ -3490,4 +3490,380 @@ class GraphContext implements Context {
$url = "/graph/$apiVersion/drives/$driveId/root:/$encoded";
$this->sendGraphRequestAndCaptureResponse($user, "GET", $url);
}
/**
* The virtual drive a public link is addressed under: the public storage
* provider/space id pair with the link token as opaque id.
*
* @param string $token
*
* @return string
*/
private function publicLinkDriveId(string $token): string {
$publicStorageId = "7993447f-687f-490d-875c-ac95e89a62a4";
return "$publicStorageId\$$publicStorageId!$token";
}
/**
* Send an anonymous Graph request in the context of the last created
* public link: token via the public-token query parameter, an optional
* password as basic auth for user "public".
*
* @param string $urlSuffix part below /graph/v1.0/drives/{publicDriveId}
* @param string|null $password
*
* @return void
*/
private function publicSendsGraphDriveRequest(string $urlSuffix, ?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$driveId = $this->publicLinkDriveId($token);
$url = $this->featureContext->getBaseUrl()
. "/graph/v1.0/drives/$driveId$urlSuffix"
. (\str_contains($urlSuffix, "?") ? "&" : "?") . "public-token=$token";
$response = HttpRequestHelper::get(
$url,
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password)
);
$this->featureContext->setResponse($response);
}
/**
* @param string|null $password
*
* @return void
*/
#[When('the public lists the children of the last created public link using the Graph API')]
#[When('the public lists the children of the last created public link with password :password using the Graph API')]
public function thePublicListsTheChildrenOfTheLastCreatedPublicLink(?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$this->publicSendsGraphDriveRequest("/items/$rootId/children", $password);
}
/**
* @param string $path
* @param string|null $password
*
* @return void
*/
#[When('the public lists the children of path :path of the last created public link using the Graph API')]
#[When('the public lists the children of path :path of the last created public link with password :password using the Graph API')]
public function thePublicListsTheChildrenOfPathOfTheLastCreatedPublicLink(
string $path,
?string $password = null
): void {
$encoded = $this->encodeColonPathSegment($path);
$this->publicSendsGraphDriveRequest("/root:/$encoded:/children", $password);
}
/**
* @param string|null $password
*
* @return void
*/
#[When('the public gets the root of the last created public link expanding its children using the Graph API')]
#[When('the public gets the root of the last created public link expanding its children with password :password using the Graph API')]
public function thePublicGetsTheRootOfTheLastCreatedPublicLinkExpandingItsChildren(?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$this->publicSendsGraphDriveRequest("/items/$rootId?\$expand=children", $password);
}
/**
* @param string $path
* @param string|null $password
*
* @return void
*/
#[When('the public gets the drive item of path :path of the last created public link selecting the allowed actions with password :password using the Graph API')]
public function thePublicGetsTheDriveItemOfPathSelectingTheAllowedActions(
string $path,
?string $password = null
): void {
$encoded = $this->encodeColonPathSegment($path);
$select = "%24select=%40libre.graph.permissions.actions.allowedValues";
$this->publicSendsGraphDriveRequest("/root:/$encoded?$select", $password);
}
/**
* @param string $urlSuffix raw suffix below /graph/v1.0, may contain :spaceOfUser
* @param string|null $password
*
* @return void
*/
private function publicSendsRawGraphRequest(string $urlSuffix, ?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$url = $this->featureContext->getBaseUrl() . "/graph/v1.0" . $urlSuffix
. (\str_contains($urlSuffix, "?") ? "&" : "?") . "public-token=$token";
$response = HttpRequestHelper::get(
$url,
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password)
);
$this->featureContext->setResponse($response);
}
/**
* @param string|null $password
*
* @return void
*/
#[When('the public lists the children of the last created public link selecting the share types with password :password using the Graph API')]
public function thePublicListsTheChildrenSelectingTheShareTypes(?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$select = "%24select=%40libre.graph.shareTypes";
$this->publicSendsGraphDriveRequest("/items/$rootId/children?$select", $password);
}
/**
* @param string|null $password
*
* @return void
*/
#[When('the public tries to list the drives using the token of the last created public link with password :password using the Graph API')]
public function thePublicTriesToListTheDrives(?string $password = null): void {
$this->publicSendsRawGraphRequest("/drives/", $password);
}
/**
* @param string $user
* @param string|null $password
*
* @return void
*/
#[When('the public tries to get the personal drive of user :user through the last created public link with password :password using the Graph API')]
public function thePublicTriesToGetThePersonalDriveOfUser(string $user, ?string $password = null): void {
$user = $this->featureContext->getActualUsername($user);
$driveId = $this->spacesContext->getSpaceIdByName($user, "Personal");
$this->publicSendsRawGraphRequest("/drives/$driveId", $password);
}
/**
* Addressing an in-share item through its REAL drive id must be rejected:
* only the token's public drive is authorized.
*
* @param string $child
* @param string|null $password
*
* @return void
*/
#[When('the public tries to get the child :child of the last created public link through its real drive id with password :password using the Graph API')]
public function thePublicTriesToGetTheChildThroughItsRealDriveId(string $child, ?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$url = $this->featureContext->getBaseUrl()
. "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token";
$listing = HttpRequestHelper::get(
$url,
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password)
);
$children = \json_decode($listing->getBody()->getContents(), true)["value"] ?? [];
$childId = null;
foreach ($children as $entry) {
if ($entry["name"] === $child) {
$childId = $entry["id"];
}
}
Assert::assertNotNull($childId, "child '$child' not found in the public link listing");
$realDriveId = \explode("!", $childId)[0];
$this->publicSendsRawGraphRequest("/drives/$realDriveId/items/$childId", $password);
}
/**
* @param string|null $password
*
* @return void
*/
#[When('the public tries to list the children of a foreign public link drive using the last created token with password :password using the Graph API')]
public function thePublicTriesToListAForeignPublicLinkDrive(?string $password = null): void {
$foreign = $this->publicLinkDriveId("notthetokenofthislink");
$this->publicSendsRawGraphRequest("/drives/$foreign/items/$foreign/children", $password);
}
/**
* Mutation probes: the public link surface is read only, every write on the
* beta routes has to be rejected.
*
* @param string $action
* @param string $child
* @param string|null $password
*
* @return void
*/
#[When('/^the public tries to (create a link for|delete|rename|list the permissions of) the child "([^"]*)" of the last created public link with password "([^"]*)" using the Graph API$/')]
public function thePublicTriesToMutateTheChildOfTheLastCreatedPublicLink(
string $action,
string $child,
?string $password = null
): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$listing = HttpRequestHelper::get(
$this->featureContext->getBaseUrl()
. "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token",
$this->featureContext->getStepLineRef(),
"public",
$this->featureContext->getActualPassword($password)
);
$children = \json_decode($listing->getBody()->getContents(), true)["value"] ?? [];
$childId = null;
foreach ($children as $entry) {
if ($entry["name"] === $child) {
$childId = $entry["id"];
}
}
Assert::assertNotNull($childId, "child '$child' not found in the public link listing");
$base = "/graph/v1beta1/drives/$rootId/items/$childId";
switch ($action) {
case "create a link for":
$method = "POST";
$url = "$base/createLink";
$body = \json_encode(["type" => "view", "password" => "Sup3rS3cret!x"]);
break;
case "delete":
$method = "DELETE";
$url = $base;
$body = null;
break;
case "rename":
$method = "PATCH";
$url = $base;
$body = \json_encode(["name" => "renamed.txt"]);
break;
case "list the permissions of":
$method = "GET";
$url = "$base/permissions";
$body = null;
break;
default:
throw new \Exception("unknown mutation action '$action'");
}
$response = HttpRequestHelper::sendRequest(
$this->featureContext->getBaseUrl() . $url
. (\str_contains($url, "?") ? "&" : "?") . "public-token=$token",
$this->featureContext->getStepLineRef(),
$method,
"public",
$this->featureContext->getActualPassword($password),
["Content-Type" => "application/json"],
$body
);
$this->featureContext->setResponse($response);
}
/**
* The public link drive endpoint rejects the token as a query parameter,
* so it rides in the header here.
*
* @param string|null $password
*
* @return void
*/
#[When('the public gets the drive of the last created public link with password :password using the Graph API')]
public function thePublicGetsTheDriveOfTheLastCreatedPublicLink(?string $password = null): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$driveId = $this->publicLinkDriveId($token);
$response = HttpRequestHelper::get(
$this->featureContext->getBaseUrl() . "/graph/v1.0/drives/$driveId",
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password),
["public-token" => $token]
);
$this->featureContext->setResponse($response);
}
/**
* Item anchored colon path: the anchor id is resolved through the public
* children listing, so the step stays within the public API.
*
* @param string $path
* @param string $child
* @param string|null $password
*
* @return void
*/
#[When('the public gets the drive item :path below the child :child of the last created public link with password :password using the Graph API')]
public function thePublicGetsTheDriveItemBelowTheChildOfTheLastCreatedPublicLink(
string $path,
string $child,
?string $password = null
): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$url = $this->featureContext->getBaseUrl()
. "/graph/v1.0/drives/$rootId/items/$rootId/children?public-token=$token";
$response = HttpRequestHelper::get(
$url,
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password)
);
$children = \json_decode($response->getBody()->getContents(), true)["value"] ?? [];
$childId = null;
foreach ($children as $entry) {
if ($entry["name"] === $child) {
$childId = $entry["id"];
}
}
Assert::assertNotNull($childId, "child '$child' not found in the public link listing");
$encoded = $this->encodeColonPathSegment($path);
$this->publicSendsGraphDriveRequest("/items/$childId:/$encoded", $password);
}
/**
* @param string $name
* @param string|null $password
*
* @return void
*/
#[When('the public creates an upload session for :name in the last created public link using the Graph API')]
#[When('the public creates an upload session for :name in the last created public link with password :password using the Graph API')]
public function thePublicCreatesAnUploadSessionForInTheLastCreatedPublicLink(
string $name,
?string $password = null
): void {
$token = $this->featureContext->shareNgGetLastCreatedLinkShareToken();
$rootId = $this->publicLinkDriveId($token);
$url = $this->featureContext->getBaseUrl()
. "/graph/v1.0/drives/$rootId/items/$rootId/createUploadSession?public-token=$token";
$response = HttpRequestHelper::post(
$url,
$this->featureContext->getStepLineRef(),
$password === null ? null : "public",
$this->featureContext->getActualPassword($password),
["Content-Type" => "application/json"],
\json_encode(["item" => ["name" => $name, "fileSize" => 6]])
);
$this->featureContext->setResponse($response);
}
/**
* The security probe: an id of a resource that is NOT inside the public
* link must not be readable through the link's token.
*
* @param string $path
* @param string $user
* @param string|null $password
*
* @return void
*/
#[When('the public tries to get the resource :path of user :user through the last created public link using the Graph API')]
#[When('the public tries to get the resource :path of user :user through the last created public link with password :password using the Graph API')]
public function thePublicTriesToGetTheResourceOfUserThroughTheLastCreatedPublicLink(
string $path,
string $user,
?string $password = null
): void {
$user = $this->featureContext->getActualUsername($user);
$resourceId = $this->featureContext->getFileIdForPath($user, $path);
$this->publicSendsGraphDriveRequest("/items/$resourceId", $password);
}
}
@@ -0,0 +1,365 @@
Feature: listing the content of a public link via the Graph API
As an anonymous visitor of a public link
I want to list the shared folder through the Graph API
So that clients can browse public links without WebDAV
Background:
Given user "Alice" has been created with default attributes
And user "Alice" has created folder "publicfolder"
And user "Alice" has created folder "publicfolder/sub"
And user "Alice" has uploaded file with content "hello public" to "publicfolder/a.txt"
And user "Alice" has uploaded file with content "nested" to "publicfolder/sub/b.txt"
And user "Alice" has uploaded file with content "not shared" to "private.txt"
And user "Alice" has created the following resource link share:
| resource | publicfolder |
| space | Personal |
| permissionsRole | view |
| password | %public% |
Scenario: the public lists the children of a public link
When the public lists the children of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["value"],
"properties": {
"value": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"uniqueItems": true,
"items": {
"oneOf": [
{
"type": "object",
"required": ["name", "folder"],
"properties": {
"name": { "const": "sub" }
}
},
{
"type": "object",
"required": ["name", "file", "size"],
"properties": {
"name": { "const": "a.txt" },
"size": { "const": 12 }
}
}
]
}
}
}
}
"""
Scenario: the public lists a subfolder of a public link by path
When the public lists the children of path "sub" of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["value"],
"properties": {
"value": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"required": ["name", "file"],
"properties": {
"name": { "const": "b.txt" }
}
}
}
}
}
"""
Scenario: the public expands the children of the public link root
When the public gets the root of the last created public link expanding its children with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["folder", "children"],
"properties": {
"children": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"uniqueItems": true,
"items": {
"type": "object",
"required": ["name"]
}
}
}
}
"""
Scenario: the public gets a file through an item anchored colon path
When the public gets the drive item "b.txt" below the child "sub" of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["name", "file", "parentReference"],
"properties": {
"name": { "const": "b.txt" },
"parentReference": {
"type": "object",
"required": ["path"],
"properties": {
"path": { "const": "/sub" }
}
}
}
}
"""
Scenario: the public must not see the owner's paths above the share
Given user "Alice" has created folder "deep"
And user "Alice" has created folder "deep/shared"
And user "Alice" has uploaded file with content "x" to "deep/shared/c.txt"
And user "Alice" has created the following resource link share:
| resource | deep/shared |
| space | Personal |
| permissionsRole | view |
| password | %public% |
When the public lists the children of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["value"],
"properties": {
"value": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"parentReference": {
"type": "object",
"properties": {
"path": {
"not": { "pattern": "deep" }
}
}
}
}
}
}
}
}
"""
Scenario: advertised permissions inside a public link stay within the link role
When the public gets the drive item of path "sub" of the last created public link selecting the allowed actions with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["@libre.graph.permissions.actions.allowedValues"],
"properties": {
"@libre.graph.permissions.actions.allowedValues": {
"type": "array",
"minItems": 6,
"maxItems": 6,
"uniqueItems": true,
"items": {
"type": "string",
"not": { "pattern": "/(delete|create|update|deny)$" }
}
}
}
}
"""
Scenario: listing a password protected public link without the password reports that a password is required
When the public lists the children of the last created public link using the Graph API
Then the HTTP status code should be "401"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code"],
"properties": {
"code": { "const": "publicLinkPasswordRequired" }
}
}
}
}
"""
Scenario: listing a password protected public link with a wrong password reports an invalid password
When the public lists the children of the last created public link with password "wrong" using the Graph API
Then the HTTP status code should be "401"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code"],
"properties": {
"code": { "const": "publicLinkPasswordInvalid" }
}
}
}
}
"""
Scenario: an editable public link grants an upload session
Given user "Alice" has created the following resource link share:
| resource | publicfolder |
| space | Personal |
| permissionsRole | edit |
| password | %public% |
When the public creates an upload session for "up.txt" in the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["UploadURL"],
"properties": {
"UploadURL": {
"type": "string",
"pattern": "/data/"
}
}
}
"""
Scenario: a view only public link does not grant an upload session
When the public creates an upload session for "up.txt" in the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "404"
Scenario: a resource outside the public link is not readable through its token
When the public tries to get the resource "private.txt" of user "Alice" through the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "404"
Scenario: a public token cannot list drives
When the public tries to list the drives using the token of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "401"
Scenario: a public token cannot read the owner's personal drive
When the public tries to get the personal drive of user "Alice" through the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "401"
Scenario: an in-share item is not addressable through its real drive id
When the public tries to get the child "sub" of the last created public link through its real drive id with password "%public%" using the Graph API
Then the HTTP status code should be "401"
Scenario: a token does not open another link's drive
When the public tries to list the children of a foreign public link drive using the last created token with password "%public%" using the Graph API
Then the HTTP status code should be "401"
Scenario: collaborative share types are not disclosed to the public
Given user "Brian" has been created with default attributes
And user "Alice" has sent the following resource share invitation:
| resource | publicfolder/sub |
| space | Personal |
| sharee | Brian |
| shareType | user |
| permissionsRole | Viewer |
When the public lists the children of the last created public link selecting the share types with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["value"],
"properties": {
"value": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"uniqueItems": true,
"items": {
"type": "object",
"not": { "required": ["@libre.graph.shareTypes"] }
}
}
}
}
"""
Scenario Outline: writes through the public link surface are rejected
Given user "Alice" has created the following resource link share:
| resource | publicfolder |
| space | Personal |
| permissionsRole | edit |
| password | %public% |
When the public tries to <action> the child "a.txt" of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "<code>"
And as "Alice" file "publicfolder/a.txt" should exist
Examples:
| action | code |
| create a link for | 404 |
| delete | 400 |
| rename | 400 |
| list the permissions of | 404 |
Scenario: the public sees who shared the link on the drive
When the public gets the drive of the last created public link with password "%public%" using the Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
{
"type": "object",
"required": ["driveType", "owner"],
"properties": {
"driveType": { "const": "mountpoint" },
"owner": {
"type": "object",
"required": ["user"],
"properties": {
"user": {
"type": "object",
"required": ["id", "displayName"],
"properties": {
"displayName": { "const": "Alice Hansen" }
}
}
}
}
}
}
"""
@@ -23,7 +23,6 @@ package publicstorageprovider
import (
"context"
"encoding/json"
"path"
"strings"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
@@ -37,6 +36,7 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/appctx"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/errtypes"
"github.com/opencloud-eu/reva/v2/pkg/publicshare"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
@@ -769,15 +769,7 @@ func (s *service) augmentStatResponse(ctx context.Context, statInfo *provider.Re
appctx.GetLogger(ctx).Error().Err(err).Interface("share", share).Interface("info", statInfo).Msg("error when adding share")
}
var sharePath string
if shareInfo.Type == provider.ResourceType_RESOURCE_TYPE_FILE {
sharePath = path.Base(shareInfo.Path)
} else {
sharePath = strings.TrimPrefix(statInfo.Path, shareInfo.Path)
}
statInfo.Path = path.Join("/", sharePath)
filterPermissions(statInfo.PermissionSet, shareInfo.PermissionSet)
publicshare.FilterResourceInfo(statInfo, shareInfo, shareInfo.GetPermissionSet())
}
}
@@ -843,7 +835,7 @@ func (s *service) ListContainer(ctx context.Context, req *provider.ListContainer
for i := range listContainerR.Infos {
// FIXME how do we reduce permissions to what is granted by the public link?
// only a problem for id based access -> middleware
filterPermissions(listContainerR.Infos[i].PermissionSet, info.PermissionSet)
publicshare.FilterPermissions(listContainerR.Infos[i].PermissionSet, info.PermissionSet)
if err := addShare(listContainerR.Infos[i], share); err != nil {
appctx.GetLogger(ctx).Error().Err(err).Interface("share", share).Interface("info", listContainerR.Infos[i]).Msg("error when adding share")
}
@@ -852,27 +844,6 @@ func (s *service) ListContainer(ctx context.Context, req *provider.ListContainer
return listContainerR, nil
}
func filterPermissions(l *provider.ResourcePermissions, r *provider.ResourcePermissions) {
l.AddGrant = l.AddGrant && r.AddGrant
l.CreateContainer = l.CreateContainer && r.CreateContainer
l.Delete = l.Delete && r.Delete
l.GetPath = l.GetPath && r.GetPath
l.GetQuota = l.GetQuota && r.GetQuota
l.InitiateFileDownload = l.InitiateFileDownload && r.InitiateFileDownload
l.InitiateFileUpload = l.InitiateFileUpload && r.InitiateFileUpload
l.ListContainer = l.ListContainer && r.ListContainer
l.ListFileVersions = l.ListFileVersions && r.ListFileVersions
l.ListGrants = l.ListGrants && r.ListGrants
l.ListRecycle = l.ListRecycle && r.ListRecycle
l.Move = l.Move && r.Move
l.PurgeRecycle = l.PurgeRecycle && r.PurgeRecycle
l.RemoveGrant = l.RemoveGrant && r.RemoveGrant
l.RestoreFileVersion = l.RestoreFileVersion && r.RestoreFileVersion
l.RestoreRecycleItem = l.RestoreRecycleItem && r.RestoreRecycleItem
l.Stat = l.Stat && r.Stat
l.UpdateGrant = l.UpdateGrant && r.UpdateGrant
}
func (s *service) ListFileVersions(ctx context.Context, req *provider.ListFileVersionsRequest) (*provider.ListFileVersionsResponse, error) {
return nil, gstatus.Errorf(codes.Unimplemented, "method not implemented")
}
+19 -1
View File
@@ -20,6 +20,7 @@ package scope
import (
"context"
"path"
"strings"
appprovider "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1"
@@ -143,7 +144,7 @@ func publicshareScope(ctx context.Context, scope *authpb.Scope, resource interfa
// public links must not leak info about collaborative shares
return false, nil
case string:
return checkResourcePath(v), nil
return checkResourcePath(v) || checkGraphDrivesPath(v, share.Token), nil
}
msg := "public resource type assertion failed"
@@ -151,6 +152,23 @@ func publicshareScope(ctx context.Context, scope *authpb.Scope, resource interfa
return false, errtypes.InternalError(msg)
}
// checkGraphDrivesPath opens the graph drive routes of exactly the link's own
// public drive; every other drive stays closed, notably the drives collection
// and real space ids. Per CS3 request checks enforce what may be read below it.
func checkGraphDrivesPath(p, token string) bool {
if token == "" {
return false
}
p = path.Clean(p)
drive := PublicStorageProviderID + "$" + PublicStorageProviderID + "!" + token
for _, prefix := range []string{"/graph/v1.0/drives/", "/graph/v1beta1/drives/"} {
if p == prefix+drive || strings.HasPrefix(p, prefix+drive+"/") {
return true
}
}
return false
}
func checkStorageRef(ctx context.Context, s *link.PublicShare, r *provider.Reference) bool {
// r: <resource_id:<storage_id:$storageID space_id:$spaceID opaque_id:$opaqueID> path:$path > >
if utils.ResourceIDEqual(s.ResourceId, r.GetResourceId()) {
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.
package publicshare
import (
"path"
"strings"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
)
// FilterResourceInfo rewrites a resource info for a public link consumer: the
// path becomes share-root relative, the permissions are cut to the grant.
func FilterResourceInfo(info, shareRoot *provider.ResourceInfo, grant *provider.ResourcePermissions) {
if info == nil {
return
}
var sharePath string
if shareRoot.GetType() == provider.ResourceType_RESOURCE_TYPE_FILE {
sharePath = path.Base(shareRoot.GetPath())
} else {
sharePath = strings.TrimPrefix(info.GetPath(), shareRoot.GetPath())
}
info.Path = path.Join("/", sharePath)
if info.PermissionSet != nil {
FilterPermissions(info.PermissionSet, grant)
}
}
// FilterPermissions reduces l to what r also grants. A nil r clears l.
func FilterPermissions(l, r *provider.ResourcePermissions) {
l.AddGrant = l.AddGrant && r.GetAddGrant()
l.CreateContainer = l.CreateContainer && r.GetCreateContainer()
l.Delete = l.Delete && r.GetDelete()
l.DenyGrant = l.DenyGrant && r.GetDenyGrant()
l.GetPath = l.GetPath && r.GetGetPath()
l.GetQuota = l.GetQuota && r.GetGetQuota()
l.InitiateFileDownload = l.InitiateFileDownload && r.GetInitiateFileDownload()
l.InitiateFileUpload = l.InitiateFileUpload && r.GetInitiateFileUpload()
l.ListContainer = l.ListContainer && r.GetListContainer()
l.ListFileVersions = l.ListFileVersions && r.GetListFileVersions()
l.ListGrants = l.ListGrants && r.GetListGrants()
l.ListRecycle = l.ListRecycle && r.GetListRecycle()
l.Move = l.Move && r.GetMove()
l.PurgeRecycle = l.PurgeRecycle && r.GetPurgeRecycle()
l.RemoveGrant = l.RemoveGrant && r.GetRemoveGrant()
l.RestoreFileVersion = l.RestoreFileVersion && r.GetRestoreFileVersion()
l.RestoreRecycleItem = l.RestoreRecycleItem && r.GetRestoreRecycleItem()
l.Stat = l.Stat && r.GetStat()
l.UpdateGrant = l.UpdateGrant && r.GetUpdateGrant()
}