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