| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "testing" |
| 7 | |
| 8 | "amadan.net/oficina/estilo.git/sky" |
| 9 | "amadan.net/oficina/estilo.git/theme" |
| 10 | "github.com/go-chi/chi/v5" |
| 11 | ) |
| 12 | |
| 13 | // sharedPublicPaths are served on BOTH Home origins: the design tokens, the |
| 14 | // brand marks, Calma's font and sky, and the front page's stylesheet and fonts. They are listed |
| 15 | // once because two copies of this list would drift, and a route that |
| 16 | // quietly stopped being shared — or quietly started — is exactly what the |
| 17 | // two tests below exist to catch. |
| 18 | var sharedPublicPaths = []string{ |
| 19 | "/healthz", "/static/tokens.css", "/static/theme.css", |
| 20 | "/static/brand/icon.svg", "/static/brand/icon-32.png", "/static/brand/apple-touch-icon.png", "/static/brand/brand.css", "/static/brand/LICENSE.lucide", "/static/brand/docs.svg", "/static/brand/sheets.svg", "/static/brand/slides.svg", "/static/brand/calendar.svg", "/static/brand/calls.svg", "/static/brand/memoria.svg", |
| 21 | "/static/brand/fichas.svg", "/static/brand/mail.svg", "/static/brand/files.svg", |
| 22 | // Calma: Onest, the sky and its stylesheet, and Home's rules for placing it. |
| 23 | "/static/estilo/fonts/v1/*", "/static/estilo/sky/v1/*", "/static/estilo/sky.css", "/static/calma.css", |
| 24 | "/static/busy.css", "/static/busy.js", |
| 25 | "/static/site/home.css", "/static/site/apps.css", |
| 26 | // estilo's shared chrome: base links, menus, the bar and the door. |
| 27 | "/static/estilo/base.css", "/static/estilo/menu.css", "/static/estilo/chrome.css", |
| 28 | } |
| 29 | |
| 30 | // examplePath is a real file under a wildcard route, so fetching it |
| 31 | // proves the handler is mounted rather than merely that chi has a pattern. |
| 32 | func examplePath(route string) string { |
| 33 | switch route { |
| 34 | case "/static/estilo/fonts/v1/*": |
| 35 | return theme.FontPrefix + "onest-latin.woff2" |
| 36 | case "/static/estilo/sky/v1/*": |
| 37 | for _, layer := range sky.For(sky.SuiteSeed).Layers { |
| 38 | if layer.Src != "" { // the placeholder is inline, with no URL |
| 39 | return layer.Src |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | return route |
| 44 | } |
| 45 | |
| 46 | func sharedPublic(route string) bool { |
| 47 | for _, p := range sharedPublicPaths { |
| 48 | if p == route { |
| 49 | return true |
| 50 | } |
| 51 | } |
| 52 | return false |
| 53 | } |
| 54 | |
| 55 | func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) { |
| 56 | app := newTestApp(t) |
| 57 | router := app.Router() |
| 58 | routes := router.(chi.Routes) |
| 59 | count := 0 |
| 60 | err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { |
| 61 | // "/" is the front page, which both origins answer (the credential |
| 62 | // host redirects rather than rendering — see the test below). |
| 63 | switch { |
| 64 | case route == "/", route == "/help/open-source", route == "/static/credits.css", sharedPublic(route): |
| 65 | return nil |
| 66 | } |
| 67 | count++ |
| 68 | req := httptest.NewRequest(method, "https://"+directoryHost+route, nil) |
| 69 | req.Header.Set("Origin", Origin) |
| 70 | rec := httptest.NewRecorder() |
| 71 | router.ServeHTTP(rec, req) |
| 72 | if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { |
| 73 | t.Errorf("%s %s on directory: status=%d cookies=%d body=%q", method, route, rec.Code, len(rec.Result().Cookies()), rec.Body.String()) |
| 74 | } |
| 75 | return nil |
| 76 | }) |
| 77 | if err != nil || count < 19 { |
| 78 | t.Fatalf("route walk err=%v credential routes=%d", err, count) |
| 79 | } |
| 80 | if _, ok := app.mailer.last(); ok { |
| 81 | t.Fatal("directory request sent mail") |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) { |
| 86 | app := newTestApp(t) |
| 87 | req := httptest.NewRequest(http.MethodGet, Origin+"/?team=untrusted", nil) |
| 88 | rec := httptest.NewRecorder() |
| 89 | app.Router().ServeHTTP(rec, req) |
| 90 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/" || rec.Body.Len() != 0 { |
| 91 | t.Fatalf("credential root: status=%d location=%q body=%q", rec.Code, rec.Header().Get("Location"), rec.Body.String()) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestUnknownHostsCannotReachRoutes(t *testing.T) { |
| 96 | app := newTestApp(t) |
| 97 | router := app.Router() |
| 98 | hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"} |
| 99 | err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { |
| 100 | for _, host := range hosts { |
| 101 | req := httptest.NewRequest(method, route, nil) |
| 102 | req.Host = host |
| 103 | req.Header.Set("Origin", Origin) |
| 104 | req.Header.Set("X-Forwarded-Host", credentialHost) |
| 105 | req.Header.Set("Forwarded", "host="+credentialHost) |
| 106 | rec := httptest.NewRecorder() |
| 107 | router.ServeHTTP(rec, req) |
| 108 | if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { |
| 109 | t.Errorf("%s %s host=%q status=%d", method, route, host, rec.Code) |
| 110 | } |
| 111 | } |
| 112 | return nil |
| 113 | }) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestDirectoryIsNotWithinCredentialRP(t *testing.T) { |
| 120 | app := newTestApp(t) |
| 121 | if credentialHost == directoryHost || app.credentialHost == directoryHost || Origin == directoryOrigin { |
| 122 | t.Fatal("directory overlaps the credential origin or relying party") |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestSharedRoutesOnBothOrigins(t *testing.T) { |
| 127 | app := newTestApp(t) |
| 128 | for _, host := range []string{credentialHost, directoryHost} { |
| 129 | for _, path := range sharedPublicPaths { |
| 130 | path = examplePath(path) |
| 131 | req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil) |
| 132 | rec := httptest.NewRecorder() |
| 133 | app.Router().ServeHTTP(rec, req) |
| 134 | if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 { |
| 135 | t.Errorf("host=%s path=%s status=%d cookies=%d", host, path, rec.Code, len(rec.Result().Cookies())) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestDirectoryCredentialPostRefusedBeforeCSRF(t *testing.T) { |
| 142 | app := newTestApp(t) |
| 143 | req := httptest.NewRequest(http.MethodPost, directoryOrigin+"/signin", nil) |
| 144 | rec := httptest.NewRecorder() |
| 145 | app.Router().ServeHTTP(rec, req) |
| 146 | if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 { |
| 147 | t.Fatalf("directory credential POST status=%d body=%q", rec.Code, rec.Body.String()) |
| 148 | } |
| 149 | } |
| 150 | |