11509: Allow cross-origin requests with Range headers.
[arvados.git] / services / keep-web / handler_test.go
1 package main
2
3 import (
4         "html"
5         "io/ioutil"
6         "net/http"
7         "net/http/httptest"
8         "net/url"
9         "regexp"
10         "strings"
11
12         "git.curoverse.com/arvados.git/sdk/go/arvadostest"
13         "git.curoverse.com/arvados.git/sdk/go/auth"
14         check "gopkg.in/check.v1"
15 )
16
17 var _ = check.Suite(&UnitSuite{})
18
19 type UnitSuite struct{}
20
21 func (s *UnitSuite) TestCORSPreflight(c *check.C) {
22         h := handler{Config: &Config{}}
23         u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
24         req := &http.Request{
25                 Method:     "OPTIONS",
26                 Host:       u.Host,
27                 URL:        u,
28                 RequestURI: u.RequestURI(),
29                 Header: http.Header{
30                         "Origin":                        {"https://workbench.example"},
31                         "Access-Control-Request-Method": {"POST"},
32                 },
33         }
34
35         resp := httptest.NewRecorder()
36         h.ServeHTTP(resp, req)
37         c.Check(resp.Code, check.Equals, http.StatusOK)
38         c.Check(resp.Body.String(), check.Equals, "")
39         c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
40         c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "GET, POST")
41         c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Range")
42
43         resp = httptest.NewRecorder()
44         req.Header.Set("Access-Control-Request-Method", "DELETE")
45         h.ServeHTTP(resp, req)
46         c.Check(resp.Body.String(), check.Equals, "")
47         c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
48 }
49
50 func mustParseURL(s string) *url.URL {
51         r, err := url.Parse(s)
52         if err != nil {
53                 panic("parse URL: " + s)
54         }
55         return r
56 }
57
58 func (s *IntegrationSuite) TestVhost404(c *check.C) {
59         for _, testURL := range []string{
60                 arvadostest.NonexistentCollection + ".example.com/theperthcountyconspiracy",
61                 arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
62         } {
63                 resp := httptest.NewRecorder()
64                 u := mustParseURL(testURL)
65                 req := &http.Request{
66                         Method:     "GET",
67                         URL:        u,
68                         RequestURI: u.RequestURI(),
69                 }
70                 s.testServer.Handler.ServeHTTP(resp, req)
71                 c.Check(resp.Code, check.Equals, http.StatusNotFound)
72                 c.Check(resp.Body.String(), check.Equals, "")
73         }
74 }
75
76 // An authorizer modifies an HTTP request to make use of the given
77 // token -- by adding it to a header, cookie, query param, or whatever
78 // -- and returns the HTTP status code we should expect from keep-web if
79 // the token is invalid.
80 type authorizer func(*http.Request, string) int
81
82 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
83         s.doVhostRequests(c, authzViaAuthzHeader)
84 }
85 func authzViaAuthzHeader(r *http.Request, tok string) int {
86         r.Header.Add("Authorization", "OAuth2 "+tok)
87         return http.StatusUnauthorized
88 }
89
90 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
91         s.doVhostRequests(c, authzViaCookieValue)
92 }
93 func authzViaCookieValue(r *http.Request, tok string) int {
94         r.AddCookie(&http.Cookie{
95                 Name:  "arvados_api_token",
96                 Value: auth.EncodeTokenCookie([]byte(tok)),
97         })
98         return http.StatusUnauthorized
99 }
100
101 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
102         s.doVhostRequests(c, authzViaPath)
103 }
104 func authzViaPath(r *http.Request, tok string) int {
105         r.URL.Path = "/t=" + tok + r.URL.Path
106         return http.StatusNotFound
107 }
108
109 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
110         s.doVhostRequests(c, authzViaQueryString)
111 }
112 func authzViaQueryString(r *http.Request, tok string) int {
113         r.URL.RawQuery = "api_token=" + tok
114         return http.StatusUnauthorized
115 }
116
117 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
118         s.doVhostRequests(c, authzViaPOST)
119 }
120 func authzViaPOST(r *http.Request, tok string) int {
121         r.Method = "POST"
122         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
123         r.Body = ioutil.NopCloser(strings.NewReader(
124                 url.Values{"api_token": {tok}}.Encode()))
125         return http.StatusUnauthorized
126 }
127
128 func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) {
129         s.doVhostRequests(c, authzViaPOST)
130 }
131 func authzViaXHRPOST(r *http.Request, tok string) int {
132         r.Method = "POST"
133         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
134         r.Header.Add("Origin", "https://origin.example")
135         r.Body = ioutil.NopCloser(strings.NewReader(
136                 url.Values{
137                         "api_token":   {tok},
138                         "disposition": {"attachment"},
139                 }.Encode()))
140         return http.StatusUnauthorized
141 }
142
143 // Try some combinations of {url, token} using the given authorization
144 // mechanism, and verify the result is correct.
145 func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) {
146         for _, hostPath := range []string{
147                 arvadostest.FooCollection + ".example.com/foo",
148                 arvadostest.FooCollection + "--collections.example.com/foo",
149                 arvadostest.FooCollection + "--collections.example.com/_/foo",
150                 arvadostest.FooPdh + ".example.com/foo",
151                 strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo",
152                 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
153         } {
154                 c.Log("doRequests: ", hostPath)
155                 s.doVhostRequestsWithHostPath(c, authz, hostPath)
156         }
157 }
158
159 func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
160         for _, tok := range []string{
161                 arvadostest.ActiveToken,
162                 arvadostest.ActiveToken[:15],
163                 arvadostest.SpectatorToken,
164                 "bogus",
165                 "",
166         } {
167                 u := mustParseURL("http://" + hostPath)
168                 req := &http.Request{
169                         Method:     "GET",
170                         Host:       u.Host,
171                         URL:        u,
172                         RequestURI: u.RequestURI(),
173                         Header:     http.Header{},
174                 }
175                 failCode := authz(req, tok)
176                 req, resp := s.doReq(req)
177                 code, body := resp.Code, resp.Body.String()
178
179                 // If the initial request had a (non-empty) token
180                 // showing in the query string, we should have been
181                 // redirected in order to hide it in a cookie.
182                 c.Check(req.URL.String(), check.Not(check.Matches), `.*api_token=.+`)
183
184                 if tok == arvadostest.ActiveToken {
185                         c.Check(code, check.Equals, http.StatusOK)
186                         c.Check(body, check.Equals, "foo")
187
188                 } else {
189                         c.Check(code >= 400, check.Equals, true)
190                         c.Check(code < 500, check.Equals, true)
191                         if tok == arvadostest.SpectatorToken {
192                                 // Valid token never offers to retry
193                                 // with different credentials.
194                                 c.Check(code, check.Equals, http.StatusNotFound)
195                         } else {
196                                 // Invalid token can ask to retry
197                                 // depending on the authz method.
198                                 c.Check(code, check.Equals, failCode)
199                         }
200                         c.Check(body, check.Equals, "")
201                 }
202         }
203 }
204
205 func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
206         resp := httptest.NewRecorder()
207         s.testServer.Handler.ServeHTTP(resp, req)
208         if resp.Code != http.StatusSeeOther {
209                 return req, resp
210         }
211         cookies := (&http.Response{Header: resp.Header()}).Cookies()
212         u, _ := req.URL.Parse(resp.Header().Get("Location"))
213         req = &http.Request{
214                 Method:     "GET",
215                 Host:       u.Host,
216                 URL:        u,
217                 RequestURI: u.RequestURI(),
218                 Header:     http.Header{},
219         }
220         for _, c := range cookies {
221                 req.AddCookie(c)
222         }
223         return s.doReq(req)
224 }
225
226 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
227         s.testVhostRedirectTokenToCookie(c, "GET",
228                 arvadostest.FooCollection+".example.com/foo",
229                 "?api_token="+arvadostest.ActiveToken,
230                 "",
231                 "",
232                 http.StatusOK,
233                 "foo",
234         )
235 }
236
237 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
238         s.testVhostRedirectTokenToCookie(c, "GET",
239                 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
240                 "",
241                 "",
242                 "",
243                 http.StatusOK,
244                 "foo",
245         )
246 }
247
248 // Bad token in URL is 404 Not Found because it doesn't make sense to
249 // retry the same URL with different authorization.
250 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
251         s.testVhostRedirectTokenToCookie(c, "GET",
252                 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
253                 "",
254                 "",
255                 "",
256                 http.StatusNotFound,
257                 "",
258         )
259 }
260
261 // Bad token in a cookie (even if it got there via our own
262 // query-string-to-cookie redirect) is, in principle, retryable at the
263 // same URL so it's 401 Unauthorized.
264 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
265         s.testVhostRedirectTokenToCookie(c, "GET",
266                 arvadostest.FooCollection+".example.com/foo",
267                 "?api_token=thisisabogustoken",
268                 "",
269                 "",
270                 http.StatusUnauthorized,
271                 "",
272         )
273 }
274
275 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
276         s.testVhostRedirectTokenToCookie(c, "GET",
277                 "example.com/c="+arvadostest.FooCollection+"/foo",
278                 "?api_token="+arvadostest.ActiveToken,
279                 "",
280                 "",
281                 http.StatusBadRequest,
282                 "",
283         )
284 }
285
286 // If client requests an attachment by putting ?disposition=attachment
287 // in the query string, and gets redirected, the redirect target
288 // should respond with an attachment.
289 func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) {
290         resp := s.testVhostRedirectTokenToCookie(c, "GET",
291                 arvadostest.FooCollection+".example.com/foo",
292                 "?disposition=attachment&api_token="+arvadostest.ActiveToken,
293                 "",
294                 "",
295                 http.StatusOK,
296                 "foo",
297         )
298         c.Check(strings.Split(resp.Header().Get("Content-Disposition"), ";")[0], check.Equals, "attachment")
299 }
300
301 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
302         s.testServer.Config.TrustAllContent = true
303         s.testVhostRedirectTokenToCookie(c, "GET",
304                 "example.com/c="+arvadostest.FooCollection+"/foo",
305                 "?api_token="+arvadostest.ActiveToken,
306                 "",
307                 "",
308                 http.StatusOK,
309                 "foo",
310         )
311 }
312
313 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
314         s.testServer.Config.AttachmentOnlyHost = "example.com:1234"
315
316         s.testVhostRedirectTokenToCookie(c, "GET",
317                 "example.com/c="+arvadostest.FooCollection+"/foo",
318                 "?api_token="+arvadostest.ActiveToken,
319                 "",
320                 "",
321                 http.StatusBadRequest,
322                 "",
323         )
324
325         resp := s.testVhostRedirectTokenToCookie(c, "GET",
326                 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
327                 "?api_token="+arvadostest.ActiveToken,
328                 "",
329                 "",
330                 http.StatusOK,
331                 "foo",
332         )
333         c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
334 }
335
336 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
337         s.testVhostRedirectTokenToCookie(c, "POST",
338                 arvadostest.FooCollection+".example.com/foo",
339                 "",
340                 "application/x-www-form-urlencoded",
341                 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
342                 http.StatusOK,
343                 "foo",
344         )
345 }
346
347 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
348         s.testVhostRedirectTokenToCookie(c, "POST",
349                 arvadostest.FooCollection+".example.com/foo",
350                 "",
351                 "application/x-www-form-urlencoded",
352                 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
353                 http.StatusNotFound,
354                 "",
355         )
356 }
357
358 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
359         s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken}
360         s.testVhostRedirectTokenToCookie(c, "GET",
361                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
362                 "",
363                 "",
364                 "",
365                 http.StatusOK,
366                 "Hello world\n",
367         )
368 }
369
370 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
371         s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
372         s.testVhostRedirectTokenToCookie(c, "GET",
373                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
374                 "",
375                 "",
376                 "",
377                 http.StatusNotFound,
378                 "",
379         )
380 }
381
382 // XHRs can't follow redirect-with-cookie so they rely on method=POST
383 // and disposition=attachment (telling us it's acceptable to respond
384 // with content instead of a redirect) and an Origin header that gets
385 // added automatically by the browser (telling us it's desirable to do
386 // so).
387 func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
388         u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo")
389         req := &http.Request{
390                 Method:     "POST",
391                 Host:       u.Host,
392                 URL:        u,
393                 RequestURI: u.RequestURI(),
394                 Header: http.Header{
395                         "Origin":       {"https://origin.example"},
396                         "Content-Type": {"application/x-www-form-urlencoded"},
397                 },
398                 Body: ioutil.NopCloser(strings.NewReader(url.Values{
399                         "api_token":   {arvadostest.ActiveToken},
400                         "disposition": {"attachment"},
401                 }.Encode())),
402         }
403         resp := httptest.NewRecorder()
404         s.testServer.Handler.ServeHTTP(resp, req)
405         c.Check(resp.Code, check.Equals, http.StatusOK)
406         c.Check(resp.Body.String(), check.Equals, "foo")
407         c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
408 }
409
410 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
411         u, _ := url.Parse(`http://` + hostPath + queryString)
412         req := &http.Request{
413                 Method:     method,
414                 Host:       u.Host,
415                 URL:        u,
416                 RequestURI: u.RequestURI(),
417                 Header:     http.Header{"Content-Type": {contentType}},
418                 Body:       ioutil.NopCloser(strings.NewReader(reqBody)),
419         }
420
421         resp := httptest.NewRecorder()
422         defer func() {
423                 c.Check(resp.Code, check.Equals, expectStatus)
424                 c.Check(resp.Body.String(), check.Equals, expectRespBody)
425         }()
426
427         s.testServer.Handler.ServeHTTP(resp, req)
428         if resp.Code != http.StatusSeeOther {
429                 return resp
430         }
431         c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
432         cookies := (&http.Response{Header: resp.Header()}).Cookies()
433
434         u, _ = u.Parse(resp.Header().Get("Location"))
435         req = &http.Request{
436                 Method:     "GET",
437                 Host:       u.Host,
438                 URL:        u,
439                 RequestURI: u.RequestURI(),
440                 Header:     http.Header{},
441         }
442         for _, c := range cookies {
443                 req.AddCookie(c)
444         }
445
446         resp = httptest.NewRecorder()
447         s.testServer.Handler.ServeHTTP(resp, req)
448         c.Check(resp.Header().Get("Location"), check.Equals, "")
449         return resp
450 }