diff --git a/README.md b/README.md index e2a28e0..dfb1881 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,10 @@ debug: false # Default is '.' (current directory). directory: . -# The default modification permissions for users. Default is 'false'. -modify: true +# The default permissions for users. This is a case insensitive option. Possible +# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple +# permissions. For example, to allow to read and create, set "RC". Default is "R". +permissions: R # The default permissions rules for users. Default is none. rules: [] @@ -120,19 +122,19 @@ users: password: "{env}ENV_PASSWORD" - username: basic password: basic - # Override default modify. - modify: false + # Override default permissions. + permissions: CRUD rules: # With this rule, the user CANNOT access /some/files. - path: /some/file - allow: false - # With this rule, the user CAN modify /public/access. + permissions: none + # With this rule, the user CAN create, read, update and delete within /public/access. - path: /public/access/ - modify: true - # With this rule, the user CAN modify all files ending with .js. It uses + permissions: CRUD + # With this rule, the user CAN read and update all files ending with .js. It uses # a regular expression. - regex: "^.+.js$" - modify: true + permissions: RU ``` ### CORS diff --git a/lib/config.go b/lib/config.go index 48eef1b..f2ae7ec 100644 --- a/lib/config.go +++ b/lib/config.go @@ -23,18 +23,18 @@ const ( ) type Config struct { - Permissions `mapstructure:",squash"` - Debug bool - Address string - Port int - TLS bool - Cert string - Key string - Prefix string - NoSniff bool - Log Log - CORS CORS - Users []User + UserPermissions `mapstructure:",squash"` + Debug bool + Address string + Port int + TLS bool + Cert string + Key string + Prefix string + NoSniff bool + Log Log + CORS CORS + Users []User } func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { @@ -74,7 +74,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { // Other defaults v.SetDefault("Directory", ".") - v.SetDefault("Modify", false) + v.SetDefault("Permissions", "R") v.SetDefault("Debug", false) v.SetDefault("NoSniff", false) v.SetDefault("Log.Format", "console") @@ -108,8 +108,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { cfg.Users[i].Directory = cfg.Directory } - if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) { - cfg.Users[i].Modify = cfg.Modify + if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) { + cfg.Users[i].Permissions = cfg.Permissions } if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { @@ -153,7 +153,7 @@ func (c *Config) Validate() error { } } - err = c.Permissions.Validate() + err = c.UserPermissions.Validate() if err != nil { return fmt.Errorf("invalid config: %w", err) } diff --git a/lib/config_test.go b/lib/config_test.go index 84c0df7..79a1eab 100644 --- a/lib/config_test.go +++ b/lib/config_test.go @@ -49,17 +49,25 @@ func TestConfigCascade(t *testing.T) { t.Parallel() check := func(t *testing.T, cfg *Config) { - require.True(t, cfg.Modify) + require.True(t, cfg.Permissions.Read) + require.True(t, cfg.Permissions.Create) + require.False(t, cfg.Permissions.Delete) + require.False(t, cfg.Permissions.Update) require.Equal(t, "/", cfg.Directory) require.Len(t, cfg.Rules, 1) require.Len(t, cfg.Users, 2) - - require.True(t, cfg.Users[0].Modify) + require.True(t, cfg.Users[0].Permissions.Read) + require.True(t, cfg.Users[0].Permissions.Create) + require.False(t, cfg.Users[0].Permissions.Delete) + require.False(t, cfg.Users[0].Permissions.Update) require.Equal(t, "/", cfg.Users[0].Directory) require.Len(t, cfg.Users[0].Rules, 1) - require.False(t, cfg.Users[1].Modify) + require.True(t, cfg.Users[1].Permissions.Read) + require.False(t, cfg.Users[1].Permissions.Create) + require.False(t, cfg.Users[1].Permissions.Delete) + require.False(t, cfg.Users[1].Permissions.Update) require.Equal(t, "/basic", cfg.Users[1].Directory) require.Len(t, cfg.Users[1].Rules, 0) } @@ -67,10 +75,10 @@ func TestConfigCascade(t *testing.T) { t.Run("YAML", func(t *testing.T) { content := ` directory: / -modify: true +permissions: CR rules: - path: /public/access/ - modify: true + permissions: R users: - username: admin @@ -78,7 +86,7 @@ users: - username: basic password: basic directory: /basic - modify: false + permissions: R rules: []` cfg := writeAndParseConfig(t, content, ".yml") @@ -90,11 +98,11 @@ users: t.Run("JSON", func(t *testing.T) { content := `{ "directory": "/", - "modify": true, + "permissions": "CR", "rules": [ { "path": "/public/access/", - "modify": true + "permissions": "R" } ], "users": [ @@ -106,7 +114,7 @@ users: "username": "basic", "password": "basic", "directory": "/basic", - "modify": false, + "permissions": "R", "rules": [] } ] @@ -121,11 +129,11 @@ users: t.Run("`TOML", func(t *testing.T) { content := ` directory = "/" -modify = true +permissions = "CR" [[rules]] path = "/public/access/" -modify = true +permissions = "R" [[users]] username = "admin" @@ -135,7 +143,7 @@ password = "admin" username = "basic" password = "basic" directory = "/basic" -modify = false +permissions = "R" rules = [] ` @@ -175,12 +183,9 @@ cors: func TestConfigRules(t *testing.T) { content := ` directory: / -modify: true rules: - regex: '^.+\.js$' - modify: true - - path: /public/access/ - modify: true` + - path: /public/access/` cfg := writeAndParseConfig(t, content, ".yaml") require.NoError(t, cfg.Validate()) @@ -199,7 +204,7 @@ rules: func TestConfigEnv(t *testing.T) { require.NoError(t, os.Setenv("WD_PORT", "1234")) require.NoError(t, os.Setenv("WD_DEBUG", "true")) - require.NoError(t, os.Setenv("WD_MODIFY", "true")) + require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD")) require.NoError(t, os.Setenv("WD_DIRECTORY", "/test")) cfg, err := ParseConfig("", nil) @@ -208,11 +213,14 @@ func TestConfigEnv(t *testing.T) { assert.Equal(t, 1234, cfg.Port) assert.Equal(t, "/test", cfg.Directory) assert.Equal(t, true, cfg.Debug) - assert.Equal(t, true, cfg.Modify) + require.True(t, cfg.Permissions.Read) + require.True(t, cfg.Permissions.Create) + require.True(t, cfg.Permissions.Delete) + require.True(t, cfg.Permissions.Update) // Reset require.NoError(t, os.Setenv("WD_PORT", "")) require.NoError(t, os.Setenv("WD_DEBUG", "")) - require.NoError(t, os.Setenv("WD_MODIFY", "")) + require.NoError(t, os.Setenv("WD_PERMISSIONS", "")) require.NoError(t, os.Setenv("WD_DIRECTORY", "")) } diff --git a/lib/handler.go b/lib/handler.go index 35c0825..dd78e7c 100644 --- a/lib/handler.go +++ b/lib/handler.go @@ -2,6 +2,8 @@ package lib import ( "net/http" + "net/url" + "os" "strings" "github.com/rs/cors" @@ -23,7 +25,7 @@ func NewHandler(c *Config) (http.Handler, error) { h := &Handler{ user: &handlerUser{ User: User{ - Permissions: c.Permissions, + UserPermissions: c.UserPermissions, }, Handler: webdav.Handler{ Prefix: c.Prefix, @@ -100,7 +102,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Checks for user permissions relatively to this PATH. - allowed := user.Allowed(r) + allowed := user.Allowed(r, func(destination string) bool { + u, err := url.Parse(destination) + if err != nil { + return false + } + path := strings.TrimPrefix(u.Path, user.Prefix) + _, err = user.FileSystem.Stat(r.Context(), path) + return !os.IsNotExist(err) + }) zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path)) diff --git a/lib/handler_test.go b/lib/handler_test.go index f6061d4..c51b22c 100644 --- a/lib/handler_test.go +++ b/lib/handler_test.go @@ -133,7 +133,7 @@ func TestServerAuthentication(t *testing.T) { srv := makeTestServer(t, fmt.Sprintf(` directory: %s -modify: true +permissions: CRUD users: - username: basic @@ -191,39 +191,58 @@ func TestServerRules(t *testing.T) { "a/foo.js": []byte("foo js"), "a/foo.txt": []byte("foo txt"), "b/foo.txt": []byte("foo b"), + "c/a.txt": []byte("b"), + "c/b.txt": []byte("b"), + "c/c.txt": []byte("b"), }) srv := makeTestServer(t, fmt.Sprintf(` directory: %s -modify: true +permissions: CRUD users: - username: basic password: basic rules: - regex: "^.+.js$" - modify: false + permissions: R - path: "/b" - modify: false + permissions: R + - path: "/a/foo.txt" + permissions: none + - path: "/c" + permissions: none `, dir)) client := gowebdav.NewClient(srv.URL, "basic", "basic") files, err := client.ReadDir("/") require.NoError(t, err) - require.Len(t, files, 3) + require.Len(t, files, 4) err = client.Write("/foo.txt", []byte("new"), 0666) require.NoError(t, err) - err = client.Write("/a/foo.txt", []byte("new"), 0666) + err = client.Write("/new.txt", []byte("new"), 0666) require.NoError(t, err) + _, err = client.Read("/a/foo.txt") + require.ErrorContains(t, err, "403") + err = client.Write("/a/foo.js", []byte("new"), 0666) require.ErrorContains(t, err, "403") err = client.Write("/b/foo.txt", []byte("new"), 0666) require.ErrorContains(t, err, "403") + + _, err = client.ReadDir("/c") + require.ErrorContains(t, err, "403") + + _, err = client.Read("/c/a.txt") + require.ErrorContains(t, err, "403") + + err = client.Write("/c/b.txt", []byte("new"), 0666) + require.ErrorContains(t, err, "403") } func TestServerPermissions(t *testing.T) { @@ -237,7 +256,7 @@ func TestServerPermissions(t *testing.T) { srv := makeTestServer(t, fmt.Sprintf(` directory: %s -modify: true +permissions: CR users: - username: a @@ -246,7 +265,7 @@ users: - username: b password: b directory: %s/b - modify: false + permissions: R `, dir, dir, dir)) t.Run("User A", func(t *testing.T) { @@ -265,6 +284,12 @@ users: err = client.Copy("/foo.txt", "/copy.txt", false) require.NoError(t, err) + err = client.Copy("/foo.txt", "/copy.txt", true) + require.ErrorContains(t, err, "403") + + err = client.Rename("/foo.txt", "/copy.txt", true) + require.ErrorContains(t, err, "403") + data, err = client.Read("/copy.txt") require.NoError(t, err) require.EqualValues(t, []byte("foo a"), data) diff --git a/lib/permissions.go b/lib/permissions.go index 0276d08..72daae3 100644 --- a/lib/permissions.go +++ b/lib/permissions.go @@ -9,18 +9,10 @@ import ( "strings" ) -var readMethods = []string{ - http.MethodGet, - http.MethodHead, - http.MethodOptions, - "PROPFIND", -} - type Rule struct { - Allow bool - Modify bool - Path string - Regex *regexp.Regexp + Permissions Permissions + Path string + Regex *regexp.Regexp } func (r *Rule) Validate() error { @@ -40,36 +32,27 @@ func (r *Rule) Matches(path string) bool { return strings.HasPrefix(path, r.Path) } -type Permissions struct { - Directory string - Modify bool - Rules []*Rule +type UserPermissions struct { + Directory string + Permissions Permissions + Rules []*Rule } // Allowed checks if the user has permission to access a directory/file -func (p Permissions) Allowed(r *http.Request) bool { - // Determine whether or not it is a read or write request. - readRequest := false - for _, method := range readMethods { - if r.Method == method { - readRequest = true - break - } - } - +func (p UserPermissions) Allowed(r *http.Request, destinationExists func(string) bool) bool { // Go through rules beginning from the last one. for i := len(p.Rules) - 1; i >= 0; i-- { rule := p.Rules[i] if rule.Matches(r.URL.Path) { - return rule.Allow && (readRequest || rule.Modify) + return rule.Permissions.Allowed(r, destinationExists) } } - return readRequest || p.Modify + return p.Permissions.Allowed(r, destinationExists) } -func (p *Permissions) Validate() error { +func (p *UserPermissions) Validate() error { var err error p.Directory, err = filepath.Abs(p.Directory) @@ -85,3 +68,64 @@ func (p *Permissions) Validate() error { return nil } + +type Permissions struct { + Create bool + Read bool + Update bool + Delete bool +} + +func (p *Permissions) UnmarshalText(data []byte) error { + text := strings.ToLower(string(data)) + if text == "none" { + return nil + } + + for _, c := range text { + switch c { + case 'c': + p.Create = true + case 'r': + p.Read = true + case 'u': + p.Update = true + case 'd': + p.Delete = true + default: + return fmt.Errorf("invalid permission: %q", c) + } + } + + return nil +} + +func (p Permissions) Allowed(r *http.Request, destinationExists func(string) bool) bool { + switch r.Method { + case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND": + // Note: POST backend implementation just returns the same thing as GET. + return p.Read + case "MKCOL": + return p.Create + case "PROPPATCH": + return p.Update + case "PUT": + if destinationExists(r.URL.Path) { + return p.Update + } else { + return p.Create + } + case "COPY", "MOVE": + if destinationExists(r.Header.Get("Destination")) { + return p.Update + } else { + return p.Create + } + case "DELETE": + return p.Delete + case "LOCK", "UNLOCK": + return p.Create || p.Read || p.Update || p.Delete + default: + return false + } +} diff --git a/lib/user.go b/lib/user.go index 538c6e1..81a74ef 100644 --- a/lib/user.go +++ b/lib/user.go @@ -10,9 +10,9 @@ import ( ) type User struct { - Permissions `mapstructure:",squash"` - Username string - Password string + UserPermissions `mapstructure:",squash"` + Username string + Password string } func (u User) checkPassword(input string) bool { @@ -44,7 +44,7 @@ func (u *User) Validate() error { } } - if err := u.Permissions.Validate(); err != nil { + if err := u.UserPermissions.Validate(); err != nil { return fmt.Errorf("invalid user %q: %w", u.Username, err) }