package boardwalk import ( "io/fs" "net/http" "net/http/httptest" "path" "regexp" "strings" "testing" "testing/fstest" ) const testPrefix = "/acme-admin" var ( assetRef = regexp.MustCompile(`(?:src|href)="([^"]+)"`) scriptTag = regexp.MustCompile(`]*>`) ) type apiSpy struct{ calls int } func (s *apiSpy) ServeHTTP(w http.ResponseWriter, _ *http.Request) { s.calls++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Not found","details":{}}}`)) } func newTestHandler(t *testing.T) (http.Handler, *apiSpy) { t.Helper() spy := &apiSpy{} h, err := Handler(testPrefix, spy) if err != nil { t.Fatal(err) } return h, spy } func get(h http.Handler, target string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } func TestIndexRewrite(t *testing.T) { h, _ := newTestHandler(t) for _, target := range []string{testPrefix, testPrefix + "/", testPrefix + "/index.html"} { rec := get(h, target) body := rec.Body.String() if rec.Code != http.StatusOK { t.Fatalf("%s status=%d", target, rec.Code) } if !strings.Contains(body, ``), testPrefix); err == nil { t.Fatal("index without the base token was accepted") } root := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("")}} if _, err := newHandler(root, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) { t.Fatalf("stale dist accepted: %v", err) } if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil { t.Fatal("dist without index.html accepted") } if _, err := Handler(testPrefix, nil); err == nil { t.Fatal("nil API not-found handler accepted") } if _, err := Handler("acme-admin", &apiSpy{}); err == nil { t.Fatal("prefix without a leading slash accepted") } } func TestRewriteIndexEscapesPrefix(t *testing.T) { out, err := RewriteIndex([]byte(``), `/a"b`) if err != nil { t.Fatal(err) } if strings.Contains(string(out), `"/a"b`) || !strings.Contains(string(out), `/a"b`) { t.Fatalf("prefix not escaped: %s", out) } } func TestEveryReferencedAssetIsEmbedded(t *testing.T) { root, err := Dist() if err != nil { t.Fatal(err) } raw, err := fs.ReadFile(root, "index.html") if err != nil { t.Fatal(err) } index, err := RewriteIndex(raw, testPrefix) if err != nil { t.Fatal(err) } refs := assetRef.FindAllStringSubmatch(string(index), -1) if len(refs) == 0 { t.Fatal("index references no assets") } h, _ := newTestHandler(t) for _, ref := range refs { target := ref[1] if !strings.HasPrefix(target, testPrefix+"/") { t.Fatalf("reference %q is not under the prefix", target) } name := strings.TrimPrefix(target, testPrefix+"/") if _, err := fs.Stat(root, name); err != nil { t.Fatalf("index references %s, missing from the embedded dist: %v", name, err) } if rec := get(h, target); rec.Code != http.StatusOK { t.Fatalf("GET %s status=%d", target, rec.Code) } } } func TestNoInlineScript(t *testing.T) { root, err := Dist() if err != nil { t.Fatal(err) } raw, err := fs.ReadFile(root, "index.html") if err != nil { t.Fatal(err) } tags := scriptTag.FindAllString(string(raw), -1) if len(tags) == 0 { t.Fatal("index has no module script") } for _, tag := range tags { if !strings.Contains(tag, " src=") { t.Fatalf("inline script in index.html: %s", tag) } } if strings.Contains(strings.ToLower(string(raw)), "javascript:") { t.Fatal("index.html contains a javascript: URL") } } func TestAPIPathsAreDelegated(t *testing.T) { h, spy := newTestHandler(t) for _, target := range []string{testPrefix + "/api", testPrefix + "/api/", testPrefix + "/api/v1/nope", testPrefix + "/api/v1/auth/login"} { before := spy.calls rec := get(h, target) if spy.calls != before+1 || rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "`)}, "assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")}, } h, err := newHandler(root, testPrefix, &apiSpy{}) if err != nil { t.Fatal(err) } for _, target := range []string{ testPrefix + "/../../../go.mod", testPrefix + "/assets/../../boardwalk.go", testPrefix + "/%2e%2e/%2e%2e/etc/passwd.txt", } { req := httptest.NewRequest(http.MethodGet, "/", nil) req.URL.Path = target rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "module ") || strings.Contains(rec.Body.String(), "package ") { t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String()) } } req := httptest.NewRequest(http.MethodGet, "/", nil) req.URL.Path = testPrefix + "/assets/../index.html" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), BaseToken) { t.Fatalf("cleaned index path served the raw index: %d %s", rec.Code, rec.Body.String()) } } func TestDirectoryIsNeverListed(t *testing.T) { h, _ := newTestHandler(t) root, err := Dist() if err != nil { t.Fatal(err) } entries, err := fs.ReadDir(root, "assets") if err != nil || len(entries) == 0 { t.Fatalf("embedded assets: %v", err) } for _, target := range []string{testPrefix + "/assets", testPrefix + "/assets/"} { rec := get(h, target) body := rec.Body.String() if strings.Contains(body, entries[0].Name()) || !strings.Contains(body, "summer-admin-base") { t.Fatalf("%s listed the directory or skipped the shell: %s", target, body) } } } func TestContentTypesAndCaching(t *testing.T) { h, _ := newTestHandler(t) root, err := Dist() if err != nil { t.Fatal(err) } want := map[string]string{ ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".woff2": "font/woff2", ".woff": "font/woff", } seen := map[string]bool{} err = fs.WalkDir(root, "assets", func(name string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } ext := path.Ext(name) ct, ok := want[ext] if !ok || seen[ext] { return nil } seen[ext] = true rec := get(h, testPrefix+"/"+name) if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != ct { t.Fatalf("%s status=%d type=%q, want %q", name, rec.Code, rec.Header().Get("Content-Type"), ct) } if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { t.Fatalf("%s Cache-Control=%q", name, cc) } return nil }) if err != nil { t.Fatal(err) } for ext := range want { if !seen[ext] { t.Fatalf("embedded dist has no %s asset", ext) } } index := get(h, testPrefix) if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") { t.Fatalf("index headers = %v", index.Header()) } if got := ContentType("x.svg"); got != "image/svg+xml" { t.Fatalf("svg type %q", got) } if got := ContentType("x.json"); got != "application/json" { t.Fatalf("json type %q", got) } if got := ContentType("x.unknown-ext"); got != "application/octet-stream" { t.Fatalf("unknown type %q", got) } } func TestSecurityHeadersOnEveryResponse(t *testing.T) { h, _ := newTestHandler(t) root, err := Dist() if err != nil { t.Fatal(err) } entries, err := fs.ReadDir(root, "assets") if err != nil || len(entries) == 0 { t.Fatalf("embedded assets: %v", err) } for _, target := range []string{ testPrefix, testPrefix + "/acme/demo/widgets", testPrefix + "/assets/" + entries[0].Name(), testPrefix + "/missing.js", testPrefix + "/api/v1/nope", } { rec := get(h, target) for header, want := range map[string]string{ "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'", "X-Robots-Tag": "noindex, nofollow", } { if got := rec.Header().Get(header); got != want { t.Fatalf("%s %s=%q, want %q", target, header, got, want) } } } } // TestPhase10BoardwalkServing covers the remaining serving branches: HEAD, // query strings, encoded traversal, the index requested by name, a nested // prefix, deep client routes, MIME fallback for unknown extensions and the // constructor's error paths. func TestPhase10BoardwalkServing(t *testing.T) { h, spy := newTestHandler(t) t.Run("HEAD answers headers without a body", func(t *testing.T) { for _, target := range []string{testPrefix, testPrefix + "/acme/demo/widgets"} { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, target, nil)) if rec.Code != http.StatusOK || rec.Body.Len() != 0 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { t.Fatalf("HEAD %s: status=%d len=%d type=%q", target, rec.Code, rec.Body.Len(), rec.Header().Get("Content-Type")) } if rec.Header().Get("Content-Length") == "" || rec.Header().Get("X-Frame-Options") != "DENY" { t.Fatalf("HEAD %s headers = %v", target, rec.Header()) } } }) t.Run("query strings do not change what is served", func(t *testing.T) { root, err := Dist() if err != nil { t.Fatal(err) } entries, err := fs.ReadDir(root, "assets") if err != nil || len(entries) == 0 { t.Fatalf("assets: %v", err) } asset := get(h, testPrefix+"/assets/"+entries[0].Name()+"?v=2") if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { t.Fatalf("asset with query: status=%d headers=%v", asset.Code, asset.Header()) } route := get(h, testPrefix+"/acme/demo/widgets?search=blue&page=2") if route.Code != http.StatusOK || !strings.Contains(route.Body.String(), "summer-admin-base") { t.Fatalf("client route with query: status=%d", route.Code) } before := spy.calls if rec := get(h, testPrefix+"/api/v1/nope?x=1"); rec.Code != http.StatusNotFound || spy.calls != before+1 { t.Fatalf("api with query not delegated: %d", rec.Code) } }) t.Run("encoded traversal never escapes the build", func(t *testing.T) { for _, target := range []string{ testPrefix + "/%2e%2e/%2e%2e/go.mod", testPrefix + "/assets/..%2f..%2fboardwalk.go", testPrefix + "/%2E%2E%2F%2E%2E%2Fgo.sum", } { rec := get(h, target) body := rec.Body.String() if rec.Code != http.StatusNotFound || strings.Contains(body, "module ") || strings.Contains(body, "package boardwalk") { t.Fatalf("%s status=%d body=%.80s", target, rec.Code, body) } } }) t.Run("index.html by name is the rewritten index", func(t *testing.T) { byName := get(h, testPrefix+"/index.html") root := get(h, testPrefix+"/") if byName.Code != http.StatusOK || byName.Body.String() != root.Body.String() { t.Fatalf("index.html differs from the root index") } if strings.Contains(byName.Body.String(), BaseToken) || byName.Header().Get("Cache-Control") != "no-store" { t.Fatalf("index.html served raw or cacheable: %v", byName.Header()) } }) t.Run("nested prefix with a trailing slash", func(t *testing.T) { nested, err := Handler("/ops/admin/", &apiSpy{}) if err != nil { t.Fatal(err) } rec := get(nested, "/ops/admin/acme/demo/widgets/12") body := rec.Body.String() if rec.Code != http.StatusOK || !strings.Contains(body, `content="/ops/admin"`) || !strings.Contains(body, `src="/ops/admin/assets/`) { t.Fatalf("nested prefix index: %d %s", rec.Code, body) } if rec := get(nested, "/ops/admin/api/v1/x"); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "`)}, "robots.txt": &fstest.MapFile{Data: []byte("User-agent: *\n")}, "assets/logo.png": &fstest.MapFile{Data: []byte("\x89PNG")}, "assets/blob.zzzext": &fstest.MapFile{Data: []byte("x")}, } mapped, err := newHandler(root, testPrefix, &apiSpy{}) if err != nil { t.Fatal(err) } for target, want := range map[string]string{ "/robots.txt": "text/plain; charset=utf-8", "/assets/logo.png": "image/png", "/assets/blob.zzzext": "application/octet-stream", } { rec := get(mapped, testPrefix+target) if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != want { t.Fatalf("%s type=%q, want %q", target, rec.Header().Get("Content-Type"), want) } } if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" { t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc) } if got := ContentType("UPPER.JS"); got != "text/javascript; charset=utf-8" { t.Fatalf("extension case: %q", got) } }) t.Run("constructor rejects a nil API handler, a relative prefix and a build without index", func(t *testing.T) { if _, err := Handler(testPrefix, nil); err == nil { t.Fatal("nil notFoundAPI accepted") } if _, err := Handler("backend", &apiSpy{}); err == nil { t.Fatal("relative prefix accepted") } if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), "index.html") { t.Fatalf("missing index: %v", err) } stale := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("")}} if _, err := newHandler(stale, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) { t.Fatalf("stale index: %v", err) } }) }