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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
224
internal/app/spa_test.go
Normal file
224
internal/app/spa_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue