Compare commits

...
15 Commits
Author SHA1 Message Date
Henrique Dias feeb33d249 docs: add note about noSniff 2024-08-01 21:53:58 +02:00
Henrique Dias d3bee98000 feat: allow disabling password check for delegated authentication 2024-08-01 21:53:58 +02:00
Henrique Dias 373b2ec931 docs: fix nginx configuration
Closes #132
2024-08-01 10:39:21 +02:00
Henrique Dias 000f404f7a docs: remove dev version note 2024-07-31 10:07:23 +01:00
Henrique Dias e4a8622c1e docs: remove outdated SECURITY.md 2024-07-31 10:07:01 +01:00
Henrique Dias b5a3d07f5c feat!: fine-grained permissions 2024-07-31 11:06:34 +02:00
Henrique Dias f4de82cfd1 feat: add test for server listing characters 2024-07-30 15:01:17 +02:00
Henrique Dias ebcf500d5e docs: cleanup readme 2024-07-29 09:13:46 +01:00
Henrique Dias d7faa1f887 feat!: further log customizations 2024-07-29 10:11:02 +02:00
Henrique Dias d5e5052f63 feat!: rename 'scope' to 'directory'
Directory makes it more clear of what it is. In addition, this will make it easier when
allowing for multiple directories in the future, since we can just name it 'directories', which is more clear than 'scopes'.
2024-07-29 10:11:02 +02:00
Henrique Dias a255fb51e2 feat!: remove Auth option 2024-07-29 10:11:02 +02:00
Henrique Dias ed23ca1820 feat!: change default port and scope
BREAKING CHANGE: the default port is no longer random, but 6065. The default scope is now the current directory instead of the root directory.
2024-07-29 10:11:02 +02:00
Henrique Dias e7e9c3176d feat!: simplified rule with regex instead of boolean
BREAKING CHANGE: the "regex" field in the rule is now a regular expression instead of a boolean.
2024-07-29 10:11:02 +02:00
Henrique Dias d3732322bc chore: bump version to v5 2024-07-29 10:10:19 +02:00
Henrique Dias f708664906 feat: permissions, auth, rules basic tests 2024-07-26 17:18:46 +02:00
14 changed files with 665 additions and 239 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ builds:
flags:
- '-trimpath'
ldflags:
- '-X github.com/hacdias/webdav/v4/cmd.version={{.Version}}'
- '-X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
goos:
- darwin
- linux
+2 -3
View File
@@ -11,14 +11,13 @@ COPY ./go.sum ./
RUN go mod download
COPY . /webdav/
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$VERSION'" .
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /webdav/main /bin/webdav
EXPOSE 80
EXPOSE 6065
ENTRYPOINT [ "webdav" ]
CMD [ "-p", "80" ]
+62 -44
View File
@@ -11,7 +11,7 @@ A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
```
go install github.com/hacdias/webdav/v4@latest
go install github.com/hacdias/webdav/v5@latest
```
### Docker
@@ -32,11 +32,11 @@ For usage information regarding the CLI, run `webdav --help`.
### Docker
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the scope to `/data`.
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
```yaml
port: 6060
scope: /data
directory: /data
```
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
@@ -55,65 +55,48 @@ The configuration can be provided as a YAML, JSON or TOML file. Below is an exam
```yaml
address: 0.0.0.0
port: 0
port: 6065
# TLS-related settings if you want to enable TLS directly.
tls: false
cert: cert.pem
key: key.pem
# Prefix to apply to the WebDAV path-ing. Default is "/".
# Prefix to apply to the WebDAV path-ing. Default is '/'.
prefix: /
# Enable or disable debug logging. Default is false.
# Enable or disable debug logging. Default is 'false'.
debug: false
# Whether or not to have authentication. With authentication on, you need to
# define one or more users. Default is false.
auth: true
# Disable sniffing the files to detect their content type. Default is 'false'.
noSniff: false
# The directory that will be able to be accessed by the users when connecting.
# This directory will be used by users unless they have their own 'scope' defined.
# Default is "/".
scope: /
# This directory will be used by users unless they have their own 'directory' defined.
# Default is '.' (current directory).
directory: .
# Whether the users can, by default, modify the contents. 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
# Default permissions rules to apply at the paths.
# The default permissions rules for users. Default is none.
rules: []
# The list of users. Must be defined if auth is set to true.
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
# Example 'john' user with bcrypt encrypted password, with custom scope.
- username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
scope: /another/path
# Example user whose details will be picked up from the environment.
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
- username: basic
password: basic
# Override default modify.
modify: false
rules:
# With this rule, the user CANNOT access /some/files.
- path: /some/file
allow: false
# With this rule, the user CAN modify /public/access.
- path: /public/access/
modify: true
# With this rule, the user CAN modify all files ending with .js. It uses
# a regular expression.
- path: "^*.js$"
regex: true
modify: true
# Logging configuration
log:
# Logging format ('console', 'json'). Default is 'console'.
format: console
# Enable or disable colors. Default is 'true'. Only applied if format is 'console'.
colors: true
# Logging outputs. You can have more than one output. Default is only 'stderr'.
outputs:
- stderr
# CORS configuration
cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
allowed_headers:
@@ -125,6 +108,41 @@ cors:
exposed_headers:
- Content-Length
- Content-Range
# The list of users. If the list is empty, then there will be no authentication.
# Otherwise, basic authentication will automatically be configured.
#
# If you're delegating the authentication to a different service, you can proxy
# the username using basic authentication, and then disable webdav's password
# check using the option:
#
# noPassword: true
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
# Example 'john' user with bcrypt encrypted password, with custom directory.
- username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /another/path
# Example user whose details will be picked up from the environment.
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
- username: basic
password: basic
# Override default permissions.
permissions: CRUD
rules:
# With this rule, the user CANNOT access /some/files.
- path: /some/file
permissions: none
# With this rule, the user CAN create, read, update and delete within /public/access.
- path: /public/access/
permissions: CRUD
# With this rule, the user CAN read and update all files ending with .js. It uses
# a regular expression.
- regex: "^.+.js$"
permissions: RU
```
### CORS
@@ -146,7 +164,7 @@ location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header Host $host;
proxy_redirect off;
}
```
-8
View File
@@ -1,8 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to:
msaa1990 [at] gmail [dot com]
cc: hacdias [at] gmail [dot com]
+9 -27
View File
@@ -10,23 +10,20 @@ import (
"strings"
"syscall"
"github.com/hacdias/webdav/v4/lib"
"github.com/hacdias/webdav/v5/lib"
"github.com/spf13/cobra"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func init() {
flags := rootCmd.Flags()
flags.StringP("config", "c", "", "config file path")
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
flags.Bool("auth", lib.DefaultAuth, "enable authentication")
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
flags.String("key", lib.DefaultKey, "path to TLS key")
flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
flags.String("key", lib.DefaultKey, "path to TLS key")
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
flags.String("log_format", lib.DefaultLogFormat, "logging format")
}
var rootCmd = &cobra.Command{
@@ -58,14 +55,15 @@ set WD_CERT.`,
return err
}
// Create HTTP handler from the config
handler, err := lib.NewHandler(cfg)
// Setup the logger based on the configuration
logger, err := cfg.GetLogger()
if err != nil {
return err
}
zap.ReplaceGlobals(logger)
// Setup the logger based on the configuration
err = setupLogger(cfg)
// Create HTTP handler from the config
handler, err := lib.NewHandler(cfg)
if err != nil {
return err
}
@@ -127,19 +125,3 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
return net.Listen(network, address)
}
func setupLogger(cfg *lib.Config) error {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
if cfg.Debug {
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.LogFormat
logger, err := loggerConfig.Build()
if err != nil {
return err
}
zap.ReplaceGlobals(logger)
return nil
}
+3 -3
View File
@@ -1,13 +1,15 @@
module github.com/hacdias/webdav/v4
module github.com/hacdias/webdav/v5
go 1.22
require (
github.com/go-viper/mapstructure/v2 v2.0.0
github.com/rs/cors v1.11.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/studio-b12/gowebdav v0.9.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.25.0
golang.org/x/net v0.27.0
@@ -35,5 +37,3 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
retract v4.1.0
+4
View File
@@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -56,6 +58,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+63 -52
View File
@@ -6,39 +6,36 @@ import (
"path/filepath"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultScope = "/"
DefaultModify = false
DefaultDebug = false
DefaultNoSniff = false
DefaultTLS = false
DefaultAuth = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 0
DefaultPrefix = "/"
DefaultLogFormat = "console"
DefaultTLS = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 6065
DefaultPrefix = "/"
)
type Config struct {
Permissions `mapstructure:",squash"`
Debug bool
Address string
Port int
TLS bool
Cert string
Key string
Prefix string
NoSniff bool
LogFormat string `mapstructure:"log_format"`
Auth bool
CORS CORS
Users []User
UserPermissions `mapstructure:",squash"`
Debug bool
Address string
Port int
TLS bool
Cert string
Key string
Prefix string
NoSniff bool
NoPassword bool
Log Log
CORS CORS
Users []User
}
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
@@ -50,11 +47,6 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
if err != nil {
return nil, err
}
err = v.BindPFlag("LogFormat", flags.Lookup("log_format"))
if err != nil {
return nil, err
}
}
// Configuration file settings
@@ -74,20 +66,22 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
// empty or false.
// Defaults shared with flags
v.SetDefault("Scope", DefaultScope)
v.SetDefault("Modify", DefaultModify)
v.SetDefault("Debug", DefaultDebug)
v.SetDefault("NoSniff", DefaultNoSniff)
v.SetDefault("TLS", DefaultTLS)
v.SetDefault("Cert", DefaultCert)
v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Auth", DefaultAuth)
v.SetDefault("Prefix", DefaultPrefix)
v.SetDefault("Log_Format", DefaultLogFormat)
// Other defaults
v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false)
v.SetDefault("NoPassword", false)
v.SetDefault("Log.Format", "console")
v.SetDefault("Log.Outputs", []string{"stderr"})
v.SetDefault("Log.Colors", true)
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Methods", []string{"*"})
@@ -101,19 +95,23 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
}
cfg := &Config{}
err = v.Unmarshal(cfg)
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
)))
if err != nil {
return nil, err
}
// Cascade user settings
for i := range cfg.Users {
if !v.IsSet(fmt.Sprintf("Users.%d.Scope", i)) {
cfg.Users[i].Scope = cfg.Scope
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
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)) {
@@ -132,15 +130,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
func (c *Config) Validate() error {
var err error
if c.Auth && len(c.Users) == 0 {
return errors.New("invalid config: auth cannot be enabled without users")
}
if !c.Auth && len(c.Users) != 0 {
return errors.New("invalid config: auth cannot be disabled with users defined")
}
c.Scope, err = filepath.Abs(c.Scope)
c.Directory, err = filepath.Abs(c.Directory)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
@@ -165,13 +155,13 @@ func (c *Config) Validate() error {
}
}
err = c.Permissions.Validate()
err = c.UserPermissions.Validate()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
for _, u := range c.Users {
err := u.Validate()
err := u.Validate(c.NoPassword)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
@@ -180,6 +170,27 @@ func (c *Config) Validate() error {
return nil
}
func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
if cfg.Debug {
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
if cfg.Log.Colors && cfg.Log.Format != "json" {
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.Log.Format
loggerConfig.OutputPaths = cfg.Log.Outputs
return loggerConfig.Build()
}
type Log struct {
Format string
Colors bool
Outputs []string
}
type CORS struct {
Enabled bool
Credentials bool
+54 -47
View File
@@ -28,13 +28,17 @@ func TestConfigDefaults(t *testing.T) {
cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate())
require.EqualValues(t, DefaultAuth, cfg.Auth)
require.EqualValues(t, DefaultTLS, cfg.TLS)
require.EqualValues(t, DefaultAddress, cfg.Address)
require.EqualValues(t, DefaultPort, cfg.Port)
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
require.EqualValues(t, DefaultLogFormat, cfg.LogFormat)
require.NotEmpty(t, cfg.Scope)
require.EqualValues(t, "console", cfg.Log.Format)
require.EqualValues(t, true, cfg.Log.Colors)
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
dir, err := os.Getwd()
require.NoError(t, err)
require.Equal(t, dir, cfg.Directory)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
@@ -45,37 +49,44 @@ func TestConfigCascade(t *testing.T) {
t.Parallel()
check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Modify)
require.Equal(t, "/", cfg.Scope)
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.Equal(t, "/", cfg.Users[0].Scope)
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.Equal(t, "/basic", cfg.Users[1].Scope)
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)
}
t.Run("YAML", func(t *testing.T) {
content := `
auth: true
scope: /
modify: true
directory: /
permissions: CR
rules:
- path: /public/access/
modify: true
permissions: R
users:
- username: admin
password: admin
- username: basic
password: basic
scope: /basic
modify: false
directory: /basic
permissions: R
rules: []`
cfg := writeAndParseConfig(t, content, ".yml")
@@ -86,13 +97,12 @@ users:
t.Run("JSON", func(t *testing.T) {
content := `{
"auth": true,
"scope": "/",
"modify": true,
"directory": "/",
"permissions": "CR",
"rules": [
{
"path": "/public/access/",
"modify": true
"permissions": "R"
}
],
"users": [
@@ -103,8 +113,8 @@ users:
{
"username": "basic",
"password": "basic",
"scope": "/basic",
"modify": false,
"directory": "/basic",
"permissions": "R",
"rules": []
}
]
@@ -117,13 +127,13 @@ users:
})
t.Run("`TOML", func(t *testing.T) {
content := `auth = true
scope = "/"
modify = true
content := `
directory = "/"
permissions = "CR"
[[rules]]
path = "/public/access/"
modify = true
permissions = "R"
[[users]]
username = "admin"
@@ -132,8 +142,8 @@ password = "admin"
[[users]]
username = "basic"
password = "basic"
scope = "/basic"
modify = false
directory = "/basic"
permissions = "R"
rules = []
`
@@ -172,16 +182,10 @@ cors:
func TestConfigRules(t *testing.T) {
content := `
auth: false
scope: /
modify: true
directory: /
rules:
- path: '^.+\.js$'
regex: true
modify: true
- path: /public/access/
regex: false
modify: true`
- regex: '^.+\.js$'
- path: /public/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
@@ -189,31 +193,34 @@ rules:
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
require.NotNil(t, cfg.Rules[0].Regexp)
require.True(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.ts"))
require.NotNil(t, cfg.Rules[0].Regex)
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
require.NotEmpty(t, cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regexp)
require.Nil(t, cfg.Rules[1].Regex)
}
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_SCOPE", "/test"))
require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Scope)
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_SCOPE", ""))
require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
}
+27 -7
View File
@@ -2,6 +2,8 @@ package lib
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/rs/cors"
@@ -15,20 +17,22 @@ type handlerUser struct {
}
type Handler struct {
user *handlerUser
users map[string]*handlerUser
noPassword bool
user *handlerUser
users map[string]*handlerUser
}
func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
noPassword: c.NoPassword,
user: &handlerUser{
User: User{
Permissions: c.Permissions,
UserPermissions: c.UserPermissions,
},
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(c.Scope),
Dir: webdav.Dir(c.Directory),
noSniff: c.NoSniff,
},
LockSystem: webdav.NewMemLS(),
@@ -43,7 +47,7 @@ func NewHandler(c *Config) (http.Handler, error) {
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(u.Scope),
Dir: webdav.Dir(u.Directory),
noSniff: c.NoSniff,
},
LockSystem: webdav.NewMemLS(),
@@ -61,6 +65,14 @@ func NewHandler(c *Config) (http.Handler, error) {
}).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
}
@@ -86,7 +98,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if !user.checkPassword(password) {
if !h.noPassword && !user.checkPassword(password) {
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
@@ -96,7 +108,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))
+348
View File
@@ -0,0 +1,348 @@
package lib
import (
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
"github.com/studio-b12/gowebdav"
)
func makeTestDirectory(t *testing.T, m map[string][]byte) string {
dir := t.TempDir()
for path, data := range m {
filename := filepath.Join(dir, path)
if data == nil {
err := os.MkdirAll(filename, 0775)
require.NoError(t, err)
} else {
err := os.MkdirAll(filepath.Dir(filename), 0775)
require.NoError(t, err)
err = os.WriteFile(filename, data, 0664)
require.NoError(t, err)
}
}
return dir
}
func makeTestServer(t *testing.T, yamlConfig string) *httptest.Server {
cfg := writeAndParseConfig(t, yamlConfig, ".yml")
require.NoError(t, cfg.Validate())
handler, err := NewHandler(cfg)
require.NoError(t, err)
return httptest.NewServer(handler)
}
func TestServerDefaults(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
files, err = client.ReadDir("/sub")
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, "bar.txt", files[0].Name())
data, err = client.Read("/sub/bar.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
// By default, no modification permissions.
require.ErrorContains(t, client.Mkdir("/dir", 0666), "403")
require.ErrorContains(t, client.MkdirAll("/dir/path", 0666), "403")
require.ErrorContains(t, client.Remove("/foo.txt"), "403")
require.ErrorContains(t, client.RemoveAll("/foo.txt"), "403")
require.ErrorContains(t, client.Rename("/foo.txt", "/file2.txt", false), "403")
require.ErrorContains(t, client.Copy("/foo.txt", "/file2.txt", false), "403")
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
}
func TestServerListingCharacters(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"富/foo.txt": []byte("foo"),
"你好.txt": []byte("bar"),
"z*.txt": []byte("zbar"),
"foo.txt": []byte("foo"),
"🌹.txt": []byte("foo"),
})
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 5)
names := []string{
files[0].Name(),
files[1].Name(),
files[2].Name(),
files[3].Name(),
files[4].Name(),
}
sort.Strings(names)
require.Equal(t, []string{
"foo.txt",
"z*.txt",
"你好.txt",
"富",
"🌹.txt",
}, names)
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
}
func TestServerAuthentication(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
users:
- username: basic
password: basic
- username: bcrypt
password: "{bcrypt}$2a$12$222dfz8Nweoyvy8OwI8.me9nfaRfuz8lqGkiiYSMH1lLMHO26qWom"
`, dir))
t.Run("Basic Auth (Plaintext)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "basic")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Basic Auth (BCrypt)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "bcrypt", "bcrypt")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Unauthorized (No Credentials)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "", "")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
t.Run("Unauthorized (Wrong User)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "wrong", "basic")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
t.Run("Unauthorized (Wrong Password)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "wrong")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
}
func TestServerAuthenticationNoPassword(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
noPassword: true
permissions: CRUD
users:
- username: basic
`, dir))
t.Run("Basic Auth", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Unauthorized Wrong User", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "wrong", "")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
}
func TestServerRules(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"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
permissions: CRUD
users:
- username: basic
password: basic
rules:
- regex: "^.+.js$"
permissions: R
- path: "/b"
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, 4)
err = client.Write("/foo.txt", []byte("new"), 0666)
require.NoError(t, err)
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) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"a/foo.txt": []byte("foo a"),
"b/foo.txt": []byte("foo b"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CR
users:
- username: a
password: a
directory: %s/a
- username: b
password: b
directory: %s/b
permissions: R
`, dir, dir, dir))
t.Run("User A", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "a", "a")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 1)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data)
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)
})
t.Run("User B", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "b", "b")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 1)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo b"), data)
err = client.Copy("/foo.txt", "/copy.txt", false)
require.ErrorContains(t, err, "403")
})
}
+85 -40
View File
@@ -1,37 +1,23 @@
package lib
import (
"errors"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
)
var readMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
"PROPFIND",
}
type Rule struct {
Regex bool
Allow bool
Modify bool
Path string
// TODO: remove Regex and replace by this. It encodes
Regexp *regexp.Regexp `mapstructure:"-"`
Permissions Permissions
Path string
Regex *regexp.Regexp
}
func (r *Rule) Validate() error {
if r.Regex {
rp, err := regexp.Compile(r.Path)
if err != nil {
return fmt.Errorf("invalid rule: %w", err)
}
r.Regexp = rp
r.Path = ""
r.Regex = false
if r.Regex != nil && r.Path != "" {
return errors.New("invalid rule: cannot define both regex and path")
}
return nil
@@ -39,43 +25,41 @@ func (r *Rule) Validate() error {
// Matches checks if [Rule] matches the given path.
func (r *Rule) Matches(path string) bool {
if r.Regexp != nil {
return r.Regexp.MatchString(path)
if r.Regex != nil {
return r.Regex.MatchString(path)
}
return strings.HasPrefix(path, r.Path)
}
type Permissions struct {
Scope 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)
if err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
for _, r := range p.Rules {
if err := r.Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
@@ -84,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
}
}
+6 -6
View File
@@ -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 {
@@ -24,12 +24,12 @@ func (u User) checkPassword(input string) bool {
return u.Password == input
}
func (u *User) Validate() error {
func (u *User) Validate(noPassword bool) error {
if u.Username == "" {
return errors.New("invalid user: username must be set")
}
if u.Password == "" {
if u.Password == "" && !noPassword {
return fmt.Errorf("invalid user %q: password must be set", u.Username)
} else if strings.HasPrefix(u.Password, "{env}") {
@@ -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)
}
+1 -1
View File
@@ -1,7 +1,7 @@
package main
import (
"github.com/hacdias/webdav/v4/cmd"
"github.com/hacdias/webdav/v5/cmd"
)
func main() {