mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-08 19:58:46 -04:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359dd2f267 | ||
|
|
b392f43662 | ||
|
|
1e6d2ec168 | ||
|
|
d9613a068f | ||
|
|
6727cec526 | ||
|
|
6d09a83adc | ||
|
|
a0e1d7cbab | ||
|
|
f70d2c45b5 | ||
|
|
56bee0a339 | ||
|
|
bcfccc4dd2 | ||
|
|
5b260877dc | ||
|
|
84f9621659 | ||
|
|
8733428aed | ||
|
|
a9822a7e24 | ||
|
|
f640858427 | ||
|
|
1e29eb9504 | ||
|
|
da421b6747 | ||
|
|
6eb235ab1c | ||
|
|
adb7327779 | ||
|
|
af999345a6 | ||
|
|
b045b2be86 | ||
|
|
d18c1a1edb | ||
|
|
aa03c6c742 | ||
|
|
1d65ce7052 | ||
|
|
40f7aaa327 | ||
|
|
46a7dbfb47 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
Generated
Vendored
+3
-32
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user