mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Recursive COPY, MOVE, DELETE and PROPFIND were checked only against the requested path, reaching descendants their rules deny. A broad rule shadowed a narrower one naming a collection. LOCK was allowed on any permission, so a read-only user could create files and block writers. Rules compared case where the backing file system does not.
1591 lines
44 KiB
Go
1591 lines
44 KiB
Go
package lib
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"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 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()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("hello world"),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodOptions, srv.URL+"/foo.txt", nil)
|
|
require.NoError(t, err)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
require.Contains(t, resp.Header.Get("DAV"), "sabredav-partialupdate")
|
|
require.Contains(t, resp.Header.Get("Allow"), "PATCH")
|
|
require.Equal(t, partialUpdateContentType, resp.Header.Get("Accept-Patch"))
|
|
}
|
|
|
|
func TestServerPatchPartialUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
initialData string
|
|
body string
|
|
updateRange string
|
|
wantData string
|
|
}{{
|
|
name: "start",
|
|
initialData: "hello world",
|
|
body: "DAV",
|
|
updateRange: "bytes=6-",
|
|
wantData: "hello DAVld",
|
|
}, {
|
|
name: "suffix",
|
|
initialData: "hello world",
|
|
body: "DAV",
|
|
updateRange: "bytes=-5",
|
|
wantData: "hello DAVld",
|
|
}, {
|
|
name: "append",
|
|
initialData: "hello",
|
|
body: " world",
|
|
updateRange: "append",
|
|
wantData: "hello world",
|
|
}, {
|
|
name: "suffix_zero",
|
|
initialData: "hello",
|
|
body: " world",
|
|
updateRange: "bytes=-0",
|
|
wantData: "hello world",
|
|
}}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte(tc.initialData),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader(tc.body))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", tc.updateRange)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.wantData, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerPatchPartialUpdateCreatesSparseFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, nil)
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest("PATCH", srv.URL+"/new.bin", strings.NewReader("x"))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", "bytes=3-")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
data, err := os.ReadFile(filepath.Join(dir, "new.bin"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte{0, 0, 0, 'x'}, data)
|
|
}
|
|
|
|
func TestServerPutContentRangePartialUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("hello world"),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodPut, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Range", "bytes 6-8/*")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "hello DAVld", string(data))
|
|
}
|
|
|
|
func TestServerPartialUpdateErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
method string
|
|
body string
|
|
contentLength int64
|
|
path string
|
|
headerName string
|
|
updateRange string
|
|
contentRange string
|
|
wantStatus int
|
|
}{{
|
|
name: "patch_missing_content_length",
|
|
method: "PATCH",
|
|
body: "DAV",
|
|
contentLength: -1,
|
|
updateRange: "bytes=6-8",
|
|
wantStatus: http.StatusLengthRequired,
|
|
}, {
|
|
name: "patch_invalid_range",
|
|
method: "PATCH",
|
|
body: "DAV",
|
|
updateRange: "bytes=8-6",
|
|
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
|
}, {
|
|
name: "patch_length_mismatch",
|
|
method: "PATCH",
|
|
body: "TOOLONG",
|
|
updateRange: "bytes=6-8",
|
|
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
|
}, {
|
|
name: "put_content_range_length_mismatch",
|
|
method: http.MethodPut,
|
|
body: "TOOLONG",
|
|
contentLength: -1,
|
|
contentRange: "bytes 6-8/*",
|
|
wantStatus: http.StatusRequestedRangeNotSatisfiable,
|
|
}, {
|
|
name: "if_none_match",
|
|
method: "PATCH",
|
|
body: "DAV",
|
|
headerName: "If-None-Match",
|
|
updateRange: "bytes=0-2",
|
|
wantStatus: http.StatusPreconditionFailed,
|
|
}, {
|
|
name: "if_match",
|
|
method: "PATCH",
|
|
path: "/missing.txt",
|
|
body: "DAV",
|
|
headerName: "If-Match",
|
|
updateRange: "bytes=0-2",
|
|
wantStatus: http.StatusPreconditionFailed,
|
|
}}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("hello world"),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
var body io.Reader = strings.NewReader(tc.body)
|
|
if tc.contentLength < 0 {
|
|
body = io.NopCloser(strings.NewReader(tc.body))
|
|
}
|
|
path := tc.path
|
|
if path == "" {
|
|
path = "/foo.txt"
|
|
}
|
|
req, err := http.NewRequest(tc.method, srv.URL+path, body)
|
|
require.NoError(t, err)
|
|
if tc.contentLength < 0 {
|
|
req.ContentLength = tc.contentLength
|
|
}
|
|
if tc.method == "PATCH" {
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", tc.updateRange)
|
|
}
|
|
if tc.contentRange != "" {
|
|
req.Header.Set("Content-Range", tc.contentRange)
|
|
}
|
|
if tc.headerName != "" {
|
|
req.Header.Set(tc.headerName, "*")
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
|
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "hello world", string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerPartialUpdateETagPreconditions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
method string
|
|
headerName string
|
|
headerValue func(string) string
|
|
contentRange string
|
|
wantStatus int
|
|
wantData string
|
|
}{{
|
|
name: "if_match_matches",
|
|
method: "PATCH",
|
|
headerName: "If-Match",
|
|
headerValue: func(etag string) string { return etag },
|
|
wantStatus: http.StatusNoContent,
|
|
wantData: "hello DAVld",
|
|
}, {
|
|
name: "if_match_mismatch",
|
|
method: "PATCH",
|
|
headerName: "If-Match",
|
|
headerValue: func(string) string { return `"definitely-wrong"` },
|
|
wantStatus: http.StatusPreconditionFailed,
|
|
wantData: "hello world",
|
|
}, {
|
|
name: "if_match_list_matches",
|
|
method: "PATCH",
|
|
headerName: "If-Match",
|
|
headerValue: func(etag string) string { return `"definitely-wrong", ` + etag },
|
|
wantStatus: http.StatusNoContent,
|
|
wantData: "hello DAVld",
|
|
}, {
|
|
name: "if_none_match_matches",
|
|
method: "PATCH",
|
|
headerName: "If-None-Match",
|
|
headerValue: func(etag string) string { return etag },
|
|
wantStatus: http.StatusPreconditionFailed,
|
|
wantData: "hello world",
|
|
}, {
|
|
name: "if_none_match_mismatch",
|
|
method: "PATCH",
|
|
headerName: "If-None-Match",
|
|
headerValue: func(string) string { return `"definitely-wrong"` },
|
|
wantStatus: http.StatusNoContent,
|
|
wantData: "hello DAVld",
|
|
}, {
|
|
name: "put_content_range_if_match_mismatch",
|
|
method: http.MethodPut,
|
|
headerName: "If-Match",
|
|
headerValue: func(string) string { return `"definitely-wrong"` },
|
|
contentRange: "bytes 6-8/*",
|
|
wantStatus: http.StatusPreconditionFailed,
|
|
wantData: "hello world",
|
|
}}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("hello world"),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest(http.MethodHead, srv.URL+"/foo.txt", nil)
|
|
require.NoError(t, err)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
etag := resp.Header.Get("ETag")
|
|
require.NotEmpty(t, etag)
|
|
|
|
req, err = http.NewRequest(tc.method, srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
|
require.NoError(t, err)
|
|
if tc.method == "PATCH" {
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", "bytes=6-8")
|
|
}
|
|
if tc.contentRange != "" {
|
|
req.Header.Set("Content-Range", tc.contentRange)
|
|
}
|
|
req.Header.Set(tc.headerName, tc.headerValue(etag))
|
|
resp, err = http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
|
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.wantData, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const createLockBody = `<?xml version="1.0" encoding="utf-8" ?>
|
|
<D:lockinfo xmlns:D='DAV:'>
|
|
<D:lockscope><D:exclusive/></D:lockscope>
|
|
<D:locktype><D:write/></D:locktype>
|
|
<D:owner>test</D:owner>
|
|
</D:lockinfo>`
|
|
|
|
testCases := []struct {
|
|
name string
|
|
lockPath string
|
|
depth string
|
|
ifPath string
|
|
}{{
|
|
name: "file",
|
|
lockPath: "/foo.txt",
|
|
depth: "0",
|
|
ifPath: "/foo.txt",
|
|
}, {
|
|
name: "root",
|
|
lockPath: "/",
|
|
depth: "infinity",
|
|
ifPath: "/",
|
|
}}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("hello world"),
|
|
})
|
|
srv := makeTestServer(t, "directory: "+dir+"\npermissions: CRUD")
|
|
defer srv.Close()
|
|
|
|
req, err := http.NewRequest("LOCK", srv.URL+tc.lockPath, strings.NewReader(createLockBody))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/xml")
|
|
req.Header.Set("Depth", tc.depth)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
lockToken := resp.Header.Get("Lock-Token")
|
|
|
|
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", "bytes=6-8")
|
|
resp, err = http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
require.Equal(t, 423, resp.StatusCode)
|
|
|
|
req, err = http.NewRequest("PATCH", srv.URL+"/foo.txt", strings.NewReader("DAV"))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", partialUpdateContentType)
|
|
req.Header.Set("X-Update-Range", "bytes=6-8")
|
|
req.Header.Set("If", fmt.Sprintf("<%s%s> (%s)", srv.URL, tc.ifPath, lockToken))
|
|
resp, err = http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { _ = resp.Body.Close() }()
|
|
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
|
|
|
data, err := os.ReadFile(filepath.Join(dir, "foo.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "hello DAVld", string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerListingCharacters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
contents := map[string][]byte{
|
|
"富/foo.txt": []byte("foo"),
|
|
"你好.txt": []byte("bar"),
|
|
"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, "", "")
|
|
|
|
// By default, reading permissions.
|
|
files, err := client.ReadDir("/")
|
|
require.NoError(t, err)
|
|
require.Len(t, files, len(expectedNames))
|
|
|
|
names := make([]string, len(files))
|
|
for i, file := range files {
|
|
names[i] = file.Name()
|
|
}
|
|
sort.Strings(names)
|
|
|
|
require.Equal(t, expectedNames, names)
|
|
|
|
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) {
|
|
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 TestServerRulesRestrictive(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")
|
|
|
|
// A rule denying a path also hides it from the collection containing it.
|
|
files, err := client.ReadDir("/")
|
|
require.NoError(t, err)
|
|
require.Len(t, files, 4)
|
|
for _, f := range files {
|
|
require.NotEqual(t, "c", f.Name())
|
|
}
|
|
|
|
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")
|
|
|
|
err = client.MkdirAll("/d/foo/bar", 0666)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Write("/d/foo/bar/test.txt", []byte("test"), 0666)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestServerRulesAdditive(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"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: none
|
|
|
|
users:
|
|
- username: basic
|
|
password: basic
|
|
rules:
|
|
- regex: "^.+.js$"
|
|
permissions: R
|
|
- path: "/a/foo.txt"
|
|
permissions: CRU
|
|
- path: "/b/"
|
|
permissions: D
|
|
`, dir))
|
|
|
|
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
|
|
|
_, err := client.ReadDir("/")
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Write("/foo.txt", []byte("new"), 0666)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Write("/new.txt", []byte("new"), 0666)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Copy("/bar.js", "/a/foo.txt", true)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Remove("/b/foo.txt")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestServerRulesPrefix(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
|
|
prefix: /prefix
|
|
|
|
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")
|
|
|
|
// A rule denying a path also hides it from the collection containing it.
|
|
files, err := client.ReadDir("/prefix")
|
|
require.NoError(t, err)
|
|
require.Len(t, files, 4)
|
|
for _, f := range files {
|
|
require.NotEqual(t, "c", f.Name())
|
|
}
|
|
|
|
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Write("/prefix/new.txt", []byte("new"), 0666)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Copy("/prefix/bar.js", "/prefix/b/bar.js", false)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Copy("/prefix/bar.js", "/prefix/bar.jsx", false)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Copy("/prefix/b/foo.txt", "/prefix/foo1.txt", false)
|
|
require.NoError(t, err)
|
|
|
|
err = client.Rename("/prefix/b/foo.txt", "/prefix/foo2.txt", false)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
_, err = client.Read("/prefix/a/foo.txt")
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Write("/prefix/a/foo.js", []byte("new"), 0666)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Write("/prefix/b/foo.txt", []byte("new"), 0666)
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
_, err = client.ReadDir("/prefix/c")
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
_, err = client.Read("/prefix/c/a.txt")
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
err = client.Write("/prefix/c/b.txt", []byte("new"), 0666)
|
|
require.ErrorContains(t, err, "403")
|
|
}
|
|
|
|
func TestServerMultiDirectories(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dirC := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("foo"),
|
|
"folder/nested.txt": []byte("nested"),
|
|
"public/access/ok.txt": []byte("ok"),
|
|
})
|
|
dirD := makeTestDirectory(t, map[string][]byte{
|
|
"bar.txt": []byte("bar"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
permissions: CRUD
|
|
directories:
|
|
- c: %s
|
|
- d: %s
|
|
`, dirC, dirD))
|
|
client := gowebdav.NewClient(srv.URL, "", "")
|
|
|
|
files, err := client.ReadDir("/")
|
|
require.NoError(t, err)
|
|
require.Len(t, files, 2)
|
|
require.Equal(t, "c", files[0].Name())
|
|
require.Equal(t, "d", files[1].Name())
|
|
|
|
data, err := client.Read("/c/foo.txt")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("foo"), data)
|
|
|
|
data, err = client.Read("/d/bar.txt")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("bar"), data)
|
|
|
|
err = client.Copy("/c/foo.txt", "/d/copied.txt", false)
|
|
require.NoError(t, err)
|
|
data, err = os.ReadFile(filepath.Join(dirD, "copied.txt"))
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("foo"), data)
|
|
|
|
err = client.Rename("/c/foo.txt", "/d/moved.txt", false)
|
|
require.NoError(t, err)
|
|
require.NoFileExists(t, filepath.Join(dirC, "foo.txt"))
|
|
data, err = os.ReadFile(filepath.Join(dirD, "moved.txt"))
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("foo"), data)
|
|
|
|
err = client.Rename("/d/bar.txt", "/d/renamed.txt", false)
|
|
require.NoError(t, err)
|
|
require.NoFileExists(t, filepath.Join(dirD, "bar.txt"))
|
|
data, err = os.ReadFile(filepath.Join(dirD, "renamed.txt"))
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("bar"), data)
|
|
|
|
err = client.Rename("/c/folder", "/d/folder", false)
|
|
require.NoError(t, err)
|
|
require.NoDirExists(t, filepath.Join(dirC, "folder"))
|
|
data, err = os.ReadFile(filepath.Join(dirD, "folder", "nested.txt"))
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("nested"), data)
|
|
|
|
require.ErrorContains(t, client.Remove("/c"), "405")
|
|
require.Error(t, client.Write("/c", []byte("blocked"), 0666))
|
|
require.ErrorContains(t, client.Rename("/d", "/c/d", false), "403")
|
|
}
|
|
|
|
func TestServerMultiDirectoriesRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dirC := makeTestDirectory(t, map[string][]byte{
|
|
"public/access/ok.txt": []byte("ok"),
|
|
})
|
|
dirD := makeTestDirectory(t, map[string][]byte{
|
|
"public/access/no.txt": []byte("no"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
permissions: none
|
|
directories:
|
|
- c: %s
|
|
- d: %s
|
|
rules:
|
|
- path: /c/public/access/
|
|
permissions: R
|
|
`, dirC, dirD))
|
|
client := gowebdav.NewClient(srv.URL, "", "")
|
|
|
|
data, err := client.Read("/c/public/access/ok.txt")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("ok"), data)
|
|
|
|
_, err = client.Read("/d/public/access/no.txt")
|
|
require.ErrorContains(t, err, "403")
|
|
|
|
_, err = client.Read("/public/access/ok.txt")
|
|
require.ErrorContains(t, err, "403")
|
|
}
|
|
|
|
func TestServerMultiDirectoriesPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dirC := makeTestDirectory(t, map[string][]byte{
|
|
"foo.txt": []byte("foo"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
permissions: R
|
|
prefix: /prefix
|
|
directories:
|
|
- c: %s
|
|
`, dirC))
|
|
client := gowebdav.NewClient(srv.URL, "", "")
|
|
|
|
files, err := client.ReadDir("/prefix")
|
|
require.NoError(t, err)
|
|
require.Len(t, files, 1)
|
|
require.Equal(t, "c", files[0].Name())
|
|
|
|
data, err := client.Read("/prefix/c/foo.txt")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, []byte("foo"), data)
|
|
}
|
|
|
|
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")
|
|
})
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
// doRequest sends a raw request, for what gowebdav does not expose directly.
|
|
// An empty username sends no credentials.
|
|
func doRequest(t *testing.T, method, url, username, password string, headers map[string]string, body string) (int, string) {
|
|
t.Helper()
|
|
|
|
var reader io.Reader
|
|
if body != "" {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, url, reader)
|
|
require.NoError(t, err)
|
|
|
|
if username != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
require.NoError(t, resp.Body.Close())
|
|
|
|
return resp.StatusCode, string(data)
|
|
}
|
|
|
|
const exclusiveWriteLock = `<?xml version="1.0" encoding="utf-8" ?>
|
|
<D:lockinfo xmlns:D="DAV:">
|
|
<D:lockscope><D:exclusive/></D:lockscope>
|
|
<D:locktype><D:write/></D:locktype>
|
|
<D:owner>tester</D:owner>
|
|
</D:lockinfo>`
|
|
|
|
// TestServerRulesShadowedCollection covers a broader rule shadowing one that
|
|
// names a collection: resolving "/data/secret" through "/data/" would skip the
|
|
// deny rule, leaving the collection listable, relocatable and removable.
|
|
func TestServerRulesShadowedCollection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
config string
|
|
username string
|
|
}{
|
|
{
|
|
name: "path rule shadows",
|
|
config: `
|
|
permissions: none
|
|
rules:
|
|
- path: "/data/"
|
|
permissions: CRUD
|
|
- path: "/data/secret/"
|
|
permissions: none`,
|
|
},
|
|
{
|
|
name: "regex rule shadows",
|
|
config: `
|
|
permissions: none
|
|
rules:
|
|
- regex: "^/data/"
|
|
permissions: CRUD
|
|
- path: "/data/secret/"
|
|
permissions: none`,
|
|
},
|
|
{
|
|
// The shadowing rule need not sit next to the rule it shadows.
|
|
name: "appended global rule shadows",
|
|
username: "basic",
|
|
config: `
|
|
permissions: none
|
|
rules:
|
|
- path: "/data/"
|
|
permissions: CRUD
|
|
users:
|
|
- username: basic
|
|
password: basic
|
|
rulesBehavior: append
|
|
rules:
|
|
- path: "/data/secret/"
|
|
permissions: none`,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"data/secret/flag.txt": []byte("secret"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf("directory: %s\n%s\n", dir, tc.config))
|
|
defer srv.Close()
|
|
|
|
do := func(method, path string, headers map[string]string) int {
|
|
t.Helper()
|
|
code, _ := doRequest(t, method, srv.URL+path, tc.username, "basic", headers, "")
|
|
return code
|
|
}
|
|
|
|
// Controls: the deny rule holds inside the collection, and for the
|
|
// collection named with its trailing slash.
|
|
require.Equal(t, http.StatusForbidden, do("GET", "/data/secret/flag.txt", nil))
|
|
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret/", map[string]string{"Depth": "1"}))
|
|
|
|
// Named without the trailing slash it is the same collection.
|
|
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret", map[string]string{"Depth": "1"}))
|
|
require.Equal(t, http.StatusForbidden, do("DELETE", "/data/secret", nil))
|
|
require.Equal(t, http.StatusForbidden, do("MOVE", "/data/secret", map[string]string{"Destination": srv.URL + "/data/exposed"}))
|
|
require.Equal(t, http.StatusForbidden, do("COPY", "/data/secret", map[string]string{"Destination": srv.URL + "/data/copied"}))
|
|
|
|
require.FileExists(t, filepath.Join(dir, "data", "secret", "flag.txt"))
|
|
require.NoFileExists(t, filepath.Join(dir, "data", "exposed", "flag.txt"))
|
|
require.NoFileExists(t, filepath.Join(dir, "data", "copied", "flag.txt"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestServerRulesCollectionGrantedByEnclosingRule guards the other direction: a
|
|
// rule naming a collection must not lose access an enclosing rule grants.
|
|
func TestServerRulesCollectionGrantedByEnclosingRule(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{"data/sub/foo.txt": []byte("foo")})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: none
|
|
rules:
|
|
- path: "/data/"
|
|
permissions: CRUD
|
|
- path: "/data/sub/"
|
|
permissions: CRUD
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
code, _ := doRequest(t, "PROPFIND", srv.URL+"/data/sub", "", "", map[string]string{"Depth": "1"}, "")
|
|
require.Equal(t, http.StatusMultiStatus, code)
|
|
}
|
|
|
|
// TestServerRulesRecursiveDescendants covers operations acting on a whole
|
|
// subtree, which authorizing only the requested path let reach denied
|
|
// descendants: reading, enumerating, relocating and destroying them.
|
|
func TestServerRulesRecursiveDescendants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"secret/flag.txt": []byte("top secret"),
|
|
"secret/public-note.txt": []byte("note"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: CRUD
|
|
rules:
|
|
- path: "/secret/flag.txt"
|
|
permissions: none
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
// Control: the rule holds when the request names the denied path.
|
|
code, _ := doRequest(t, "GET", srv.URL+"/secret/flag.txt", "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
// Enumerating the parent must not report the denied descendant.
|
|
code, body := doRequest(t, "PROPFIND", srv.URL+"/secret", "", "", map[string]string{"Depth": "infinity"}, "")
|
|
require.Equal(t, http.StatusMultiStatus, code)
|
|
require.NotContains(t, body, "flag.txt")
|
|
require.Contains(t, body, "public-note.txt")
|
|
|
|
// Copying the parent must leave the denied descendant behind, not carry it
|
|
// to a path no rule covers. The permitted sibling still copies.
|
|
code, _ = doRequest(t, "COPY", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/stolen", "Depth": "infinity"}, "")
|
|
require.Equal(t, http.StatusCreated, code)
|
|
|
|
code, _ = doRequest(t, "GET", srv.URL+"/stolen/flag.txt", "", "", nil, "")
|
|
require.Equal(t, http.StatusNotFound, code)
|
|
|
|
code, _ = doRequest(t, "GET", srv.URL+"/stolen/public-note.txt", "", "", nil, "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
|
|
// MOVE and DELETE act on the subtree in one call, so they cannot be partial.
|
|
code, _ = doRequest(t, "MOVE", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/moved"}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
code, _ = doRequest(t, "DELETE", srv.URL+"/secret", "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
|
|
}
|
|
|
|
// TestServerRulesRecursiveDescendantsMultiDir is the same defect across mounts,
|
|
// where copying out of one lands the denied file in another.
|
|
func TestServerRulesRecursiveDescendantsMultiDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dirA := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("mount secret")})
|
|
dirB := makeTestDirectory(t, map[string][]byte{"keep.txt": []byte("keep")})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
permissions: CRUD
|
|
directories:
|
|
- name: alpha
|
|
path: %s
|
|
- name: beta
|
|
path: %s
|
|
rules:
|
|
- path: "/alpha/secret/flag.txt"
|
|
permissions: none
|
|
`, dirA, dirB))
|
|
defer srv.Close()
|
|
|
|
code, _ := doRequest(t, "COPY", srv.URL+"/alpha/secret", "", "", map[string]string{"Destination": srv.URL + "/beta/stolen", "Depth": "infinity"}, "")
|
|
require.Equal(t, http.StatusCreated, code)
|
|
|
|
code, _ = doRequest(t, "GET", srv.URL+"/beta/stolen/flag.txt", "", "", nil, "")
|
|
require.Equal(t, http.StatusNotFound, code)
|
|
|
|
code, _ = doRequest(t, "DELETE", srv.URL+"/alpha/secret", "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
require.FileExists(t, filepath.Join(dirA, "secret", "flag.txt"))
|
|
}
|
|
|
|
// TestServerLockRequiresWritePermission covers LOCK being authorized by any
|
|
// permission at all, which let a read-only user create a file by locking a
|
|
// missing path, and hold a write lock that blocks legitimate writers.
|
|
func TestServerLockRequiresWritePermission(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{"existing.txt": []byte("x")})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: none
|
|
users:
|
|
- username: reader
|
|
password: reader
|
|
permissions: R
|
|
- username: writer
|
|
password: writer
|
|
permissions: CRUD
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
lock := func(username, path string) int {
|
|
t.Helper()
|
|
code, _ := doRequest(t, "LOCK", srv.URL+path, username, username,
|
|
map[string]string{"Timeout": "Infinite", "Content-Type": "application/xml"}, exclusiveWriteLock)
|
|
return code
|
|
}
|
|
|
|
// A read-only user may neither lock a resource into existence nor reserve one.
|
|
require.Equal(t, http.StatusForbidden, lock("reader", "/created-by-lock.txt"))
|
|
require.NoFileExists(t, filepath.Join(dir, "created-by-lock.txt"))
|
|
require.Equal(t, http.StatusForbidden, lock("reader", "/existing.txt"))
|
|
|
|
// Reading is unaffected.
|
|
code, _ := doRequest(t, "GET", srv.URL+"/existing.txt", "reader", "reader", nil, "")
|
|
require.Equal(t, http.StatusOK, code)
|
|
|
|
// A user who can write still locks as before.
|
|
require.Equal(t, http.StatusOK, lock("writer", "/existing.txt"))
|
|
require.Equal(t, http.StatusCreated, lock("writer", "/new.txt"))
|
|
}
|
|
|
|
// TestServerRulesCaseInsensitiveFilesystem covers a rule being evaded by asking
|
|
// for a differently cased spelling of the same file.
|
|
func TestServerRulesCaseInsensitiveFilesystem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("secret")})
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, "SECRET", "flag.txt")); err != nil {
|
|
t.Skip("backing file system distinguishes path case")
|
|
}
|
|
|
|
// A regex rule has to follow the file system too, or it is evaded the same
|
|
// way while looking like it denies the path.
|
|
for _, rule := range []string{`path: "/secret/"`, `regex: "^/secret/"`} {
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: CRUD
|
|
rules:
|
|
- %s
|
|
permissions: none
|
|
`, dir, rule))
|
|
|
|
for _, path := range []string{"/secret/flag.txt", "/SECRET/flag.txt", "/Secret/flag.txt"} {
|
|
code, _ := doRequest(t, "GET", srv.URL+path, "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code, rule, path)
|
|
|
|
code, _ = doRequest(t, "DELETE", srv.URL+path, "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code, rule, path)
|
|
}
|
|
|
|
srv.Close()
|
|
}
|
|
|
|
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
|
|
}
|
|
|
|
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)
|
|
}
|