Compare commits

...
10 Commits
Author SHA1 Message Date
Mao Mao 4733aa03c4 feat: support CORS Private Network Access (#350) 2026-08-28 15:27:04 +02:00
renovate[bot] 6dc4d8de20 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to afebf4c (#351) 2026-08-28 15:24:17 +02:00
renovate[bot] d0ad4623a9 chore(deps): update all non-major dependencies (#355) 2026-08-28 15:24:08 +02:00
renovate[bot] 203d2f72bf chore(deps): update all non-major dependencies (#352) 2026-08-19 09:23:05 +02:00
Henrique Dias f869dd6276 Merge commit from fork
* fix: resolve dot segments before checking path rules (GHSA-chxv-mvjv-f92j)

* fix: match trailing-slash path rules against the bare collection

* fix: match destination rules against the URL path when no prefix is set

* fix: restrict collections named by a rule without granting access to them

* docs: cleanup
2026-08-05 10:18:13 +02:00
Henrique Dias c04649bf40 docs: add security policy 2026-08-02 07:28:17 +02:00
snowy_smile 390fe21ed9 fix: use slash-separated lock paths on Windows (#344) 2026-07-25 07:15:27 +02:00
renovate[bot] 44e5e02dd3 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to d701c51 (#349) 2026-07-25 07:14:36 +02:00
renovate[bot] d59dd02f96 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to ff03daf (#347) 2026-07-19 09:19:16 +02:00
renovate[bot] 10183d09bc chore(deps): update actions/setup-go action to v7 (#348) 2026-07-19 09:16:47 +02:00
16 changed files with 549 additions and 113 deletions
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- run: go build .
env:
CGO_ENABLED: '0'
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
+2 -2
View File
@@ -15,9 +15,9 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
+14 -5
View File
@@ -10,12 +10,21 @@ on:
jobs:
test:
name: test
runs-on: ubuntu-latest
name: test (${{ matrix.os }})
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
- name: Run test with coverage
go-version: "1.27.x"
- name: Run test with race detector and coverage
if: runner.os != 'Windows'
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Run test with coverage
if: runner.os == 'Windows'
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
+11 -1
View File
@@ -152,6 +152,8 @@ cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
# Allow Private Network Access preflight requests. Default is 'false'.
allow_private_network: false
# The following are the default CORS settings when it is enabled.
allowed_hosts:
- '*'
@@ -230,9 +232,17 @@ users:
# noPassword: true
```
### Rules
Rules are matched against the request path after dot segments have been resolved, so `/public/../secret/file` is matched as `/secret/file`. The last rule that matches wins.
A `path` rule is a prefix match. A rule written with a trailing slash also covers the collection it names, so `path: /secret/` applies to a request for `/secret` as well. Such a rule can only restrict that collection: acting on the collection itself also requires the permissions that apply outside the rule, since the operation takes place in the parent collection.
A `regex` rule is matched literally against the path, and gets none of the above handling. In particular `regex: "^/secret/"` does **not** match a request for `/secret` itself. Write `regex: "^/secret(/|$)"` if you want to cover the collection too.
### CORS
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `allow_private_network` to `true` to allow Private-Network-Access preflight requests. Setting `credentials` to `true` will allow you to:
1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax.
+20
View File
@@ -0,0 +1,20 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 5.x | :white_check_mark: |
| < 5.x | :x: |
## Reporting a Vulnerability
- **Critical:** report privately via the [Security](https://github.com/hacdias/webdav/security) page.
- **Non-critical:** open a public issue so the community can help; it'll be labeled it as a security issue.
Please include, where possible:
- The commit the issue was found at
- A plaintext proof of concept (no binaries)
- Steps to reproduce
- Recommended remediation, if any
+7 -10
View File
@@ -1,6 +1,6 @@
module github.com/hacdias/webdav/v5
go 1.25.0
go 1.26.0
require (
github.com/coreos/go-systemd/v22 v22.7.0
@@ -9,28 +9,25 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/studio-b12/gowebdav v0.13.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.54.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8
golang.org/x/net v0.57.0
golang.org/x/crypto v0.55.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb
golang.org/x/net v0.58.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/text v0.41.0 // indirect
)
+12 -19
View File
@@ -1,8 +1,6 @@
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -19,8 +17,6 @@ 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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
@@ -41,8 +37,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -53,20 +49,17 @@ 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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8 h1:OZy0hsjD/gbnVKENxnVK6I3e4bdKwYy4R3dfdKosYyA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb h1:3OQBwC/IAO9+1/yWsvWkE1Lw5InpHGlZxXZHUcWBouA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+7 -6
View File
@@ -383,10 +383,11 @@ type Log struct {
}
type CORS struct {
Enabled bool
Credentials bool
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
Enabled bool
Credentials bool
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
+17 -4
View File
@@ -55,17 +55,25 @@ func TestConfigDefaults(t *testing.T) {
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
require.False(t, cfg.CORS.AllowPrivateNetwork)
}
func TestConfigCascade(t *testing.T) {
t.Parallel()
// Directories are resolved to absolute paths, which differ by platform
// (for example "/" becomes the current drive root on Windows).
rootDirectory, err := filepath.Abs("/")
require.NoError(t, err)
basicDirectory, err := filepath.Abs("/basic")
require.NoError(t, err)
check := func(t *testing.T, cfg *Config) {
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.Equal(t, rootDirectory, cfg.Directory)
require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
@@ -73,14 +81,14 @@ func TestConfigCascade(t *testing.T) {
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.Equal(t, rootDirectory, cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1)
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.Equal(t, basicDirectory, cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0)
}
@@ -349,6 +357,7 @@ func TestConfigKeys(t *testing.T) {
cors:
enabled: true
credentials: true
allow_private_network: true
allowed_headers:
- Depth
allowed_hosts:
@@ -362,6 +371,7 @@ cors:
require.True(t, cfg.CORS.Enabled)
require.True(t, cfg.CORS.Credentials)
require.True(t, cfg.CORS.AllowPrivateNetwork)
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
@@ -485,8 +495,11 @@ func TestConfigEnv(t *testing.T) {
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
expectedDirectory, err := filepath.Abs("/test")
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory)
assert.Equal(t, expectedDirectory, cfg.Directory)
assert.Equal(t, true, cfg.Debug)
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
+9 -9
View File
@@ -3,7 +3,6 @@ package lib
import (
"net/http"
"os"
"strings"
"github.com/rs/cors"
"go.uber.org/zap"
@@ -49,12 +48,13 @@ func NewHandler(c *Config) (http.Handler, error) {
if c.CORS.Enabled {
return cors.New(cors.Options{
AllowCredentials: c.CORS.Credentials,
AllowedOrigins: c.CORS.AllowedHosts,
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
OptionsPassthrough: false,
AllowCredentials: c.CORS.Credentials,
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
AllowedOrigins: c.CORS.AllowedHosts,
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
OptionsPassthrough: false,
}).Handler(h), nil
}
@@ -167,8 +167,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// collection resources.
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
if (r.Method == "GET" || r.Method == "HEAD") && strings.HasPrefix(r.URL.Path, user.Prefix) {
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
if r.Method == "GET" || r.Method == "HEAD" {
info, err := user.FileSystem.Stat(r.Context(), req.path)
if err == nil && info.IsDir() {
r.Method = "PROPFIND"
+285 -20
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
@@ -85,6 +86,29 @@ func TestServerDefaults(t *testing.T) {
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
}
func TestServerCORSPrivateNetwork(t *testing.T) {
t.Parallel()
srv := makeTestServer(t, `
cors:
enabled: true
allow_private_network: true`)
defer srv.Close()
req, err := http.NewRequest(http.MethodOptions, srv.URL, nil)
require.NoError(t, err)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
req.Header.Set("Access-Control-Request-Private-Network", "true")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Equal(t, "true", resp.Header.Get("Access-Control-Allow-Private-Network"))
}
func TestServerPartialUpdateOptions(t *testing.T) {
t.Parallel()
@@ -486,13 +510,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) {
func TestServerListingCharacters(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
contents := map[string][]byte{
"富/foo.txt": []byte("foo"),
"你好.txt": []byte("bar"),
"z*.txt": []byte("zbar"),
"foo.txt": []byte("foo"),
"🌹.txt": []byte("foo"),
})
}
expectedNames := []string{"foo.txt", "你好.txt", "富", "🌹.txt"}
if runtime.GOOS != "windows" {
// Asterisks are invalid in Windows filenames.
contents["z*.txt"] = []byte("zbar")
expectedNames = append(expectedNames, "z*.txt")
}
sort.Strings(expectedNames)
dir := makeTestDirectory(t, contents)
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
@@ -500,28 +532,21 @@ func TestServerListingCharacters(t *testing.T) {
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 5)
require.Len(t, files, len(expectedNames))
names := []string{
files[0].Name(),
files[1].Name(),
files[2].Name(),
files[3].Name(),
files[4].Name(),
names := make([]string, len(files))
for i, file := range files {
names[i] = file.Name()
}
sort.Strings(names)
require.Equal(t, []string{
"foo.txt",
"z*.txt",
"你好.txt",
"富",
"🌹.txt",
}, names)
require.Equal(t, expectedNames, names)
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
if runtime.GOOS != "windows" {
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
}
}
func TestServerAuthentication(t *testing.T) {
@@ -1010,3 +1035,243 @@ users:
require.ErrorContains(t, err, "403")
})
}
func TestServerRulesDotSegments(t *testing.T) {
t.Parallel()
// Sends a request with an unmodified request-target, since a WebDAV client
// would normalize the dot segments away before they reach the server.
do := func(t *testing.T, method, url string, header map[string]string) int {
t.Helper()
req, err := http.NewRequest(method, url, nil)
require.NoError(t, err)
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode
}
makeServer := func(t *testing.T) (*httptest.Server, string) {
dir := makeTestDirectory(t, map[string][]byte{
"public/pub.txt": []byte("public"),
"secret/flag.txt": []byte("secret"),
"secret/keep.txt": []byte("keep"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/secret/"
permissions: none
`, dir))
t.Cleanup(srv.Close)
return srv, dir
}
t.Run("Source Path", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
for _, path := range []string{
"/secret/flag.txt",
"/public/../secret/flag.txt",
"/public/%2e%2e/secret/flag.txt",
"/public/../secret/",
"/secret/.",
"/secret/%2e",
} {
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+path, nil), path)
}
require.Equal(t, http.StatusForbidden, do(t, "PUT", srv.URL+"/public/%2e%2e/secret/new.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "DELETE", srv.URL+"/public/%2e%2e/secret/keep.txt", nil))
// A request that resolves outside a rule must still succeed.
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/secret/../public/pub.txt", nil))
})
t.Run("Destination Header", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
for _, destination := range []string{
srv.URL + "/public/%2e%2e/secret/moved.txt",
srv.URL + "/public/../secret/moved.txt",
"/public/%2e%2e/secret/moved.txt",
} {
code := do(t, "MOVE", srv.URL+"/public/pub.txt", map[string]string{
"Destination": destination,
"Overwrite": "T",
})
require.Equal(t, http.StatusForbidden, code, destination)
}
})
t.Run("Regex Rule", func(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"public/pub.txt": []byte("public"),
"secret/flag.txt": []byte("secret"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- regex: "^/secret/"
permissions: none
`, dir))
defer srv.Close()
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/secret/flag.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/public/%2e%2e/secret/flag.txt", nil))
})
t.Run("Directory Mounts", func(t *testing.T) {
t.Parallel()
alpha := makeTestDirectory(t, map[string][]byte{"a.txt": []byte("a")})
beta := makeTestDirectory(t, map[string][]byte{"b.txt": []byte("b")})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- name: alpha
path: %s
- name: beta
path: %s
rules:
- path: "/beta/"
permissions: none
`, alpha, beta))
defer srv.Close()
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/beta/b.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/alpha/%2e%2e/beta/b.txt", nil))
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/alpha/a.txt", nil))
})
t.Run("No Users", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
// Without a users block no authentication runs, so the rule is the only
// access control there is.
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/public/%2e%2e/secret/flag.txt", nil))
})
}
func TestServerRulesBareCollection(t *testing.T) {
t.Parallel()
do := func(t *testing.T, method, url string) int {
t.Helper()
req, err := http.NewRequest(method, url, nil)
require.NoError(t, err)
req.Header.Set("Depth", "1")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode
}
dir := makeTestDirectory(t, map[string][]byte{
"c/secret.txt": []byte("secret"),
"cd/open.txt": []byte("open"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/c/"
permissions: none
`, dir))
defer srv.Close()
// A rule written "/c/" must also cover the collection named without the
// trailing slash, otherwise the denied directory can be listed or deleted.
for _, path := range []string{"/c/", "/c", "/c/secret.txt"} {
require.Equal(t, http.StatusForbidden, do(t, "PROPFIND", srv.URL+path), path)
require.Equal(t, http.StatusForbidden, do(t, "DELETE", srv.URL+path), path)
}
// A sibling whose name merely starts with the same characters is unaffected.
require.Equal(t, http.StatusMultiStatus, do(t, "PROPFIND", srv.URL+"/cd"))
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/cd/open.txt"))
// A rule governs the collection it names, but must not grant access to it
// that would not otherwise exist: removing "/pub" acts on the root, which
// the global permissions still deny.
grantDir := makeTestDirectory(t, map[string][]byte{"pub/x.txt": []byte("x")})
grantSrv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: none
rules:
- path: "/pub/"
permissions: CRUD
`, grantDir))
defer grantSrv.Close()
require.Equal(t, http.StatusNoContent, do(t, "DELETE", grantSrv.URL+"/pub/x.txt"))
require.Equal(t, http.StatusForbidden, do(t, "DELETE", grantSrv.URL+"/pub"))
require.Equal(t, http.StatusForbidden, do(t, "PROPFIND", grantSrv.URL+"/pub"))
}
func TestServerRulesEmptyPrefixDestination(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"public/a.txt": []byte("a"),
"public/b.txt": []byte("b"),
"secret/x.txt": []byte("secret"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
prefix: ""
permissions: CRUD
rules:
- path: "/secret/"
permissions: none
`, dir))
defer srv.Close()
// RFC 4918 has Destination as an absolute URI, so the host must not end up
// in the value the rules are matched against.
for name, destination := range map[string]string{
"absolute": srv.URL + "/secret/moved.txt",
"bare path": "/secret/moved.txt",
"dot": srv.URL + "/public/%2e%2e/secret/moved.txt",
} {
req, err := http.NewRequest("MOVE", srv.URL+"/public/a.txt", nil)
require.NoError(t, err)
req.Header.Set("Destination", destination)
req.Header.Set("Overwrite", "T")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode, name)
}
// A destination outside any rule must still work.
req, err := http.NewRequest("MOVE", srv.URL+"/public/b.txt", nil)
require.NoError(t, err)
req.Header.Set("Destination", srv.URL+"/public/moved.txt")
req.Header.Set("Overwrite", "T")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
+9 -2
View File
@@ -1,6 +1,7 @@
package lib
import (
"path"
"path/filepath"
"time"
@@ -24,7 +25,10 @@ func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
return filepath.Join(directory, name), nil
// Lock names share a slash-separated namespace across users, even
// on Windows where filepath.Join would emit backslashes and break
// descendant-lock matching in the underlying LockSystem.
return path.Join(filepath.ToSlash(directory), name), nil
},
}
}
@@ -44,7 +48,10 @@ func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSy
return "", err
}
return mount.filePath(rest), nil
// filePath returns an OS-native path for real file operations; the
// lock namespace must stay slash-separated so descendant locks match
// on Windows.
return filepath.ToSlash(mount.filePath(rest)), nil
},
}
}
+79
View File
@@ -0,0 +1,79 @@
package lib
import (
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/webdav"
)
func TestLockSystemRootLockProtectsDescendants(t *testing.T) {
t.Parallel()
locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested"))
now := time.Now()
token, err := locks.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, locks.Unlock(time.Now(), token))
})
_, err = locks.Create(now, webdav.LockDetails{
Root: "/child.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
}
func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) {
t.Parallel()
shared := webdav.NewMemLS()
parentDirectory := t.TempDir()
childDirectory := filepath.Join(parentDirectory, "child")
parent := newLockSystem(shared, parentDirectory)
child := newLockSystem(shared, childDirectory)
now := time.Now()
token, err := parent.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, parent.Unlock(time.Now(), token))
})
_, err = child.Create(now, webdav.LockDetails{
Root: "/file.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
// The lock key is slash-separated on every platform, so the child's file
// nests under the parent's root lock rather than diverging on Windows.
key, err := child.resolve("/file.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key)
}
func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) {
t.Parallel()
mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}}
locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts)
key, err := locks.resolve("/docs/report.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key)
require.NotContains(t, key, "\\")
}
+36 -21
View File
@@ -35,6 +35,17 @@ func (r *Rule) Matches(path string) bool {
return strings.HasPrefix(path, r.Path)
}
// matchesCollection checks if [Rule] names path as the collection it governs,
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
// literally and are not considered here.
func (r *Rule) matchesCollection(path string) bool {
if r.Regex != nil || !strings.HasSuffix(r.Path, "/") {
return false
}
return path == strings.TrimSuffix(r.Path, "/")
}
type RulesBehavior string
const (
@@ -67,35 +78,39 @@ func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool
// 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.destination
ruleMatched := false
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(dst) {
ruleMatched = true
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
return false
}
// Only check the first rule that matches, similarly to the source rules.
break
}
}
if !ruleMatched && !p.Permissions.AllowedDestination(r, fileExists) {
if !p.allowedAt(r.destination, func(perms Permissions) bool {
return perms.AllowedDestination(r, fileExists)
}) {
return false
}
}
// Go through rules beginning from the last one, and check the permissions at
// the source. The first matched rule returns.
return p.allowedAt(r.path, func(perms Permissions) bool {
return perms.Allowed(r, fileExists)
})
}
// allowedAt resolves the permissions that govern path and applies check to them.
func (p UserPermissions) allowedAt(path string, check func(Permissions) bool) bool {
// Go through rules beginning from the last one. The first matched rule returns.
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(r.path) {
return p.Rules[i].Permissions.Allowed(r, fileExists)
if p.Rules[i].Matches(path) {
return check(p.Rules[i].Permissions)
}
}
return p.Permissions.Allowed(r, fileExists)
// A rule written with a trailing slash also governs the collection it names,
// so that a rule for "/c/" cannot be evaded by asking for "/c". Such a request
// acts on an entry of the parent collection, so it needs the permissions that
// apply there too. Requiring both means the rule can restrict the collection
// without granting access that would otherwise not exist.
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].matchesCollection(path) {
return check(p.Rules[i].Permissions) && check(p.Permissions)
}
}
return check(p.Permissions)
}
func (p *UserPermissions) Validate() error {
+37 -10
View File
@@ -4,9 +4,39 @@ import (
"errors"
"net/http"
"net/url"
"path"
"strings"
)
// cleanPath resolves dot segments so that the permission checks see the same
// path that the backing file system will ultimately open. The file systems in
// golang.org/x/net/webdav apply path.Clean before joining the backing
// directory, so without this the two layers disagree on which file a request
// names and a rule can be bypassed with e.g. "/public/../secret/file.txt".
func cleanPath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
// path.Clean drops the trailing slash, but rules are prefix matches and are
// commonly written with one, such as "/c/". Dropping it would stop a request
// for the collection itself from matching the rule that names it.
if cleaned != "/" && isCollectionPath(p) {
cleaned += "/"
}
return cleaned
}
// isCollectionPath reports whether p names a collection rather than a resource
// within it. Besides an explicit trailing slash, a trailing "." or ".." segment
// also resolves to the collection itself.
func isCollectionPath(p string) bool {
return strings.HasSuffix(p, "/") || strings.HasSuffix(p, "/.") || strings.HasSuffix(p, "/..")
}
type request struct {
method string
path string
@@ -24,6 +54,11 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
return nil, errors.New("invalid destination header")
}
// RFC 4918, section 10.3, has Destination as an absolute URI, which is
// what clients send in practice. Only the path is relevant here, and
// taking it unconditionally keeps the host out of the matched value.
destination = u.Path
if prefix != "" {
destination = strings.TrimPrefix(u.Path, prefix)
if len(destination) >= len(u.Path) {
@@ -31,11 +66,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
}
}
if !strings.HasPrefix(destination, "/") {
destination = "/" + destination
}
ctx.destination = destination
ctx.destination = cleanPath(destination)
}
path := r.URL.Path
@@ -47,11 +78,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
}
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
ctx.path = path
ctx.path = cleanPath(path)
return ctx, nil
}