// 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