package app import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // These tests cover the static file handler, which is the one place in the service // where a request path is turned into a filesystem path. Everything else operates on // database rows or JSON, so the containment logic here is the only traversal // defence in the codebase and it needs its own tests. // newSPAFixture builds a web root with an index, a stylesheet and a secret file // outside the root. func newSPAFixture(t *testing.T) (root, outside string, handler http.Handler) { t.Helper() base := t.TempDir() root = filepath.Join(base, "web") outside = filepath.Join(base, "secret.txt") if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } writeFile(t, filepath.Join(root, "index.html"), "app") writeFile(t, filepath.Join(root, "assets", "app.css"), "body{color:red}") // The name Vite actually emits: an extension from staticAssetSuffixes with a // content hash in it. writeFile(t, filepath.Join(root, "assets", "app-CyCU_ony.js"), "export const x=1") writeFile(t, outside, "top secret") handler = spaHandler{root: root, files: http.FileServer(http.Dir(root))} return root, outside, handler } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)) return rec } // TestSPAServesTheShellForClientRoutes covers the single-page-app fallback: an // unknown path that is not a file is the router's problem, not a 404. func TestSPAServesTheShellForClientRoutes(t *testing.T) { _, _, handler := newSPAFixture(t) for _, path := range []string{"/", "/themes/midnight", "/keys", "/a/deep/client/route"} { t.Run(path, func(t *testing.T) { rec := get(t, handler, path) if rec.Code != http.StatusOK { t.Fatalf("got %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "app") { t.Errorf("did not serve the shell: %q", rec.Body.String()) } }) } } // TestSPAServesRealFiles covers the assets themselves, including a nested path. func TestSPAServesRealFiles(t *testing.T) { _, _, handler := newSPAFixture(t) rec := get(t, handler, "/assets/app.css") if rec.Code != http.StatusOK { t.Fatalf("got %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "color:red") { t.Errorf("unexpected body %q", rec.Body.String()) } } // TestSPACacheHeaders pins the caching contract, which is what decides whether a // deploy reaches users holding a cached copy. // // The rule is extension-based: an asset extension is assumed to be content-hashed by // the frontend build, and is therefore immutable for a year. Everything else gets a // short max-age, because a document that is replaced under the same name must not // be cached indefinitely. func TestSPACacheHeaders(t *testing.T) { _, _, handler := newSPAFixture(t) cases := []struct { path string immutable bool }{ {"/assets/app-CyCU_ony.js", true}, {"/assets/app.css", true}, {"/index.html", false}, {"/themes/midnight", false}, // the SPA shell for a client route } for _, tc := range cases { t.Run(tc.path, func(t *testing.T) { cc := get(t, handler, tc.path).Header().Get("Cache-Control") if immutable := strings.Contains(cc, "immutable"); immutable != tc.immutable { t.Errorf("Cache-Control %q, immutable=%v, want %v", cc, immutable, tc.immutable) } }) } } // TestSPARejectsTraversal is the security test. Every one of these paths is an // attempt to read a file outside the web root, and each must fail to return the // file's contents. // // The interesting cases are the encoded and mixed ones: a naive handler that // strips ".." once, or that compares paths with HasPrefix, passes at least one. func TestSPARejectsTraversal(t *testing.T) { root, _, handler := newSPAFixture(t) // A sibling directory whose name starts with the root's name. A HasPrefix // containment check accepts this; a component comparison does not. sibling := root + "-secrets" if err := os.MkdirAll(sibling, 0o755); err != nil { t.Fatalf("mkdir sibling: %v", err) } writeFile(t, filepath.Join(sibling, "leak.txt"), "sibling secret") cases := []string{ "/../secret.txt", "/../../etc/passwd", "/assets/../../secret.txt", "/./../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt", "/assets/%2e%2e%2f%2e%2e%2fsecret.txt", "/../web-secrets/leak.txt", } for _, path := range cases { t.Run(path, func(t *testing.T) { rec := get(t, handler, path) body := rec.Body.String() if strings.Contains(body, "top secret") || strings.Contains(body, "sibling secret") { t.Fatalf("leaked a file outside the root: %q", body) } // Serving the shell instead of a 404 is acceptable; that is what the SPA // fallback does for any path that is not a file. if rec.Code == http.StatusOK && !strings.Contains(body, "app") { t.Errorf("unexpected 200 body: %q", body) } }) } } // TestSPARejectsSymlinkEscape covers the case a string comparison cannot: the path // is inside the root and contains no "..", but a symlink points outside it. func TestSPARejectsSymlinkEscape(t *testing.T) { base := t.TempDir() root := filepath.Join(base, "web") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatalf("mkdir: %v", err) } writeFile(t, filepath.Join(root, "index.html"), "app") target := filepath.Join(base, "secret.txt") writeFile(t, target, "top secret") // Skipped where symlinks are unavailable, which is a Windows privilege rather // than a reason to fail the suite. if err := os.Symlink(target, filepath.Join(root, "link.txt")); err != nil { t.Skipf("symlinks unavailable: %v", err) } handler := spaHandler{root: root, files: http.FileServer(http.Dir(root))} rec := get(t, handler, "/link.txt") if strings.Contains(rec.Body.String(), "top secret") { t.Errorf("served a file through a symlink out of the root: %q", rec.Body.String()) } } // TestSPARejectsNonGETMethods keeps the handler read-only. It has no mutating route, // so a POST should not be answered at all rather than quietly treated as a GET. func TestSPARejectsNonGETMethods(t *testing.T) { _, _, handler := newSPAFixture(t) for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { t.Run(method, func(t *testing.T) { rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), method, "/", nil)) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("got %d, want 405", rec.Code) } }) } } // TestWithinRoot covers the containment predicate directly, including the sibling // directory that separates it from a prefix comparison. func TestWithinRoot(t *testing.T) { root := filepath.Join(t.TempDir(), "web") inside := []string{root, filepath.Join(root, "a.txt"), filepath.Join(root, "a", "b", "c.txt")} for _, path := range inside { if !withinRoot(root, path) { t.Errorf("withinRoot(%q, %q) = false, want true", root, path) } } outside := []string{ filepath.Join(root, "..", "secret.txt"), root + "-secrets", filepath.Join(root, ".."), "/etc/passwd", root + "X", } for _, path := range outside { if withinRoot(root, path) { t.Errorf("withinRoot(%q, %q) = true, want false", root, path) } } }