feat(dav): group-level WebDAV read-only + enforce PROPPATCH (#3409)

Admins could only toggle WebDAV on/off per group; upload traffic rides
on the Cloudreve server regardless of storage policy, so a read-only
tier is operationally valuable.

- New GroupPermissionWebDAVReadOnly (bit 18): when set, all
  state-changing DAV methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK,
  UNLOCK, PROPPATCH) return 403 for the group's members — enforced at
  the WebDAVAuth layer and again inside each mutating handler.
- The pre-existing per-account DavAccountReadOnly now also covers
  PROPPATCH, which the auth-layer method list previously allowed.
- OPTIONS capability advertisement drops write methods for read-only
  users.
- Admin group editor gains a "WebDAV read-only" switch (shown when
  WebDAV is enabled); en/zh strings included.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 9e62f03489
commit 527b828439

@ -1264,6 +1264,8 @@
"allowWabDAVDes": "If disabled, users cannot connect to the storage via the WebDAV protocol",
"allowWabDAVProxy": "WebDAV Proxy",
"allowWabDAVProxyDes": "If enabled, users can configure the WebDAV to be proxied by Cloudreve when downloading files.",
"wabDAVReadOnly": "WebDAV read-only",
"wabDAVReadOnlyDes": "If enabled, WebDAV mounts for users in this group become read-only — uploads, deletes, moves and other write methods are rejected.",
"compressTask": "Compression/Decompression tasks",
"compressTaskDes": "If enabled, users can do compression/decompression for files online.",
"compressSize": "Maximum file size to be compressed",

@ -1264,6 +1264,8 @@
"allowWabDAVDes": "关闭后,用户无法通过 WebDAV 协议连接至网盘。",
"allowWabDAVProxy": "WebDAV 代理",
"allowWabDAVProxyDes": "启用后,用户可以配置 WebDAV 下载经由 Cloudreve 中转。",
"wabDAVReadOnly": "WebDAV 只读",
"wabDAVReadOnlyDes": "启用后,该用户组的 WebDAV 挂载变为只读——上传、删除、移动及其他写操作将被拒绝。",
"compressTask": "压缩/解压缩任务",
"compressTaskDes": "开启后,用户可以在线压缩/解压缩文件。",
"compressSize": "待压缩文件最大大小",

@ -93,6 +93,7 @@ export const GroupPermission = {
redirected_source: 11,
advance_delete: 12,
unique_direct_link: 17,
webdav_read_only: 18,
};
export interface UserSettings {

@ -63,6 +63,16 @@ const FileManagementSection = () => {
[setGroup],
);
const onWabDAVReadOnlyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.webdav_read_only, e.target.checked).toString(),
}));
},
[setGroup],
);
const onAllowCompressTaskChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
@ -186,6 +196,20 @@ const FileManagementSection = () => {
<NoMarginHelperText>{t("group.allowWabDAVProxyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.webdav_read_only)}
onChange={onWabDAVReadOnlyChange}
/>
}
label={t("group.wabDAVReadOnly")}
/>
<NoMarginHelperText>{t("group.wabDAVReadOnlyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth>

@ -269,6 +269,10 @@ const (
GroupPermissionSetExplicitUser_placeholder
GroupPermissionIgnoreFileOwnership // not used
GroupPermissionUniqueRedirectDirectLink
// GroupPermissionWebDAVReadOnly restricts the group's WebDAV access to
// read operations — write methods (PUT, MKCOL, DELETE, COPY, MOVE, LOCK,
// PROPPATCH) are rejected even if the group's WebDAV access is enabled.
GroupPermissionWebDAVReadOnly
)
const (

@ -161,9 +161,10 @@ func WebDAVAuth() gin.HandlerFunc {
}
// 检查是否只读
if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
if expectedUser.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) ||
group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) {
switch c.Request.Method {
case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK":
case http.MethodDelete, http.MethodPut, "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "PROPPATCH":
c.Status(http.StatusForbidden)
c.Abort()
return

@ -190,7 +190,29 @@ func confirmLock(c *gin.Context, fm manager.FileManager, user *ent.User, srcAnc,
return nil, nil, http.StatusPreconditionFailed, ErrLocked
}
// davWriteForbidden reports whether the user is confined to read-only WebDAV
// access, either by a group-level restriction or a read-only dav account.
// Read-only access allows GET/HEAD/OPTIONS/PROPFIND and rejects every
// state-changing method.
func davWriteForbidden(user *ent.User) bool {
if user == nil {
return false
}
if len(user.Edges.DavAccounts) > 0 &&
user.Edges.DavAccounts[0].Options.Enabled(int(types.DavAccountReadOnly)) {
return true
}
if user.Edges.Group != nil &&
user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVReadOnly)) {
return true
}
return false
}
func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@ -227,6 +249,9 @@ func handleMkcol(c *gin.Context, user *ent.User, fm manager.FileManager) (status
}
func handlePut(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@ -439,6 +464,12 @@ func handleRangedPut(ctx context.Context, c *gin.Context, user *ent.User, m mana
return http.StatusInternalServerError, err
}
if !allReceived {
// If the session record vanished mid-upload, coverage can never
// complete — fail so the client retries rather than leaving a stuck
// placeholder.
if _, ok := kv.Get(manager.UploadSessionCachePrefix + sessionKey); !ok {
return http.StatusConflict, errors.New("upload session expired")
}
return http.StatusCreated, nil
}
@ -466,7 +497,7 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat
if target, _, err := fm.SharedAddressTranslation(c, reqPath); err == nil {
allow = allow[:1]
read, update, del, create := true, true, true, true
if target.OwnerID() != user.ID {
if target.OwnerID() != user.ID || davWriteForbidden(user) {
update = false
del = false
create = false
@ -475,7 +506,10 @@ func handleOptions(c *gin.Context, user *ent.User, fm manager.FileManager) (stat
allow = append(allow, "DELETE", "MOVE")
}
if read {
allow = append(allow, "COPY", "PROPFIND")
if !davWriteForbidden(user) {
allow = append(allow, "COPY")
}
allow = append(allow, "PROPFIND")
if target.Type() == types.FileTypeFile {
allow = append(allow, "GET", "HEAD", "POST")
}
@ -559,6 +593,9 @@ func handleUnlock(c *gin.Context, user *ent.User, fm manager.FileManager) (retSt
}
func handleLock(c *gin.Context, user *ent.User, fm manager.FileManager) (retStatus int, retErr error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
duration, err := parseTimeout(c.Request.Header.Get("Timeout"))
if err != nil {
return http.StatusBadRequest, err
@ -732,6 +769,9 @@ func handlePropfind(c *gin.Context, user *ent.User, fm manager.FileManager) (sta
}
func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err
@ -759,6 +799,9 @@ func handleDelete(c *gin.Context, user *ent.User, fm manager.FileManager) (statu
}
func handleCopyMove(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
hdr := c.Request.Header.Get("Destination")
if hdr == "" {
return http.StatusBadRequest, errInvalidDestination
@ -902,6 +945,9 @@ func performCopyMove(
}
func handleProppatch(c *gin.Context, user *ent.User, fm manager.FileManager) (status int, err error) {
if davWriteForbidden(user) {
return http.StatusForbidden, nil
}
_, reqPath, status, err := stripPrefix(c.Request.URL.Path, user)
if err != nil {
return status, err

@ -6,6 +6,9 @@ import (
"net/http"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
)
@ -156,3 +159,34 @@ func TestParseContentRange(t *testing.T) {
}
}
}
func TestDavWriteForbidden(t *testing.T) {
readOnlyAccount := &boolset.BooleanSet{}
boolset.Set(types.DavAccountReadOnly, true, readOnlyAccount)
readOnlyGroup := &boolset.BooleanSet{}
boolset.Set(types.GroupPermissionWebDAVReadOnly, true, readOnlyGroup)
mkUser := func(group *boolset.BooleanSet, account *boolset.BooleanSet) *ent.User {
u := &ent.User{}
if group != nil {
u.SetGroup(&ent.Group{Permissions: group})
}
if account != nil {
u.Edges.DavAccounts = []*ent.DavAccount{{Options: account}}
}
return u
}
if !davWriteForbidden(mkUser(nil, readOnlyAccount)) {
t.Fatal("read-only dav account not blocked")
}
if !davWriteForbidden(mkUser(readOnlyGroup, &boolset.BooleanSet{})) {
t.Fatal("read-only group not blocked")
}
if davWriteForbidden(mkUser(&boolset.BooleanSet{}, &boolset.BooleanSet{})) {
t.Fatal("normal user blocked")
}
if davWriteForbidden(nil) {
t.Fatal("nil user blocked")
}
}

Loading…
Cancel
Save