Skip to content

Commit 03fe450

Browse files
committed
end-to-end test for WebDAV correctly passing through
main considerations to keep in mind: - reverse proxy MUST NOT do HTTP method allowlisting (or if does, it has to accept all method needed by WebDAV) - the requests passed to WebDAV origin SHOULD NOT be path translated, as file paths are also communicated via headers and whatnot
1 parent 617caf9 commit 03fe450

1 file changed

Lines changed: 234 additions & 0 deletions

File tree

‎pkg/erserver/webdav_test.go‎

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package erserver
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/http/httptest"
11+
"net/url"
12+
"strings"
13+
"testing"
14+
15+
"github.com/function61/edgerouter/pkg/erconfig"
16+
"github.com/function61/edgerouter/pkg/erdiscovery"
17+
"github.com/prometheus/client_golang/prometheus"
18+
)
19+
20+
func TestReverseProxyWebDAV(t *testing.T) {
21+
t.Setenv("METRICS_ENDPOINT", "")
22+
t.Setenv("TURBOCHARGER_STORE", "")
23+
24+
const (
25+
publicHost = "dav.example.com"
26+
username = "alice"
27+
password = "secret"
28+
fileBody = "WebDAV integration test\n"
29+
lockToken = "<opaquelocktoken:edgerouter-test>"
30+
)
31+
32+
files := map[string]string{}
33+
webDAVOrigin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34+
if r.Host != publicHost {
35+
http.Error(w, "unexpected Host: "+r.Host, http.StatusBadRequest)
36+
return
37+
}
38+
39+
actualUsername, actualPassword, ok := r.BasicAuth()
40+
if !ok || actualUsername != username || actualPassword != password {
41+
http.Error(w, "missing origin credentials", http.StatusUnauthorized)
42+
return
43+
}
44+
if r.Header.Get("X-Forwarded-For") == "" {
45+
http.Error(w, "missing X-Forwarded-For", http.StatusBadRequest)
46+
return
47+
}
48+
49+
switch r.Method {
50+
case http.MethodOptions:
51+
w.Header().Set("DAV", "1, 2")
52+
w.Header().Set("Allow", "OPTIONS, PROPFIND, PROPPATCH, MKCOL, PUT, COPY, MOVE, LOCK, UNLOCK, GET, DELETE")
53+
w.WriteHeader(http.StatusNoContent)
54+
case "MKCOL":
55+
if r.URL.Path != "/documents/" {
56+
http.Error(w, "unexpected collection path", http.StatusBadRequest)
57+
return
58+
}
59+
w.WriteHeader(http.StatusCreated)
60+
case http.MethodPut:
61+
body, err := io.ReadAll(r.Body)
62+
if err != nil {
63+
http.Error(w, err.Error(), http.StatusBadRequest)
64+
return
65+
}
66+
files[r.URL.Path] = string(body)
67+
w.WriteHeader(http.StatusCreated)
68+
case "PROPFIND":
69+
if r.Header.Get("Depth") != "1" {
70+
http.Error(w, "unexpected Depth header", http.StatusBadRequest)
71+
return
72+
}
73+
body, err := io.ReadAll(r.Body)
74+
if err != nil || !bytes.Contains(body, []byte("propfind")) {
75+
http.Error(w, "unexpected PROPFIND body", http.StatusBadRequest)
76+
return
77+
}
78+
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
79+
w.WriteHeader(http.StatusMultiStatus)
80+
_, _ = fmt.Fprintf(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>https://%s/documents/source.txt</d:href></d:response></d:multistatus>`, publicHost)
81+
case "PROPPATCH":
82+
body, err := io.ReadAll(r.Body)
83+
if err != nil || !bytes.Contains(body, []byte("propertyupdate")) {
84+
http.Error(w, "unexpected PROPPATCH body", http.StatusBadRequest)
85+
return
86+
}
87+
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
88+
w.WriteHeader(http.StatusMultiStatus)
89+
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"/>`)
90+
case "COPY", "MOVE":
91+
destination, err := url.Parse(r.Header.Get("Destination"))
92+
if err != nil || destination.Scheme != "https" || destination.Host != publicHost {
93+
http.Error(w, "unexpected Destination header", http.StatusBadRequest)
94+
return
95+
}
96+
if r.Header.Get("Overwrite") != "T" {
97+
http.Error(w, "unexpected Overwrite header", http.StatusBadRequest)
98+
return
99+
}
100+
files[destination.Path] = files[r.URL.Path]
101+
if r.Method == "MOVE" {
102+
delete(files, r.URL.Path)
103+
}
104+
w.WriteHeader(http.StatusCreated)
105+
case "LOCK":
106+
if r.Header.Get("Timeout") != "Second-3600" {
107+
http.Error(w, "unexpected Timeout header", http.StatusBadRequest)
108+
return
109+
}
110+
body, err := io.ReadAll(r.Body)
111+
if err != nil || !bytes.Contains(body, []byte("lockinfo")) {
112+
http.Error(w, "unexpected LOCK body", http.StatusBadRequest)
113+
return
114+
}
115+
w.Header().Set("Lock-Token", lockToken)
116+
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
117+
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:prop xmlns:d="DAV:"/>`)
118+
case "UNLOCK":
119+
if r.Header.Get("Lock-Token") != lockToken {
120+
http.Error(w, "unexpected Lock-Token header", http.StatusBadRequest)
121+
return
122+
}
123+
w.WriteHeader(http.StatusNoContent)
124+
case http.MethodGet:
125+
body, found := files[r.URL.Path]
126+
if !found {
127+
http.NotFound(w, r)
128+
return
129+
}
130+
_, _ = io.WriteString(w, body)
131+
case http.MethodDelete:
132+
delete(files, r.URL.Path)
133+
w.WriteHeader(http.StatusNoContent)
134+
default:
135+
http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
136+
}
137+
}))
138+
t.Cleanup(webDAVOrigin.Close)
139+
140+
app := erconfig.SimpleApplication(
141+
"webdav",
142+
erconfig.SimpleHostnameFrontend(publicHost),
143+
erconfig.ReverseProxyBackend([]string{webDAVOrigin.URL}, nil, true))
144+
currentConfig := newAtomicConfig()
145+
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
146+
matchers, err := syncAppsFromDiscovery(
147+
context.Background(),
148+
erdiscovery.StaticDiscovery([]erconfig.Application{app}),
149+
currentConfig,
150+
logger,
151+
logger)
152+
if err != nil {
153+
t.Fatal(err)
154+
}
155+
currentConfig.Store(matchers)
156+
157+
metrics := initMetrics()
158+
t.Cleanup(func() {
159+
prometheus.Unregister(metrics.requestsOk)
160+
prometheus.Unregister(metrics.requestsFail)
161+
prometheus.Unregister(metrics.requestDuration)
162+
})
163+
edgeRouter := httptest.NewTLSServer(newServerHandler(currentConfig, nil, metrics))
164+
t.Cleanup(edgeRouter.Close)
165+
166+
request := func(method string, path string, body string, headers map[string]string) *http.Response {
167+
t.Helper()
168+
169+
req, err := http.NewRequest(method, edgeRouter.URL+path, strings.NewReader(body))
170+
if err != nil {
171+
t.Fatal(err)
172+
}
173+
req.Host = publicHost
174+
req.SetBasicAuth(username, password)
175+
for key, value := range headers {
176+
req.Header.Set(key, value)
177+
}
178+
179+
resp, err := edgeRouter.Client().Do(req)
180+
if err != nil {
181+
t.Fatal(err)
182+
}
183+
return resp
184+
}
185+
assertResponse := func(resp *http.Response, expectedStatus int, expectedBody string) {
186+
t.Helper()
187+
defer resp.Body.Close()
188+
189+
body, err := io.ReadAll(resp.Body)
190+
if err != nil {
191+
t.Fatal(err)
192+
}
193+
if resp.StatusCode != expectedStatus {
194+
t.Fatalf("expected status %d, got %d: %s", expectedStatus, resp.StatusCode, body)
195+
}
196+
if expectedBody != "" && !strings.Contains(string(body), expectedBody) {
197+
t.Fatalf("expected body to contain %q, got %q", expectedBody, body)
198+
}
199+
}
200+
201+
options := request(http.MethodOptions, "/", "", nil)
202+
if options.Header.Get("DAV") != "1, 2" {
203+
t.Fatalf("expected DAV response header, got %q", options.Header.Get("DAV"))
204+
}
205+
assertResponse(options, http.StatusNoContent, "")
206+
207+
assertResponse(request("MKCOL", "/documents/", "", nil), http.StatusCreated, "")
208+
assertResponse(request(http.MethodPut, "/documents/source.txt", fileBody, nil), http.StatusCreated, "")
209+
assertResponse(request("PROPFIND", "/documents/", `<d:propfind xmlns:d="DAV:"/>`, map[string]string{
210+
"Depth": "1",
211+
}), http.StatusMultiStatus, "https://"+publicHost+"/documents/source.txt")
212+
assertResponse(request("PROPPATCH", "/documents/source.txt", `<d:propertyupdate xmlns:d="DAV:"/>`, nil), http.StatusMultiStatus, "<d:multistatus")
213+
assertResponse(request("COPY", "/documents/source.txt", "", map[string]string{
214+
"Destination": "https://" + publicHost + "/documents/copied.txt",
215+
"Overwrite": "T",
216+
}), http.StatusCreated, "")
217+
assertResponse(request("MOVE", "/documents/copied.txt", "", map[string]string{
218+
"Destination": "https://" + publicHost + "/documents/moved.txt",
219+
"Overwrite": "T",
220+
}), http.StatusCreated, "")
221+
222+
lock := request("LOCK", "/documents/moved.txt", `<d:lockinfo xmlns:d="DAV:"/>`, map[string]string{
223+
"Timeout": "Second-3600",
224+
})
225+
if lock.Header.Get("Lock-Token") != lockToken {
226+
t.Fatalf("expected Lock-Token response header, got %q", lock.Header.Get("Lock-Token"))
227+
}
228+
assertResponse(lock, http.StatusOK, "<d:prop")
229+
assertResponse(request("UNLOCK", "/documents/moved.txt", "", map[string]string{
230+
"Lock-Token": lockToken,
231+
}), http.StatusNoContent, "")
232+
assertResponse(request(http.MethodGet, "/documents/moved.txt", "", nil), http.StatusOK, fileBody)
233+
assertResponse(request(http.MethodDelete, "/documents/moved.txt", "", nil), http.StatusNoContent, "")
234+
}

0 commit comments

Comments
 (0)