Skip to content

Commit 821a034

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 821a034

1 file changed

Lines changed: 239 additions & 0 deletions

File tree

‎pkg/erserver/webdav_test.go‎

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

0 commit comments

Comments
 (0)