// spec package is introduced to avoid circular dependencies since this // particular test requires to depend on routing directly to expose the API and // the APP server. package apps_test import ( "archive/tar" "bytes" "compress/gzip" "encoding/json" "errors" "fmt" "html" "io" "net" "net/http" "net/http/httptest" "net/http/httputil" "net/url" "os" "path/filepath" "testing" "time" apps "github.com/cozy/cozy-stack/model/app" "github.com/cozy/cozy-stack/model/feature" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/model/intent" "github.com/cozy/cozy-stack/model/oauth" "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/model/publiclink" "github.com/cozy/cozy-stack/model/session" "github.com/cozy/cozy-stack/model/stack" "github.com/cozy/cozy-stack/model/vfs" "github.com/cozy/cozy-stack/pkg/assets" "github.com/cozy/cozy-stack/pkg/assets/dynamic" "github.com/cozy/cozy-stack/pkg/assets/model" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/couchdb/mango" "github.com/cozy/cozy-stack/pkg/crypto" "github.com/cozy/cozy-stack/pkg/filetype" "github.com/cozy/cozy-stack/pkg/utils" "github.com/cozy/cozy-stack/tests/testutils" "github.com/cozy/cozy-stack/web" webApps "github.com/cozy/cozy-stack/web/apps" "github.com/cozy/cozy-stack/web/auth" "github.com/gavv/httpexpect/v2" "github.com/labstack/echo/v4" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const domain = "cozywithapps.example.net" func TestApps(t *testing.T) { if testing.Short() { t.Skip("an instance is required for this test: test skipped due to the use of --short flag") } config.UseTestFile(t) config.GetConfig().Assets = "../../assets" testutils.NeedCouchdb(t) setup := testutils.NewSetup(t, t.Name()) setup.SetupSwiftTest() require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String()), "Could not init dynamic FS") tempdir := t.TempDir() cfg := config.GetConfig() cfg.Fs.URL = &url.URL{ Scheme: "file", Host: "localhost", Path: tempdir, } was := cfg.Subdomains cfg.Subdomains = config.NestedSubdomains defer func() { cfg.Subdomains = was }() pass := "aephe2Ei" testInstance := setup.GetTestInstance(&lifecycle.Options{Domain: domain}) params := lifecycle.PassParameters{ Key: "fake-encrypt-key", Iterations: 0, } _ = lifecycle.ForceUpdatePassphrase(testInstance, []byte(pass), params) testInstance.RegisterToken = nil testInstance.OnboardingFinished = true _ = instance.Update(testInstance) slug, err := setup.InstallMiniApp() require.NoError(t, err, "Could not install mini app") konnectorSlug, err := setup.InstallMiniKonnector() require.NoError(t, err, "Could not install mini konnector") clientSideSlug, err := setup.InstallMiniClientSideKonnector() require.NoError(t, err, "Could not install miniClientSideKonnector konnector") ts := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo { r.POST("/login", func(c echo.Context) error { sess, _ := session.New(testInstance, session.LongRun, "") cookie, _ := sess.ToCookie() c.SetCookie(cookie) return c.HTML(http.StatusOK, "OK") }) r.POST("/auth/session_code", auth.CreateSessionCode) router, err := web.CreateSubdomainProxy(r, &stack.Services{}, webApps.Serve) require.NoError(t, err, "Cant start subdoman proxy") return router }) t.Cleanup(ts.Close) // Login cozysessID := testutils.CreateTestClient(t, ts.URL).POST("/login"). WithHost(testInstance.Domain). Expect().Status(200). Cookie("cozysessid").Value().Raw() _, token := setup.GetTestClient(consts.Apps + " " + consts.Konnectors) t.Run("Serve", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) eAnon := testutils.CreateTestClient(t, ts.URL) assertNotPublic(e, slug, testInstance.Domain, "/foo", 302, "https://cozywithapps.example.net/auth/login?redirect=https%3A%2F%2Fmini.cozywithapps.example.net%2Ffoo") assertNotPublic(e, slug, testInstance.Domain, "/foo/hello.tml", 401, "") e = e.Builder(func(r *httpexpect.Request) { r.WithCookie("cozysessid", cozysessID) }) assertAuthGet(e, slug, testInstance.Domain, "/foo/", "text/html", "utf-8", `this is index.html. Status`) assertAuthGet(e, slug, testInstance.Domain, "/foo/hello.html", "text/html", "utf-8", "world {{.Token}}") assertAuthGet(e, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/") assertAuthGet(e, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/") assertAnonGet(eAnon, slug, testInstance.Domain, "/public", "text/html", "utf-8", "this is a file in public/") assertAnonGet(eAnon, slug, testInstance.Domain, "/public/index.html", "text/html", "utf-8", "this is a file in public/") assertBodyContainsUnescaped( t, e.GET("/public"). WithHost(slug+"."+testInstance.Domain). Expect().Status(200). Body().Raw(), `"isLoggedIn":true`, ) assertBodyContainsUnescaped( t, eAnon.GET("/public"). WithHost(slug+"."+testInstance.Domain). Expect().Status(200). Body().Raw(), `"isLoggedIn":false`, ) assertNotFound(e, slug, testInstance.Domain, "/404") assertNotFound(e, slug, testInstance.Domain, "/") assertNotFound(e, slug, testInstance.Domain, "/index.html") assertNotFound(e, slug, testInstance.Domain, "/public/hello.html") assertInternalServerError(e, slug, testInstance.Domain, "/invalid") }) t.Run("OpenGraph", func(t *testing.T) { eAnon := testutils.CreateTestClient(t, ts.URL) file, err := vfs.Create(testInstance.VFS(), "/opengraph-report.pdf") require.NoError(t, err) require.NoError(t, file.Close()) doc, err := testInstance.VFS().FileByPath("/opengraph-report.pdf") require.NoError(t, err) createShareCode := func(subject string, password interface{}) string { code, err := testInstance.MakeJWT(consts.ShareAudience, subject, consts.Files, "", time.Now()) require.NoError(t, err) rules := permission.Set{ permission.Rule{ Type: consts.Files, Verbs: permission.Verbs(permission.GET), Values: []string{doc.ID()}, }, } _, err = permission.CreateShareSet( testInstance, &permission.Permission{Type: permission.TypeWebapp, Permissions: rules}, consts.Apps+"/"+slug, map[string]string{subject: code}, nil, permission.Permission{Permissions: rules, Password: password}, nil, false, ) require.NoError(t, err) return code } testInstance.FeatureFlags = map[string]interface{}{publiclink.FlagEnabled: true} require.NoError(t, instance.Update(testInstance)) require.NoError(t, couchdb.CreateDB(testInstance, consts.PublicLinkEvents)) code := createShareCode("public", nil) t.Run("VisitorIPThroughProxy", func(t *testing.T) { type observedRequest struct{ clientIP, remoteAddr string } observed := make(chan observedRequest, 1) stackServer := setup.GetTestServer("/apps", webApps.WebappsRoutes, func(r *echo.Echo) *echo.Echo { router, err := web.CreateSubdomainProxy(r, &stack.Services{}, func(c echo.Context) error { observed <- observedRequest{utils.ClientIP(c.Request()), c.Request().RemoteAddr} return webApps.Serve(c) }) require.NoError(t, err) return router }) defer stackServer.Close() target, err := url.Parse(stackServer.URL) require.NoError(t, err) transport := &http.Transport{DialContext: (&net.Dialer{ LocalAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.2")}, }).DialContext} defer transport.CloseIdleConnections() proxy := httptest.NewServer(&httputil.ReverseProxy{ Transport: transport, Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(target) r.Out.Host = r.In.Host r.SetXForwarded() }, }) defer proxy.Close() response := testutils.CreateTestClient(t, proxy.URL).GET("/public"). WithHeader("X-Forwarded-For", "203.0.113.10"). WithQuery("sharecode", code). WithHost(slug + "." + testInstance.Domain). Expect().Status(http.StatusOK) response.Body().Contains(``) seen := <-observed assert.Equal(t, "127.0.0.1", seen.clientIP) proxyIP, _, err := net.SplitHostPort(seen.remoteAddr) require.NoError(t, err) assert.Equal(t, "127.0.0.2", proxyIP) }) t.Run("EventCollection", func(t *testing.T) { code := createShareCode("event", nil) perm, err := permission.GetForShareCode(testInstance, code) require.NoError(t, err) readEvents := func() []publiclink.Event { var events []publiclink.Event require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", perm.ID())}, &events)) return events } for n := 0; n < 2; n++ { eAnon.GET("/public").WithQuery("sharecode", code).WithHost(slug+"."+testInstance.Domain). WithHeader("X-Forwarded-For", "2001:db8::123, 192.0.2.1").WithHeader("User-Agent", "preview-bot").Expect().Status(200) } require.Eventually(t, func() bool { return len(readEvents()) == 2 }, 5*time.Second, 10*time.Millisecond) for _, event := range readEvents() { require.Equal(t, publiclink.Opened, event.Action) require.Equal(t, "2001:db8::123", event.IP) require.Empty(t, event.Identity) require.WithinDuration(t, time.Now(), event.OccurredAt, 5*time.Second) } eAnon.HEAD("/public").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(200) eAnon.GET("/public/index.html").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(200) require.Eventually(t, func() bool { return len(readEvents()) == 3 }, 5*time.Second, 10*time.Millisecond) eAnon.GET("/public/hello.html").WithQuery("sharecode", code).WithHost(slug + "." + testInstance.Domain).Expect().Status(404) require.Len(t, readEvents(), 3) eAnon.GET("/foo/hello.html").WithQuery("sharecode", code).WithCookie("cozysessid", cozysessID).WithHost(slug + "." + testInstance.Domain).Expect().Status(200) require.Len(t, readEvents(), 3, "serving an asset is not an opening") eAnon.GET("/public").WithQuery("sharecode", code).WithCookie("cozysessid", cozysessID).WithHost(slug + "." + testInstance.Domain).Expect().Status(200) require.Eventually(t, func() bool { return len(readEvents()) == 4 }, 5*time.Second, 10*time.Millisecond) var identified int for _, event := range readEvents() { if event.Identity == testInstance.Domain { identified++ } } require.Equal(t, 1, identified, "only an authenticated Cozy session establishes identity") eAnon.GET("/public").WithQuery("sharecode", code).WithCookie("cozysessid", "invalid").WithHost(slug + "." + testInstance.Domain).Expect().Status(200) require.Eventually(t, func() bool { return len(readEvents()) == 5 }, 5*time.Second, 10*time.Millisecond) identified = 0 for _, event := range readEvents() { if event.Identity != "" { identified++ } } require.Equal(t, 1, identified, "an invalid cookie does not establish identity") protectedCode := createShareCode("protected-event", "password") protected, err := permission.GetForShareCode(testInstance, protectedCode) require.NoError(t, err) eAnon.GET("/public").WithQuery("sharecode", protectedCode).WithHost(slug + "." + testInstance.Domain).Expect().Status(401) var protectedEvents []publiclink.Event require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", protected.ID())}, &protectedEvents)) require.Empty(t, protectedEvents) cookieName := "pass" + protected.ID() cookie, err := crypto.EncodeAuthMessage(crypto.MACConfig{Name: cookieName, MaxLen: 256}, testInstance.SessionSecret(), []byte(protected.ID()), nil) require.NoError(t, err) eAnon.GET("/public").WithQuery("sharecode", protectedCode).WithCookie(cookieName, string(cookie)).WithHost(slug + "." + testInstance.Domain).Expect().Status(200) require.Eventually(t, func() bool { require.NoError(t, couchdb.FindDocsUnoptimized(testInstance, consts.PublicLinkEvents, &couchdb.FindRequest{Selector: mango.Equal("link_id", protected.ID())}, &protectedEvents)) return len(protectedEvents) == 1 }, 5*time.Second, 10*time.Millisecond) }) body := eAnon.GET("/public"). WithQuery("sharecode", code). WithHost(slug + "." + testInstance.Domain). Expect().Status(200). Body() body.Contains(``) body.Contains(``) body.Contains(``) body.Contains(intent.ID()) body.Contains("PICK") body.Contains("io.cozy.foos") body.Contains("https://test-app.cozywithapps.example.net") }) t.Run("ServeWithoutIntentDoesNotInline", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) body := e.GET("/foo"). WithHost(slug+"."+testInstance.Domain). WithCookie("cozysessid", cozysessID). Expect().Status(200). Body() body.NotContains(`id="cozy-intent"`) }) t.Run("ServeWithAnIntentsClientURL", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) clientURLApp := &couchdb.JSONDoc{ Type: consts.Apps, M: map[string]interface{}{ "_id": consts.Apps + "/clienturl-app", "name": "ClientURLApp", "slug": "clienturl-app", "source": "git://github.com/cozy/clienturl.git", "state": apps.Ready, "client_url_flag": "clienturl_csp_flag", "routes": apps.Routes{ "/foo": apps.Route{ Folder: "/", Index: "index.html", Public: false, }, }, "permissions": permission.Set{}, "version": "1.0.0", }, } require.NoError(t, couchdb.CreateNamedDoc(testInstance, clientURLApp)) t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, clientURLApp) }) testutils.WithFlag(t, testInstance, "clienturl_csp_flag", "https://external.example.com") intent := &intent.Intent{ Action: "PICK", Type: "io.cozy.foos", Client: "io.cozy.apps/clienturl-app", } err := intent.Save(testInstance) require.NoError(t, err) err = intent.FillServices(testInstance) require.NoError(t, err) require.Len(t, intent.Services, 1) err = intent.Save(testInstance) require.NoError(t, err) u, err := url.Parse(intent.Services[0].Href) require.NoError(t, err) csp := e.GET(u.Path). WithHost(slug+"."+testInstance.Domain). WithQueryString(u.RawQuery). WithCookie("cozysessid", cozysessID). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.Contains("frame-ancestors 'self' https://external.example.com https://clienturl-app.cozywithapps.example.net;") }) t.Run("ServeDataProxyWithIntentAllowsContainerAppsFrame", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) require.NoError(t, setup.InstallMiniDataProxy(t)) containerApp := &couchdb.JSONDoc{ Type: consts.Apps, M: map[string]interface{}{ "_id": consts.Apps + "/container-app", "name": "ContainerApp", "slug": "container-app", "source": "git://github.com/cozy/containerapp.git", "state": apps.Ready, "client_url_flag": "container_csp_flag", "routes": apps.Routes{}, "permissions": permission.Set{}, "version": "1.0.0", }, } require.NoError(t, couchdb.CreateNamedDoc(testInstance, containerApp)) t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, containerApp) }) testutils.WithFlag(t, testInstance, "container_csp_flag", "https://external.example.com") intent := &intent.Intent{ Action: "PICK", Type: "io.cozy.foos", Client: "io.cozy.apps/container-app", Services: []intent.Service{{Slug: consts.DataProxySlug, Href: "/"}}, } require.NoError(t, intent.Save(testInstance)) csp := e.GET("/"). WithHost(consts.DataProxySlug + "." + testInstance.Domain). WithQueryString("intent=" + intent.ID()). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.Contains("frame-ancestors 'self'") // Cozy root (login page rule) csp.Contains("https://cozywithapps.example.net/") // All container apps and their external clients csp.Contains("https://external.example.com") csp.Contains("https://container-app.cozywithapps.example.net") // Apps without a client_url_flag are not added csp.NotContains("https://mini.cozywithapps.example.net") }) t.Run("ServeAddsNoExternalIntentServiceWithoutServiceURLFlag", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) // The flag has a valid value, but no app declares it as its // service_url_flag: nothing is added to frame-src testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://calendar.external.example.com/app/") csp := e.GET("/foo/"). WithHost(slug+"."+testInstance.Domain). WithCookie("cozysessid", cozysessID). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.NotContains("calendar.external.example.com") }) t.Run("ServeAddsExternalIntentServicesToFrameSrc", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) externalServiceApp := &couchdb.JSONDoc{ Type: consts.Apps, M: map[string]interface{}{ "_id": consts.Apps + "/extservice-app", "name": "ExtServiceApp", "slug": "extservice-app", "source": "git://github.com/cozy/extservice.git", "state": apps.Ready, "service_url_flag": "extservice_url_flag", "routes": apps.Routes{}, "permissions": permission.Set{}, "version": "1.0.0", }, } require.NoError(t, couchdb.CreateNamedDoc(testInstance, externalServiceApp)) t.Cleanup(func() { _ = couchdb.DeleteDoc(testInstance, externalServiceApp) }) // Flag absent: nothing is added csp := e.GET("/foo/"). WithHost(slug+"."+testInstance.Domain). WithCookie("cozysessid", cozysessID). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.NotContains("calendar.external.example.com") csp.NotContains("https://extservice-app.cozywithapps.example.net") // Flag set: its origin (not its path) is added to frame-src testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://calendar.external.example.com/app/") csp = e.GET("/foo/"). WithHost(slug+"."+testInstance.Domain). WithCookie("cozysessid", cozysessID). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.Match(`frame-src [^;]*https://calendar\.external\.example\.com[ ;]`) csp.NotContains("https://calendar.external.example.com/app/") // Flag with a CSP separator in its host: nothing is added testutils.WithFlag(t, testInstance, "extservice_url_flag", "https://x.example;sandbox") csp = e.GET("/foo/"). WithHost(slug+"."+testInstance.Domain). WithCookie("cozysessid", cozysessID). Expect().Status(200). Header(echo.HeaderContentSecurityPolicy) csp.NotContains("x.example") csp.NotContains("sandbox") }) t.Run("NoScriptTagBreakoutIntentData", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) intent := &intent.Intent{ Action: "PICK", Type: "io.cozy.foos", Client: "io.cozy.apps/" + slug, } err := intent.Save(testInstance) require.NoError(t, err) err = intent.FillServices(testInstance) require.NoError(t, err) require.Len(t, intent.Services, 1) intent.Action = "PICK" err = intent.Save(testInstance) require.NoError(t, err) u, err := url.Parse(intent.Services[0].Href) require.NoError(t, err) body := e.GET(u.Path). WithHost(slug+"."+testInstance.Domain). WithQueryString(u.RawQuery). WithCookie("cozysessid", cozysessID). Expect().Status(200). Body().Raw() // IntentData returns template.HTML (raw, unescaped by html/template). // json.Marshal escapes < to \u003c to prevent breakout // inside the JSON embedded in a