mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-23 19:51:53 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f869dd6276 | ||
|
|
c04649bf40 | ||
|
|
390fe21ed9 | ||
|
|
44e5e02dd3 | ||
|
|
d59dd02f96 | ||
|
|
10183d09bc |
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- run: go build .
|
- run: go build .
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- uses: golangci/golangci-lint-action@v9
|
- uses: golangci/golangci-lint-action@v9
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- uses: goreleaser/goreleaser-action@v7
|
- uses: goreleaser/goreleaser-action@v7
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test (${{ matrix.os }})
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-latest
|
||||||
|
- windows-latest
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.26.x"
|
||||||
- name: Run test with coverage
|
- name: Run test with race detector and coverage
|
||||||
|
if: runner.os != 'Windows'
|
||||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
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 ./...
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ users:
|
|||||||
# noPassword: true
|
# 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
|
### 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 `credentials` to `true` will allow you to:
|
||||||
|
|||||||
+20
@@ -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
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/studio-b12/gowebdav v0.13.0
|
github.com/studio-b12/gowebdav v0.13.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
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-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260709184058-243e02a382f8/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
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=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
|||||||
+14
-4
@@ -60,12 +60,19 @@ func TestConfigDefaults(t *testing.T) {
|
|||||||
func TestConfigCascade(t *testing.T) {
|
func TestConfigCascade(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
check := func(t *testing.T, cfg *Config) {
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
require.False(t, cfg.Permissions.Delete)
|
require.False(t, cfg.Permissions.Delete)
|
||||||
require.False(t, cfg.Permissions.Update)
|
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.Rules, 1)
|
||||||
|
|
||||||
require.Len(t, cfg.Users, 2)
|
require.Len(t, cfg.Users, 2)
|
||||||
@@ -73,14 +80,14 @@ func TestConfigCascade(t *testing.T) {
|
|||||||
require.True(t, cfg.Users[0].Permissions.Create)
|
require.True(t, cfg.Users[0].Permissions.Create)
|
||||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[0].Permissions.Update)
|
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.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
|
||||||
require.True(t, cfg.Users[1].Permissions.Read)
|
require.True(t, cfg.Users[1].Permissions.Read)
|
||||||
require.False(t, cfg.Users[1].Permissions.Create)
|
require.False(t, cfg.Users[1].Permissions.Create)
|
||||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[1].Permissions.Update)
|
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)
|
require.Len(t, cfg.Users[1].Rules, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,8 +492,11 @@ func TestConfigEnv(t *testing.T) {
|
|||||||
cfg, err := ParseConfig("", nil)
|
cfg, err := ParseConfig("", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedDirectory, err := filepath.Abs("/test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1234, cfg.Port)
|
assert.Equal(t, 1234, cfg.Port)
|
||||||
assert.Equal(t, "/test", cfg.Directory)
|
assert.Equal(t, expectedDirectory, cfg.Directory)
|
||||||
assert.Equal(t, true, cfg.Debug)
|
assert.Equal(t, true, cfg.Debug)
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,6 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -167,8 +166,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// collection resources.
|
// collection resources.
|
||||||
//
|
//
|
||||||
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
// 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) {
|
if r.Method == "GET" || r.Method == "HEAD" {
|
||||||
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
|
info, err := user.FileSystem.Stat(r.Context(), req.path)
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
r.Method = "PROPFIND"
|
r.Method = "PROPFIND"
|
||||||
|
|
||||||
|
|||||||
+262
-20
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -486,13 +487,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
|||||||
func TestServerListingCharacters(t *testing.T) {
|
func TestServerListingCharacters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := makeTestDirectory(t, map[string][]byte{
|
contents := map[string][]byte{
|
||||||
"富/foo.txt": []byte("foo"),
|
"富/foo.txt": []byte("foo"),
|
||||||
"你好.txt": []byte("bar"),
|
"你好.txt": []byte("bar"),
|
||||||
"z*.txt": []byte("zbar"),
|
|
||||||
"foo.txt": []byte("foo"),
|
"foo.txt": []byte("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)
|
srv := makeTestServer(t, "directory: "+dir)
|
||||||
client := gowebdav.NewClient(srv.URL, "", "")
|
client := gowebdav.NewClient(srv.URL, "", "")
|
||||||
@@ -500,28 +509,21 @@ func TestServerListingCharacters(t *testing.T) {
|
|||||||
// By default, reading permissions.
|
// By default, reading permissions.
|
||||||
files, err := client.ReadDir("/")
|
files, err := client.ReadDir("/")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, files, 5)
|
require.Len(t, files, len(expectedNames))
|
||||||
|
|
||||||
names := []string{
|
names := make([]string, len(files))
|
||||||
files[0].Name(),
|
for i, file := range files {
|
||||||
files[1].Name(),
|
names[i] = file.Name()
|
||||||
files[2].Name(),
|
|
||||||
files[3].Name(),
|
|
||||||
files[4].Name(),
|
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
require.Equal(t, []string{
|
require.Equal(t, expectedNames, names)
|
||||||
"foo.txt",
|
|
||||||
"z*.txt",
|
|
||||||
"你好.txt",
|
|
||||||
"富",
|
|
||||||
"🌹.txt",
|
|
||||||
}, names)
|
|
||||||
|
|
||||||
data, err := client.Read("/z*.txt")
|
if runtime.GOOS != "windows" {
|
||||||
require.NoError(t, err)
|
data, err := client.Read("/z*.txt")
|
||||||
require.EqualValues(t, []byte("zbar"), data)
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, []byte("zbar"), data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerAuthentication(t *testing.T) {
|
func TestServerAuthentication(t *testing.T) {
|
||||||
@@ -1010,3 +1012,243 @@ users:
|
|||||||
require.ErrorContains(t, err, "403")
|
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
@@ -1,6 +1,7 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,7 +25,10 @@ func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
|
|||||||
return &lockSystem{
|
return &lockSystem{
|
||||||
LockSystem: ls,
|
LockSystem: ls,
|
||||||
resolve: func(name string) (string, error) {
|
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 "", 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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -35,6 +35,17 @@ func (r *Rule) Matches(path string) bool {
|
|||||||
return strings.HasPrefix(path, r.Path)
|
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
|
type RulesBehavior string
|
||||||
|
|
||||||
const (
|
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,
|
// 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.
|
// we fail immediately. If no rule matches, we check the global permissions.
|
||||||
if r.method == "COPY" || r.method == "MOVE" {
|
if r.method == "COPY" || r.method == "MOVE" {
|
||||||
dst := r.destination
|
if !p.allowedAt(r.destination, func(perms Permissions) bool {
|
||||||
ruleMatched := false
|
return perms.AllowedDestination(r, fileExists)
|
||||||
|
}) {
|
||||||
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) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go through rules beginning from the last one, and check the permissions at
|
return p.allowedAt(r.path, func(perms Permissions) bool {
|
||||||
// the source. The first matched rule returns.
|
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-- {
|
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||||
if p.Rules[i].Matches(r.path) {
|
if p.Rules[i].Matches(path) {
|
||||||
return p.Rules[i].Permissions.Allowed(r, fileExists)
|
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 {
|
func (p *UserPermissions) Validate() error {
|
||||||
|
|||||||
+37
-10
@@ -4,9 +4,39 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"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 {
|
type request struct {
|
||||||
method string
|
method string
|
||||||
path string
|
path string
|
||||||
@@ -24,6 +54,11 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
return nil, errors.New("invalid destination header")
|
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 != "" {
|
if prefix != "" {
|
||||||
destination = strings.TrimPrefix(u.Path, prefix)
|
destination = strings.TrimPrefix(u.Path, prefix)
|
||||||
if len(destination) >= len(u.Path) {
|
if len(destination) >= len(u.Path) {
|
||||||
@@ -31,11 +66,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(destination, "/") {
|
ctx.destination = cleanPath(destination)
|
||||||
destination = "/" + destination
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.destination = destination
|
|
||||||
}
|
}
|
||||||
|
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
@@ -47,11 +78,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
ctx.path = cleanPath(path)
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.path = path
|
|
||||||
|
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user