Compare commits

..

37 commits

Author SHA1 Message Date
Henrique Dias
eaf42b03e9
feat: add bcrypt command 2024-12-22 10:28:49 +01:00
Henrique Dias
dce6010b61
chore: update dependencies 2024-12-21 08:04:50 +01:00
Henrique Dias
88863d7242
ci: update golangci-lint 2024-12-15 18:30:50 +01:00
Henrique Dias
2c96db92aa
chore: update dependencies 2024-12-15 18:30:28 +01:00
networkException
79bc17afab feat: add support for systemd listener activation sockets
To support starting a network service on demand and to
support a "least privilege-approach" with regards to the
permission a network service process needs to have, systemd
supports opening a network socket on behalf of the service
and passing it as an open file descriptor.

The service gets notified about open file descriptors for
this purpose as well as metadata such as named listeners
via environment variables.

This patch adds support for prefixing the listen address
passed with --address with "sd-listen-fd:" to access these
file descriptors, taking either a listener name passed using
the `LISTEN_FDNAMES` environment variable or `LISTEN_FD_$n`
for unnamed file descriptiors where `n` is the id of the
descriptor starting at 3 (LISTEN_FD_3).

See sd_listen_fds(3)
2024-12-11 17:29:03 +01:00
Henrique Dias
51b101d3d8 feat: rules behavior 2024-12-07 12:07:12 +01:00
Henrique Dias
ca7f3374d5 chore: update dependencies 2024-11-28 16:59:04 +01:00
Henrique Dias
64bbdc7b15 fix: error if rule has no regex or path 2024-11-28 16:57:10 +01:00
Steven Vandevelde
d418bd2661 fix: Pass ExposedHeaders to cors.New 2024-11-19 17:52:14 +01:00
Henrique Dias
d500716f29 fix: spoofing of X-Forwarded-For 2024-10-21 08:15:19 +02:00
Henrique Dias
8c49af0b68 fix: environment parsing for username 2024-10-20 09:09:05 +02:00
Henrique Dias
a5777e18ee
chore: update dependencies 2024-10-12 14:41:05 +02:00
Henrique Dias
49a6e935c3
docs(readme): make fail2ban config part of the examples 2024-10-12 14:40:29 +02:00
炯轩
a698e31cb4 chore: removed the the login attempt log commented line to makes the code cleaner and more focused 2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
74b514c877 feat(authentication): enhance login failure logging and reduce log volume
- Added logging for invalid username attempts to provide more detailed failure reasons.
- Removed "login attempt" log entries to reduce log volume and focus on final verification results.
- Retained logging for invalid password and successful user authorization for clarity.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
ca0bdb1cfa docs: add Fail2Ban configuration guide to README
- Added a section in README.md explaining how to configure Fail2Ban for WebDAV security.
- Included examples for filter and jail configuration.
- Provided instructions on setting up and testing Fail2Ban to block IPs after failed login attempts.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
a056e1ba18 feat(authentication): improve IP logging by extracting real client IP from X-Forwarded-For header
- Added getRealRemoteIP function to retrieve the real client IP address when behind a reverse proxy.
- Updated authentication logging to use the extracted IP instead of r.RemoteAddr.
- Ensured compatibility for both proxy and non-proxy setups, falling back to r.RemoteAddr when X-Forwarded-For is not present.
2024-10-12 14:38:42 +02:00
Keith Gaughan
189af88bc8
chore: omit debug information release builds (#185) 2024-09-08 21:34:26 +02:00
Henrique Dias
4e87e6a613
chore: disable CGO (#184) 2024-08-25 15:06:46 +02:00
Henrique Dias
63449f1636
fix: check permissions at copy/move source and destination (#181) 2024-08-21 18:15:32 +02:00
Henrique Dias
4ad26dad35 ci: use go 1.23 2024-08-19 19:32:46 +02:00
Henrique Dias
623bbc9a70 chore: update dependencies 2024-08-19 19:32:46 +02:00
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
18 changed files with 1130 additions and 293 deletions

View file

@ -15,5 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.23.x"
- run: go build .
env:
CGO_ENABLED: '0'

View file

@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.23.x"
- uses: golangci/golangci-lint-action@v6
with:
version: "v1.59"
version: "v1.62"

View file

@ -16,6 +16,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
go-version: "1.23.x"
- name: Run test with coverage
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...

View file

@ -8,10 +8,12 @@ before:
builds:
- main: main.go
binary: webdav
env:
- CGO_ENABLED=0
flags:
- '-trimpath'
ldflags:
- '-X github.com/hacdias/webdav/v4/cmd.version={{.Version}}'
- '-s -w -X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
goos:
- darwin
- linux

View file

@ -1,4 +1,4 @@
FROM golang:1.22-alpine3.20 AS build
FROM golang:1.23-alpine3.20 AS build
ARG VERSION="untracked"
@ -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 -trimpath -ldflags="-s -w -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" ]

183
README.md
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,65 @@ 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
# Whether the server runs behind a trusted proxy or not. When this is true,
# the header X-Forwarded-For will be used for logging the remote addresses
# of logging attempts (if available).
behindProxy: 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 are applied
# from last to first, that is, the first rule that matches the request, starting
# from the end, will be applied to the request.
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
# The behavior of redefining the rules for users. It can be:
# - overwrite: when a user has rules defined, these will overwrite any global
# rules already defined. That is, the global rules are not applicable to the
# user.
# - append: when a user has rules defined, these will be appended to the global
# rules already defined. That is, for this user, their own specific rules will
# be checked first, and then the global rules.
# Default is 'overwrite'.
rulesBehavior: overwrite
# 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 +125,43 @@ 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.
# You can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
# command lint utility.
- 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 +183,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;
}
```
@ -172,6 +209,64 @@ Restart=on-failure
WantedBy=multi-user.target
```
### Fail2Ban Setup
To add security against brute-force attacks in your WebDAV server, you can configure Fail2Ban to ban IP addresses after a set number of failed login attempts.
#### Filter Configuration
Create a new filter rule under `filter.d/webdav.conf`:
```ini
[INCLUDES]
before = common.conf
[Definition]
# Failregex to match "invalid password" and extract remote_address only
failregex = ^.*invalid password\s*\{.*"remote_address":\s*"<HOST>"\s*\}
# Failregex to match "invalid username" and extract remote_address only (if applicable)
failregex += ^.*invalid username\s*\{.*"remote_address":\s*"<HOST>"\s*\}
ignoreregex =
```
This configuration will capture invalid login attempts and extract the IP address to ban.
#### Jail Configuration
In `jail.d/webdav.conf`, define the jail that monitors your WebDAV log for failed login attempts:
```ini
[webdav]
enabled = true
port = [your_port]
filter = webdav
logpath = [your_log_path]
banaction = iptables-allports
ignoreself = false
```
- Replace `[your_port]` with the port your WebDAV server is running on.
- Replace `[your_log_path]` with the path to your WebDAV log file.
#### Final Steps
1. Restart Fail2Ban to apply these configurations:
```bash
sudo systemctl restart fail2ban
```
2. Verify that Fail2Ban is running and monitoring your WebDAV logs:
```bash
sudo fail2ban-client status webdav
```
With this setup, Fail2Ban will automatically block IP addresses that exceed the allowed number of failed login attempts.
## Contributing
Feel free to open an issue or a pull request.

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]

49
cmd/bcrypt.go Normal file
View file

@ -0,0 +1,49 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
func init() {
flags := bcryptCmd.Flags()
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
rootCmd.AddCommand(bcryptCmd)
}
var bcryptCmd = &cobra.Command{
Use: "bcrypt",
Short: "Generate a bcrypt encrypted password",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cost, err := cmd.Flags().GetInt("cost")
if err != nil {
return err
}
if cost < bcrypt.MinCost {
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
}
if cost > bcrypt.MaxCost {
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
}
pwd := args[0]
if pwd == "" {
return errors.New("password argument must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
if err != nil {
return err
}
fmt.Println(string(hash))
return nil
},
}

View file

@ -10,23 +10,21 @@ import (
"strings"
"syscall"
"github.com/hacdias/webdav/v4/lib"
"github.com/coreos/go-systemd/v22/activation"
"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 +56,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
}
@ -117,7 +116,21 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
network string
)
if strings.HasPrefix(cfg.Address, "unix:") {
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
listeners, err := activation.ListenersWithNames()
if err != nil {
return nil, err
}
address := cfg.Address[13:]
listener, ok := listeners[address]
if !ok || len(listener) < 1 {
return nil, errors.New("unknown sd-listen-fd address '" + address + "'")
}
return listener[0], nil
} else if strings.HasPrefix(cfg.Address, "unix:") {
address = cfg.Address[5:]
network = "unix"
} else {
@ -127,19 +140,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
}

34
go.mod
View file

@ -1,34 +1,40 @@
module github.com/hacdias/webdav/v4
module github.com/hacdias/webdav/v5
go 1.22
go 1.23
require (
github.com/rs/cors v1.11.0
github.com/coreos/go-systemd/v22 v22.5.0
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.20.0-alpha.6
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
golang.org/x/crypto v0.31.0
golang.org/x/net v0.33.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.6.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
retract v4.1.0

67
go.sum
View file

@ -1,55 +1,59 @@
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0-alpha.6 h1:f65Cr/+2qk4GfHC0xqT/isoupQppwN5+VLRztUGTDbY=
github.com/spf13/viper v1.20.0-alpha.6/go.mod h1:CGBZzv0c9fOUASm6rfus4wdeIjR/04NOLq1P4KRhX3k=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
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=
@ -58,17 +62,20 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -6,40 +6,41 @@ 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 = "/"
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
BehindProxy bool
Log Log
CORS CORS
Users []User
}
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
v := viper.New()
// Configure flags bindings
if flags != nil {
@ -47,11 +48,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
@ -66,19 +62,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v.SetEnvPrefix("wd")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// TODO: use new env struct bind feature when it's released in viper.
// This should make it redundant to set defaults for things that are
// empty or false.
// Defaults shared with flags
v.SetDefault("Scope", DefaultScope)
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("RulesBehavior", RulesOverwrite)
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{"*"})
@ -92,22 +97,40 @@ 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)) {
if !v.IsSet(fmt.Sprintf("Users.%d.RulesBehavior", i)) {
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
}
if v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
switch cfg.Users[i].RulesBehavior {
case RulesOverwrite:
// Do nothing
case RulesAppend:
rules := append([]*Rule{}, cfg.Rules...)
rules = append(rules, cfg.Users[i].Rules...)
cfg.Users[i].Rules = rules
}
} else {
cfg.Users[i].Rules = cfg.Rules
}
}
@ -123,15 +146,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)
}
@ -156,13 +171,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()
for i := range c.Users {
err := c.Users[i].Validate(c.NoPassword)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
@ -171,6 +186,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

View file

@ -22,19 +22,34 @@ func writeAndParseConfig(t *testing.T, content, extension string) *Config {
return cfg
}
func writeAndParseConfigWithError(t *testing.T, content, extension, error string) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err)
_, err = ParseConfig(tmpFile, nil)
require.ErrorContains(t, err, error)
}
func TestConfigDefaults(t *testing.T) {
t.Parallel()
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 +60,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 +108,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 +124,8 @@ users:
{
"username": "basic",
"password": "basic",
"scope": "/basic",
"modify": false,
"directory": "/basic",
"permissions": "R",
"rules": []
}
]
@ -117,13 +138,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 +153,8 @@ password = "admin"
[[users]]
username = "basic"
password = "basic"
scope = "/basic"
modify = false
directory = "/basic"
permissions = "R"
rules = []
`
@ -171,49 +192,163 @@ cors:
}
func TestConfigRules(t *testing.T) {
content := `
auth: false
scope: /
modify: true
t.Run("Only Regex or Path", func(t *testing.T) {
content := `
directory: /
rules:
- path: '^.+\.js$'
regex: true
modify: true
- regex: '^.+\.js$'
path: /public/access/`
writeAndParseConfigWithError(t, content, ".yaml", "cannot define both regex and path")
})
t.Run("Regex or Path Required", func(t *testing.T) {
content := `
directory: /
rules:
- permissions: CRUD`
writeAndParseConfigWithError(t, content, ".yaml", "must either define a path of a regex")
})
t.Run("Parse", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
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].Regex)
})
t.Run("Rules Behavior (Default: Overwrite)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
regex: false
modify: true`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
users:
- username: foo
password: bar
rules:
- path: /private/access/`
require.Len(t, cfg.Rules, 2)
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
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.Len(t, cfg.Rules, 2)
require.NotEmpty(t, cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regexp)
require.Empty(t, cfg.Rules[0].Path)
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.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 1)
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[0].Path)
})
t.Run("Rules Behavior (Append)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
rulesBehavior: append
users:
- username: foo
password: bar
rules:
- path: /private/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
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.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 3)
require.EqualValues(t, cfg.Rules[0], cfg.Users[0].Rules[0])
require.EqualValues(t, cfg.Rules[1], cfg.Users[0].Rules[1])
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[2].Path)
})
}
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", ""))
}
func TestConfigParseUserPasswordEnvironment(t *testing.T) {
content := `
directory: /
users:
- username: '{env}USER1_USERNAME'
password: '{env}USER1_PASSWORD'
- username: basic
password: basic
`
writeAndParseConfigWithError(t, content, ".yml", "username environment variable is empty")
err := os.Setenv("USER1_USERNAME", "admin")
require.NoError(t, err)
writeAndParseConfigWithError(t, content, ".yml", "password environment variable is empty")
err = os.Setenv("USER1_PASSWORD", "admin")
require.NoError(t, err)
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Equal(t, "admin", cfg.Users[0].Username)
require.Equal(t, "basic", cfg.Users[1].Username)
require.True(t, cfg.Users[0].checkPassword("admin"))
require.True(t, cfg.Users[1].checkPassword("basic"))
}

View file

@ -2,6 +2,8 @@ package lib
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/rs/cors"
@ -15,20 +17,24 @@ type handlerUser struct {
}
type Handler struct {
user *handlerUser
users map[string]*handlerUser
noPassword bool
behindProxy bool
user *handlerUser
users map[string]*handlerUser
}
func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
noPassword: c.NoPassword,
behindProxy: c.BehindProxy,
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 +49,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(),
@ -57,10 +63,19 @@ func NewHandler(c *Config) (http.Handler, error) {
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
}
@ -72,9 +87,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(h.users) > 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Retrieve the real client IP address using the updated helper function
remoteAddr := getRealRemoteIP(r, h.behindProxy)
// Gets the correct user for this request.
username, password, ok := r.BasicAuth()
zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
if !ok {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
@ -82,21 +99,41 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user, ok = h.users[username]
if !ok {
// Log invalid username
zap.L().Info("invalid username", zap.String("username", username), zap.String("remote_address", remoteAddr))
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
if !user.checkPassword(password) {
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
if !h.noPassword && !user.checkPassword(password) {
// Log invalid password
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", remoteAddr))
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
zap.L().Info("user authorized", zap.String("username", username))
// Log successful authorization
zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr))
}
// Cleanup destination header if it's present by stripping out the prefix
// and only keeping the path.
if destination := r.Header.Get("Destination"); destination != "" {
u, err := url.Parse(destination)
if err == nil {
destination = strings.TrimPrefix(u.Path, user.Prefix)
if !strings.HasPrefix(destination, "/") {
destination = "/" + destination
}
r.Header.Set("Destination", destination)
}
}
// Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r)
allowed := user.Allowed(r, func(filename string) bool {
_, err := user.FileSystem.Stat(r.Context(), filename)
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))
@ -131,6 +168,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user.ServeHTTP(w, r)
}
// 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
}

361
lib/handler_test.go Normal file
View file

@ -0,0 +1,361 @@
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"),
"bar.js": []byte("foo js"),
"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, 5)
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.Copy("/bar.js", "/b/bar.js", false)
require.ErrorContains(t, err, "403")
err = client.Copy("/bar.js", "/bar.jsx", false)
require.NoError(t, err)
err = client.Copy("/b/foo.txt", "/foo1.txt", false)
require.NoError(t, err)
err = client.Rename("/b/foo.txt", "/foo2.txt", false)
require.ErrorContains(t, err, "403")
_, 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")
})
}

View file

@ -1,37 +1,27 @@
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: must either define a path of a regex")
}
if r.Regex != nil && r.Path != "" {
return errors.New("invalid rule: cannot define both regex and path")
}
return nil
@ -39,48 +29,158 @@ 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 RulesBehavior string
const (
RulesOverwrite RulesBehavior = "overwrite"
RulesAppend RulesBehavior = "append"
)
type UserPermissions struct {
Directory string
Permissions Permissions
Rules []*Rule
RulesBehavior RulesBehavior
}
// 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, fileExists func(string) bool) bool {
// For COPY and MOVE requests, we first check the permissions for the destination
// path. As soon as a rule matches and does not allow the operation at the destination,
// we fail immediately. If no rule matches, we check the global permissions.
if r.Method == "COPY" || r.Method == "MOVE" {
dst := r.Header.Get("Destination")
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(dst) {
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
return false
}
// Only check the first rule that matches, similarly to the source rules.
break
}
}
if !p.Permissions.AllowedDestination(r, fileExists) {
return false
}
}
// Go through rules beginning from the last one.
// Go through rules beginning from the last one, and check the permissions at
// the source. The first matched rule returns.
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)
if p.Rules[i].Matches(r.URL.Path) {
return p.Rules[i].Permissions.Allowed(r, fileExists)
}
}
return readRequest || p.Modify
return p.Permissions.Allowed(r, fileExists)
}
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)
}
}
switch p.RulesBehavior {
case RulesAppend, RulesOverwrite:
// Good to go
default:
return fmt.Errorf("invalid rule behavior: %s", p.RulesBehavior)
}
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
}
// Allowed returns whether this permission set has permissions to execute this
// request in the source directory. This applies to all requests with all methods.
func (p Permissions) Allowed(r *http.Request, fileExists 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 fileExists(r.URL.Path) {
return p.Update
} else {
return p.Create
}
case "COPY":
return p.Read
case "MOVE":
return p.Read && p.Delete
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
default:
return false
}
}
// AllowedDestination returns whether this permissions set has permissions to execute this
// request in the destination directory. This only applies for COPY and MOVE requests.
func (p Permissions) AllowedDestination(r *http.Request, fileExists func(string) bool) bool {
switch r.Method {
case "COPY", "MOVE":
if fileExists(r.Header.Get("Destination")) {
return p.Update
} else {
return p.Create
}
default:
return false
}
}

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,15 +24,24 @@ 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")
} else if strings.HasPrefix(u.Username, "{env}") {
env := strings.TrimPrefix(u.Username, "{env}")
if env == "" {
return fmt.Errorf("invalid user %q: username environment variable not set", u.Username)
}
u.Username = os.Getenv(env)
if u.Username == "" {
return fmt.Errorf("invalid user %q: username environment variable is empty", u.Username)
}
}
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}") {
env := strings.TrimPrefix(u.Password, "{env}")
if env == "" {
return fmt.Errorf("invalid user %q: password environment variable not set", u.Username)
@ -44,7 +53,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)
}

View file

@ -1,7 +1,7 @@
package main
import (
"github.com/hacdias/webdav/v4/cmd"
"github.com/hacdias/webdav/v5/cmd"
)
func main() {