mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-21 19:10:42 +08:00
A COPY or MOVE onto an existing destination replaces it, which destroys everything the destination collection held. Only the source subtree was authorized per descendant, and the destination itself was authorized as an update, so a rule restricting a subtree held against DELETE but not against an overwrite of the collection above it. Removing a collection is now authorized the way DELETE on it would be, on the collection and on every path beneath it. Writing over a file stays an update.
301 lines
9.0 KiB
Go
301 lines
9.0 KiB
Go
package lib
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/rs/cors"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/net/webdav"
|
|
)
|
|
|
|
type handlerUser struct {
|
|
User
|
|
handler webdav.Handler
|
|
fs permissionsFS
|
|
}
|
|
|
|
type Handler struct {
|
|
noPassword bool
|
|
behindProxy bool
|
|
user *handlerUser
|
|
users map[string]*handlerUser
|
|
}
|
|
|
|
func NewHandler(c *Config) (http.Handler, error) {
|
|
ls := webdav.NewMemLS()
|
|
|
|
logFunc := func(r *http.Request, err error) {
|
|
lZap := getRequestLogger(r, c.BehindProxy)
|
|
lZap.Debug("handle webdav request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
|
|
}
|
|
|
|
h := &Handler{
|
|
noPassword: c.NoPassword,
|
|
behindProxy: c.BehindProxy,
|
|
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
|
|
users: map[string]*handlerUser{},
|
|
}
|
|
|
|
for _, u := range c.Users {
|
|
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
|
|
}
|
|
|
|
if c.CORS.Enabled {
|
|
return cors.New(cors.Options{
|
|
AllowCredentials: c.CORS.Credentials,
|
|
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
|
|
AllowedOrigins: c.CORS.AllowedHosts,
|
|
AllowedMethods: c.CORS.AllowedMethods,
|
|
AllowedHeaders: c.CORS.AllowedHeaders,
|
|
ExposedHeaders: c.CORS.ExposedHeaders,
|
|
OptionsPassthrough: false,
|
|
}).Handler(h), nil
|
|
}
|
|
|
|
if len(c.Users) == 0 {
|
|
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
|
|
}
|
|
|
|
if c.NoPassword {
|
|
zap.L().Warn("unprotected config: password check is disabled, only intended when delegating authentication to another service")
|
|
}
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// newHandlerUser prepares a user for serving, keeping the unwrapped file system
|
|
// alongside the handler.
|
|
func newHandlerUser(u User, c *Config, ls webdav.LockSystem, logFunc func(*http.Request, error)) *handlerUser {
|
|
fs := permissionsFS{fs: buildFileSystem(u.UserPermissions, c.NoSniff), perms: u.UserPermissions}
|
|
|
|
return &handlerUser{
|
|
User: u,
|
|
handler: buildWebdavHandler(u.UserPermissions, fs, c.Prefix, ls, logFunc),
|
|
fs: fs,
|
|
}
|
|
}
|
|
|
|
// buildFileSystem creates the unfiltered [webdav.FileSystem] for a set of user
|
|
// permissions, selecting between single-directory and multi-directory backing
|
|
// depending on whether directories are configured.
|
|
func buildFileSystem(p UserPermissions, noSniff bool) webdav.FileSystem {
|
|
if p.useDirectories {
|
|
return multiDir{
|
|
mounts: p.Directories,
|
|
noSniff: noSniff,
|
|
}
|
|
}
|
|
|
|
return Dir{
|
|
Dir: webdav.Dir(p.Directory),
|
|
noSniff: noSniff,
|
|
}
|
|
}
|
|
|
|
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions.
|
|
func buildWebdavHandler(p UserPermissions, fs permissionsFS, prefix string, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
|
h := webdav.Handler{
|
|
Prefix: prefix,
|
|
Logger: logFunc,
|
|
FileSystem: fs,
|
|
}
|
|
|
|
if p.useDirectories {
|
|
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
|
} else {
|
|
h.LockSystem = newLockSystem(ls, p.Directory)
|
|
}
|
|
|
|
return h
|
|
}
|
|
|
|
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := h.user
|
|
|
|
lZap := getRequestLogger(r, h.behindProxy)
|
|
|
|
// Authentication
|
|
if len(h.users) > 0 {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
|
|
|
// Gets the correct user for this request.
|
|
username, password, ok := r.BasicAuth()
|
|
if !ok {
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
user, ok = h.users[username]
|
|
if !ok {
|
|
// Log invalid username
|
|
lZap.Info("invalid username", zap.String("username", username))
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !h.noPassword && !user.checkPassword(password) {
|
|
// Log invalid password
|
|
lZap.Info("invalid password", zap.String("username", username))
|
|
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Log successful authorization
|
|
lZap.Info("user authorized", zap.String("username", username))
|
|
}
|
|
|
|
// Convert the HTTP request into an internal request type
|
|
req, err := newRequest(r, h.user.handler.Prefix)
|
|
if err != nil {
|
|
lZap.Info("invalid request path or destination", zap.Error(err))
|
|
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fileExists := func(filename string) bool {
|
|
_, err := user.fs.Stat(r.Context(), filename)
|
|
return !os.IsNotExist(err)
|
|
}
|
|
|
|
// Checks for user permissions relatively to this PATH.
|
|
allowed := user.Allowed(req, fileExists)
|
|
|
|
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
|
|
|
if !allowed {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// MOVE and DELETE act on a whole source subtree in one call, so every
|
|
// descendant needs authorizing here. Reading COPY and PROPFIND out of the
|
|
// source goes through permFS instead.
|
|
if r.Method == "MOVE" || r.Method == "DELETE" {
|
|
ok, err := user.fs.allowedThroughout(r.Context(), req.path, func(p Permissions) bool {
|
|
return p.Allowed(req, fileExists)
|
|
})
|
|
if err != nil {
|
|
lZap.Error("could not authorize subtree", zap.String("path", req.path), zap.Error(err))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if !ok {
|
|
lZap.Info("denied by a rule on a descendant", zap.String("method", r.Method), zap.String("path", req.path))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Excerpt from RFC4918, section 9.9.3, on MOVE:
|
|
//
|
|
// If a resource exists at the destination and the Overwrite header is
|
|
// "T", then prior to performing the move, the server MUST perform a
|
|
// DELETE with "Depth: infinity" on the destination resource.
|
|
//
|
|
// And from section 9.8.4, on COPY:
|
|
//
|
|
// When a collection is overwritten, the membership of the destination
|
|
// collection after the successful COPY request MUST be the same
|
|
// membership as the source collection immediately before the COPY.
|
|
//
|
|
// Either way whatever the destination collection held is gone, which
|
|
// golang.org/x/net/webdav carries out as a RemoveAll before the rename or
|
|
// the copy. Writing over a file is an update, already authorized by Allowed,
|
|
// but removing a collection takes everything under it. Nothing writes to
|
|
// those descendants, they are only destroyed, so authorize the destination
|
|
// the way DELETE on that collection would be.
|
|
if (r.Method == "MOVE" || r.Method == "COPY") && req.destination != "" {
|
|
info, err := user.fs.Stat(r.Context(), req.destination)
|
|
if err == nil && info.IsDir() {
|
|
deletable := func(p Permissions) bool { return p.Delete }
|
|
|
|
// allowedThroughout reaches descendants only, and the destination
|
|
// check in Allowed covers the collection itself as an update rather
|
|
// than a delete, so that is checked here.
|
|
ok := user.allowedAt(req.destination, deletable)
|
|
if ok {
|
|
ok, err = user.fs.allowedThroughout(r.Context(), req.destination, deletable)
|
|
if err != nil {
|
|
lZap.Error("could not authorize destination subtree", zap.String("destination", req.destination), zap.Error(err))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !ok {
|
|
lZap.Info("denied by a rule on the destination collection", zap.String("method", r.Method), zap.String("destination", req.destination))
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.Method == "HEAD" {
|
|
w = responseWriterNoBody{w}
|
|
}
|
|
|
|
// Excerpt from RFC4918, section 9.4:
|
|
//
|
|
// GET, when applied to a collection, may return the contents of an
|
|
// "index.html" resource, a human-readable view of the contents of
|
|
// the collection, or something else altogether.
|
|
//
|
|
// Similarly, since the definition of HEAD is a GET without a response
|
|
// message body, the semantics of HEAD are unmodified when applied to
|
|
// collection resources.
|
|
//
|
|
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
|
if r.Method == "GET" || r.Method == "HEAD" {
|
|
info, err := user.fs.Stat(r.Context(), req.path)
|
|
if err == nil && info.IsDir() {
|
|
r.Method = "PROPFIND"
|
|
|
|
if r.Header.Get("Depth") == "" {
|
|
r.Header.Add("Depth", "1")
|
|
}
|
|
}
|
|
}
|
|
|
|
if r.Method == "OPTIONS" {
|
|
user.handleOptions(w, r, req.path)
|
|
return
|
|
}
|
|
|
|
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
|
|
user.handlePartialUpdate(w, r, req.path)
|
|
return
|
|
}
|
|
|
|
// Runs the WebDAV.
|
|
user.handler.ServeHTTP(w, r)
|
|
}
|
|
|
|
// getRequestLogger creates a zap.Logger using the request remote ip.
|
|
func getRequestLogger(r *http.Request, behindProxy bool) *zap.Logger {
|
|
// Retrieve the real client IP address using the updated helper function
|
|
remoteAddr := getRealRemoteIP(r, behindProxy)
|
|
|
|
return zap.L().With(zap.String("remote_address", remoteAddr))
|
|
}
|
|
|
|
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
|
|
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
|
|
if behindProxy {
|
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
type responseWriterNoBody struct {
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
|
return len(data), nil
|
|
}
|