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