initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

224
internal/app/spa_test.go Normal file
View file

@ -0,0 +1,224 @@
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"), "<!doctype html><title>app</title>")
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(), "<title>app</title>") {
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, "<title>app</title>") {
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"), "<!doctype html><title>app</title>")
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)
}
}
}