X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/ead2387d5dbbf15065d0ec07a3a4982628fae995..c22bd85568bdef0358a82bb20e4995477c7d24ac:/services/keep-web/handler_test.go diff --git a/services/keep-web/handler_test.go b/services/keep-web/handler_test.go index a64aeb5f79..57ac2190c4 100644 --- a/services/keep-web/handler_test.go +++ b/services/keep-web/handler_test.go @@ -16,7 +16,67 @@ import ( var _ = check.Suite(&UnitSuite{}) -type UnitSuite struct {} +type UnitSuite struct{} + +func (s *UnitSuite) TestCORSPreflight(c *check.C) { + h := handler{Config: &Config{}} + u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo") + req := &http.Request{ + Method: "OPTIONS", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{ + "Origin": {"https://workbench.example"}, + "Access-Control-Request-Method": {"POST"}, + }, + } + + // Check preflight for an allowed request + resp := httptest.NewRecorder() + h.ServeHTTP(resp, req) + c.Check(resp.Code, check.Equals, http.StatusOK) + c.Check(resp.Body.String(), check.Equals, "") + c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*") + c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "GET, POST") + c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Range") + + // Check preflight for a disallowed request + resp = httptest.NewRecorder() + req.Header.Set("Access-Control-Request-Method", "DELETE") + h.ServeHTTP(resp, req) + c.Check(resp.Body.String(), check.Equals, "") + c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed) +} + +func (s *UnitSuite) TestInvalidUUID(c *check.C) { + bogusID := strings.Replace(arvadostest.FooPdh, "+", "-", 1) + "-" + token := arvadostest.ActiveToken + for _, trial := range []string{ + "http://keep-web/c=" + bogusID + "/foo", + "http://keep-web/c=" + bogusID + "/t=" + token + "/foo", + "http://keep-web/collections/download/" + bogusID + "/" + token + "/foo", + "http://keep-web/collections/" + bogusID + "/foo", + "http://" + bogusID + ".keep-web/" + bogusID + "/foo", + "http://" + bogusID + ".keep-web/t=" + token + "/" + bogusID + "/foo", + } { + c.Log(trial) + u, err := url.Parse(trial) + c.Assert(err, check.IsNil) + req := &http.Request{ + Method: "GET", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + } + resp := httptest.NewRecorder() + h := handler{Config: &Config{ + AnonymousTokens: []string{arvadostest.AnonymousToken}, + }} + h.ServeHTTP(resp, req) + c.Check(resp.Code, check.Equals, http.StatusNotFound) + } +} func mustParseURL(s string) *url.URL { r, err := url.Parse(s) @@ -32,11 +92,13 @@ func (s *IntegrationSuite) TestVhost404(c *check.C) { arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy", } { resp := httptest.NewRecorder() + u := mustParseURL(testURL) req := &http.Request{ - Method: "GET", - URL: mustParseURL(testURL), + Method: "GET", + URL: u, + RequestURI: u.RequestURI(), } - (&handler{}).ServeHTTP(resp, req) + s.testServer.Handler.ServeHTTP(resp, req) c.Check(resp.Code, check.Equals, http.StatusNotFound) c.Check(resp.Body.String(), check.Equals, "") } @@ -49,26 +111,26 @@ func (s *IntegrationSuite) TestVhost404(c *check.C) { type authorizer func(*http.Request, string) int func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) { - doVhostRequests(c, authzViaAuthzHeader) + s.doVhostRequests(c, authzViaAuthzHeader) } func authzViaAuthzHeader(r *http.Request, tok string) int { - r.Header.Add("Authorization", "OAuth2 " + tok) + r.Header.Add("Authorization", "OAuth2 "+tok) return http.StatusUnauthorized } func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) { - doVhostRequests(c, authzViaCookieValue) + s.doVhostRequests(c, authzViaCookieValue) } func authzViaCookieValue(r *http.Request, tok string) int { r.AddCookie(&http.Cookie{ - Name: "api_token", + Name: "arvados_api_token", Value: auth.EncodeTokenCookie([]byte(tok)), }) return http.StatusUnauthorized } func (s *IntegrationSuite) TestVhostViaPath(c *check.C) { - doVhostRequests(c, authzViaPath) + s.doVhostRequests(c, authzViaPath) } func authzViaPath(r *http.Request, tok string) int { r.URL.Path = "/t=" + tok + r.URL.Path @@ -76,7 +138,7 @@ func authzViaPath(r *http.Request, tok string) int { } func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) { - doVhostRequests(c, authzViaQueryString) + s.doVhostRequests(c, authzViaQueryString) } func authzViaQueryString(r *http.Request, tok string) int { r.URL.RawQuery = "api_token=" + tok @@ -84,7 +146,7 @@ func authzViaQueryString(r *http.Request, tok string) int { } func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) { - doVhostRequests(c, authzViaPOST) + s.doVhostRequests(c, authzViaPOST) } func authzViaPOST(r *http.Request, tok string) int { r.Method = "POST" @@ -94,22 +156,38 @@ func authzViaPOST(r *http.Request, tok string) int { return http.StatusUnauthorized } +func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) { + s.doVhostRequests(c, authzViaPOST) +} +func authzViaXHRPOST(r *http.Request, tok string) int { + r.Method = "POST" + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + r.Header.Add("Origin", "https://origin.example") + r.Body = ioutil.NopCloser(strings.NewReader( + url.Values{ + "api_token": {tok}, + "disposition": {"attachment"}, + }.Encode())) + return http.StatusUnauthorized +} + // Try some combinations of {url, token} using the given authorization // mechanism, and verify the result is correct. -func doVhostRequests(c *check.C, authz authorizer) { +func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) { for _, hostPath := range []string{ arvadostest.FooCollection + ".example.com/foo", - arvadostest.FooCollection + "--dl.example.com/foo", - arvadostest.FooCollection + "--dl.example.com/_/foo", + arvadostest.FooCollection + "--collections.example.com/foo", + arvadostest.FooCollection + "--collections.example.com/_/foo", arvadostest.FooPdh + ".example.com/foo", - strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--dl.example.com/foo", + strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo", + arvadostest.FooBarDirCollection + ".example.com/dir1/foo", } { c.Log("doRequests: ", hostPath) - doVhostRequestsWithHostPath(c, authz, hostPath) + s.doVhostRequestsWithHostPath(c, authz, hostPath) } } -func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) { +func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) { for _, tok := range []string{ arvadostest.ActiveToken, arvadostest.ActiveToken[:15], @@ -119,17 +197,25 @@ func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) } { u := mustParseURL("http://" + hostPath) req := &http.Request{ - Method: "GET", - Host: u.Host, - URL: u, - Header: http.Header{}, + Method: "GET", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{}, } failCode := authz(req, tok) - resp := doReq(req) + req, resp := s.doReq(req) code, body := resp.Code, resp.Body.String() + + // If the initial request had a (non-empty) token + // showing in the query string, we should have been + // redirected in order to hide it in a cookie. + c.Check(req.URL.String(), check.Not(check.Matches), `.*api_token=.+`) + if tok == arvadostest.ActiveToken { c.Check(code, check.Equals, http.StatusOK) c.Check(body, check.Equals, "foo") + } else { c.Check(code >= 400, check.Equals, true) c.Check(code < 500, check.Equals, true) @@ -147,140 +233,249 @@ func doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) } } -func doReq(req *http.Request) *httptest.ResponseRecorder { +func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) { resp := httptest.NewRecorder() - (&handler{}).ServeHTTP(resp, req) + s.testServer.Handler.ServeHTTP(resp, req) if resp.Code != http.StatusSeeOther { - return resp + return req, resp } cookies := (&http.Response{Header: resp.Header()}).Cookies() u, _ := req.URL.Parse(resp.Header().Get("Location")) req = &http.Request{ - Method: "GET", - Host: u.Host, - URL: u, - Header: http.Header{}, + Method: "GET", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{}, } for _, c := range cookies { req.AddCookie(c) } - return doReq(req) + return s.doReq(req) } func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) { s.testVhostRedirectTokenToCookie(c, "GET", - arvadostest.FooCollection + ".example.com/foo", - "?api_token=" + arvadostest.ActiveToken, - "text/plain", + arvadostest.FooCollection+".example.com/foo", + "?api_token="+arvadostest.ActiveToken, + "", "", http.StatusOK, + "foo", + ) +} + +func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) { + s.testVhostRedirectTokenToCookie(c, "GET", + "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo", + "", + "", + "", + http.StatusOK, + "foo", + ) +} + +// Bad token in URL is 404 Not Found because it doesn't make sense to +// retry the same URL with different authorization. +func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) { + s.testVhostRedirectTokenToCookie(c, "GET", + "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo", + "", + "", + "", + http.StatusNotFound, + "", + ) +} + +// Bad token in a cookie (even if it got there via our own +// query-string-to-cookie redirect) is, in principle, retryable at the +// same URL so it's 401 Unauthorized. +func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) { + s.testVhostRedirectTokenToCookie(c, "GET", + arvadostest.FooCollection+".example.com/foo", + "?api_token=thisisabogustoken", + "", + "", + http.StatusUnauthorized, + "", ) } func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) { s.testVhostRedirectTokenToCookie(c, "GET", - "example.com/c=" + arvadostest.FooCollection + "/foo", - "?api_token=" + arvadostest.ActiveToken, - "text/plain", + "example.com/c="+arvadostest.FooCollection+"/foo", + "?api_token="+arvadostest.ActiveToken, + "", "", http.StatusBadRequest, + "", ) } +// If client requests an attachment by putting ?disposition=attachment +// in the query string, and gets redirected, the redirect target +// should respond with an attachment. +func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) { + resp := s.testVhostRedirectTokenToCookie(c, "GET", + arvadostest.FooCollection+".example.com/foo", + "?disposition=attachment&api_token="+arvadostest.ActiveToken, + "", + "", + http.StatusOK, + "foo", + ) + c.Check(strings.Split(resp.Header().Get("Content-Disposition"), ";")[0], check.Equals, "attachment") +} + func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) { - defer func(orig bool) { - trustAllContent = orig - }(trustAllContent) - trustAllContent = true + s.testServer.Config.TrustAllContent = true s.testVhostRedirectTokenToCookie(c, "GET", - "example.com/c=" + arvadostest.FooCollection + "/foo", - "?api_token=" + arvadostest.ActiveToken, - "text/plain", + "example.com/c="+arvadostest.FooCollection+"/foo", + "?api_token="+arvadostest.ActiveToken, + "", "", http.StatusOK, + "foo", ) } func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) { - defer func(orig string) { - attachmentOnlyHost = orig - }(attachmentOnlyHost) - attachmentOnlyHost = "example.com:1234" + s.testServer.Config.AttachmentOnlyHost = "example.com:1234" s.testVhostRedirectTokenToCookie(c, "GET", - "example.com/c=" + arvadostest.FooCollection + "/foo", - "?api_token=" + arvadostest.ActiveToken, - "text/plain", + "example.com/c="+arvadostest.FooCollection+"/foo", + "?api_token="+arvadostest.ActiveToken, + "", "", http.StatusBadRequest, + "", ) resp := s.testVhostRedirectTokenToCookie(c, "GET", - "example.com:1234/c=" + arvadostest.FooCollection + "/foo", - "?api_token=" + arvadostest.ActiveToken, - "text/plain", + "example.com:1234/c="+arvadostest.FooCollection+"/foo", + "?api_token="+arvadostest.ActiveToken, + "", "", http.StatusOK, + "foo", ) c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment") } func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) { s.testVhostRedirectTokenToCookie(c, "POST", - arvadostest.FooCollection + ".example.com/foo", + arvadostest.FooCollection+".example.com/foo", "", "application/x-www-form-urlencoded", url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(), http.StatusOK, + "foo", ) } func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) { s.testVhostRedirectTokenToCookie(c, "POST", - arvadostest.FooCollection + ".example.com/foo", + arvadostest.FooCollection+".example.com/foo", "", "application/x-www-form-urlencoded", url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(), http.StatusNotFound, + "", + ) +} + +func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) { + s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken} + s.testVhostRedirectTokenToCookie(c, "GET", + "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt", + "", + "", + "", + http.StatusOK, + "Hello world\n", + ) +} + +func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) { + s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"} + s.testVhostRedirectTokenToCookie(c, "GET", + "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt", + "", + "", + "", + http.StatusNotFound, + "", ) } -func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, body string, expectStatus int) *httptest.ResponseRecorder { +// XHRs can't follow redirect-with-cookie so they rely on method=POST +// and disposition=attachment (telling us it's acceptable to respond +// with content instead of a redirect) and an Origin header that gets +// added automatically by the browser (telling us it's desirable to do +// so). +func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) { + u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo") + req := &http.Request{ + Method: "POST", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{ + "Origin": {"https://origin.example"}, + "Content-Type": {"application/x-www-form-urlencoded"}, + }, + Body: ioutil.NopCloser(strings.NewReader(url.Values{ + "api_token": {arvadostest.ActiveToken}, + "disposition": {"attachment"}, + }.Encode())), + } + resp := httptest.NewRecorder() + s.testServer.Handler.ServeHTTP(resp, req) + c.Check(resp.Code, check.Equals, http.StatusOK) + c.Check(resp.Body.String(), check.Equals, "foo") + c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*") +} + +func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder { u, _ := url.Parse(`http://` + hostPath + queryString) req := &http.Request{ - Method: method, - Host: u.Host, - URL: u, - Header: http.Header{"Content-Type": {contentType}}, - Body: ioutil.NopCloser(strings.NewReader(body)), + Method: method, + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{"Content-Type": {contentType}}, + Body: ioutil.NopCloser(strings.NewReader(reqBody)), } resp := httptest.NewRecorder() - (&handler{}).ServeHTTP(resp, req) + defer func() { + c.Check(resp.Code, check.Equals, expectStatus) + c.Check(resp.Body.String(), check.Equals, expectRespBody) + }() + + s.testServer.Handler.ServeHTTP(resp, req) if resp.Code != http.StatusSeeOther { - c.Assert(resp.Code, check.Equals, expectStatus) return resp } - c.Check(resp.Body.String(), check.Matches, `.*href="//` + regexp.QuoteMeta(html.EscapeString(hostPath)) + `".*`) + c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`) cookies := (&http.Response{Header: resp.Header()}).Cookies() u, _ = u.Parse(resp.Header().Get("Location")) req = &http.Request{ - Method: "GET", - Host: u.Host, - URL: u, - Header: http.Header{}, + Method: "GET", + Host: u.Host, + URL: u, + RequestURI: u.RequestURI(), + Header: http.Header{}, } for _, c := range cookies { req.AddCookie(c) } resp = httptest.NewRecorder() - (&handler{}).ServeHTTP(resp, req) + s.testServer.Handler.ServeHTTP(resp, req) c.Check(resp.Header().Get("Location"), check.Equals, "") - c.Check(resp.Code, check.Equals, expectStatus) - if expectStatus == http.StatusOK { - c.Check(resp.Body.String(), check.Equals, "foo") - } return resp }