mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
A COPY or MOVE onto an existing destination replaces it, which destroys everything the destination collection held. Only the source subtree was authorized per descendant, and the destination itself was authorized as an update, so a rule restricting a subtree held against DELETE but not against an overwrite of the collection above it. Removing a collection is now authorized the way DELETE on it would be, on the collection and on every path beneath it. Writing over a file stays an update.
1764 lines
50 KiB
Go
1764 lines
50 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"))
|
|
}
|
|
|
|
// TestServerRulesDestinationOverwriteDescendants covers a COPY or MOVE onto an
|
|
// existing collection, which replaces it: the server deletes the destination
|
|
// with "Depth: infinity" first, reaching descendants that authorizing only the
|
|
// destination itself let it destroy.
|
|
func TestServerRulesDestinationOverwriteDescendants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"shared/protected/confidential.txt": []byte("top secret"),
|
|
"empty/.keep": []byte(""),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: CRUD
|
|
rules:
|
|
- path: "/shared/protected/"
|
|
permissions: R
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
confidential := filepath.Join(dir, "shared", "protected", "confidential.txt")
|
|
|
|
// Controls: the rule holds when the request names the denied subtree.
|
|
code, _ := doRequest(t, "DELETE", srv.URL+"/shared/protected/confidential.txt", "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
code, _ = doRequest(t, "DELETE", srv.URL+"/shared/", "", "", nil, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
// Overwriting the collection above the rule destroys the denied subtree, so
|
|
// it is refused for the same reason DELETE is.
|
|
code, _ = doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/shared/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
// COPY overwrites unless the header says otherwise, so it needs no Overwrite.
|
|
code, _ = doRequest(t, "COPY", srv.URL+"/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/shared/",
|
|
"Depth": "infinity",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
require.FileExists(t, confidential)
|
|
}
|
|
|
|
// TestServerRulesDestinationOverwriteDescendantsMultiDir is the same defect
|
|
// across mounts, where the destination collection lives under another mount.
|
|
func TestServerRulesDestinationOverwriteDescendantsMultiDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dirA := makeTestDirectory(t, map[string][]byte{"shared/protected/flag.txt": []byte("mount secret")})
|
|
dirB := makeTestDirectory(t, map[string][]byte{"empty/.keep": []byte("")})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
permissions: CRUD
|
|
directories:
|
|
- name: alpha
|
|
path: %s
|
|
- name: beta
|
|
path: %s
|
|
rules:
|
|
- path: "/alpha/shared/protected/"
|
|
permissions: R
|
|
`, dirA, dirB))
|
|
defer srv.Close()
|
|
|
|
code, _ := doRequest(t, "MOVE", srv.URL+"/beta/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/alpha/shared/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
code, _ = doRequest(t, "COPY", srv.URL+"/beta/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/alpha/shared/",
|
|
"Depth": "infinity",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
|
|
require.FileExists(t, filepath.Join(dirA, "shared", "protected", "flag.txt"))
|
|
}
|
|
|
|
// TestServerRulesDestinationOverwriteRequiresDelete covers the permission class
|
|
// the overwrite is authorized under. Removing a collection is delete-class, so a
|
|
// rule that grants writes but withholds D refuses the overwrite, whether it
|
|
// governs the destination itself or something beneath it.
|
|
func TestServerRulesDestinationOverwriteRequiresDelete(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"empty/.keep": []byte(""),
|
|
"empty2/.keep": []byte(""),
|
|
"nodelete/note.txt": []byte("kept by the rule on the collection"),
|
|
"shared/keep/n.txt": []byte("kept by the rule on a descendant"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: CRUD
|
|
rules:
|
|
- path: "/nodelete/"
|
|
permissions: CRU
|
|
- path: "/shared/keep/"
|
|
permissions: CRU
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
// The rule governs the destination collection itself.
|
|
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/nodelete/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
require.FileExists(t, filepath.Join(dir, "nodelete", "note.txt"))
|
|
|
|
// The rule governs a descendant of the destination collection.
|
|
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/shared/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusForbidden, code)
|
|
require.FileExists(t, filepath.Join(dir, "shared", "keep", "n.txt"))
|
|
}
|
|
|
|
// TestServerDestinationOverwriteAllowed pins what overwriting a destination is
|
|
// still allowed to do, so the delete-class check on collections does not spread
|
|
// to writing over a file. Replacing a file is update-class, the same class PUT
|
|
// over an existing file needs, which clients rely on when they save by writing a
|
|
// temporary file and moving it over the target.
|
|
func TestServerDestinationOverwriteAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := makeTestDirectory(t, map[string][]byte{
|
|
"empty/.keep": []byte(""),
|
|
"empty2/.keep": []byte(""),
|
|
"plain/note.txt": []byte("plain"),
|
|
"updatable/doc.txt": []byte("updatable"),
|
|
"source.txt": []byte("source"),
|
|
})
|
|
|
|
srv := makeTestServer(t, fmt.Sprintf(`
|
|
directory: %s
|
|
permissions: CRUD
|
|
rules:
|
|
- path: "/updatable/doc.txt"
|
|
permissions: RU
|
|
`, dir))
|
|
defer srv.Close()
|
|
|
|
// No rule withholds D anywhere under the destination.
|
|
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/plain/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusNoContent, code)
|
|
|
|
// A destination that does not exist is created, not overwritten.
|
|
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
|
|
"Destination": srv.URL + "/fresh/",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusCreated, code)
|
|
|
|
// A file destination needs only the U its rule grants, not D.
|
|
code, _ = doRequest(t, "MOVE", srv.URL+"/source.txt", "", "", map[string]string{
|
|
"Destination": srv.URL + "/updatable/doc.txt",
|
|
"Overwrite": "T",
|
|
}, "")
|
|
require.Equal(t, http.StatusNoContent, code)
|
|
}
|
|
|
|
// 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)
|
|
}
|