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

524
internal/app/app.go Normal file
View file

@ -0,0 +1,524 @@
// Package app wires the application together: configuration, store, auth,
// HTTP, and the lifecycle that starts and stops them.
//
// Keeping the wiring in one place means main() is a dozen lines, the whole
// object graph can be constructed in a test, and there is exactly one place to
// look to answer "what actually runs in production".
package app
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/store"
)
// Build information, stamped at link time with -ldflags -X.
//
// Every value has a usable default so that `go run ./cmd/go-bananas` and a plain
// `go build` both work without ceremony, and so that a binary built by hand still
// reports something meaningful rather than an empty string in a log line.
var (
// Version is the release version, e.g. 1.4.2.
Version = "dev"
// Commit is the source revision.
Commit = "unknown"
// BuildDate is the RFC 3339 build timestamp.
BuildDate = "unknown"
)
// App is the assembled application.
type App struct {
Config config.Config
Log logging.Logger
Store *store.Store
Cache *httpapi.ThemeCache
Auth *auth.Authenticator
Router http.Handler
Server *http.Server
metrics *httpapi.Metrics
// listener is bound before Serve so that the ready log line can report the
// real address, which matters when the configured port is 0.
listener net.Listener
}
// Options are the inputs needed to build an App.
type Options struct {
// Config is the resolved configuration.
Config config.Config
// Version is the build version.
Version string
// Logger overrides the constructed logger, for tests.
Logger logging.Logger
}
// New builds the application without starting it.
//
// Construction validates the whole object graph and reconciles the built-in
// themes, so a misconfigured deployment or a broken migration fails here rather
// than on the first request.
func New(ctx context.Context, opts Options) (*App, error) {
cfg := opts.Config
if cfg.StaticDir == "" && cfg.SDKDir == "" {
return nil, errors.New("neither STATIC_DIR nor SDK_DIR is set")
}
log := opts.Logger
if cfg.EphemeralSessionSecret() {
// Worth a warning: an operator debugging a session that "keeps logging
// me out" in local development needs to know why.
if log == nil {
log = logging.New(logging.Options{
Level: cfg.LogLevel, Format: cfg.LogFormat,
Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version,
})
}
log.Warn("SESSION_SECRET was not set; an ephemeral one was generated. " +
"Sessions will not survive a restart. Set SESSION_SECRET to at least 32 bytes.")
}
if log == nil {
log = logging.New(logging.Options{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
Service: "Go Bananas!!",
Environment: cfg.Environment,
Version: opts.Version,
})
}
st, err := store.Open(ctx, store.Options{
Path: cfg.DatabasePath,
BusyTimeout: cfg.DatabaseBusyTimeout,
MaxConns: cfg.DatabaseMaxConns,
})
if err != nil {
return nil, fmt.Errorf("open store: %w", err)
}
cache := httpapi.NewThemeCache()
if cfg.SeedOnStart {
inserted, refreshed, err := st.Seed(ctx, seed.Themes())
if err != nil {
st.Close()
return nil, fmt.Errorf("seed built-in themes: %w", err)
}
if inserted > 0 || refreshed > 0 {
log.Info("built-in themes reconciled",
"inserted", inserted, "refreshed", refreshed, "total", len(seed.Slugs()))
}
}
authenticator, err := buildAuthenticator(ctx, cfg, st, log)
if err != nil {
st.Close()
return nil, err
}
// Warming the render cache at start-up means the first request after a deploy
// is already fast. Failure is not fatal: the cache is an optimisation.
warmed := 0
if themes, err := st.ListRuntime(ctx); err == nil {
for _, theme := range themes {
warmed += cache.Prefetch(theme, nil)
}
}
if warmed > 0 {
log.Debug("render cache warmed", "artefacts", warmed)
}
metrics := httpapi.NewMetrics().WithCache(cache)
metrics.RegisterCounter("bananas_theme_publishes_total", "Themes moved to the published status.")
metrics.RegisterCounter("bananas_theme_updates_total", "Theme documents written.")
metrics.RegisterCounter("bananas_auth_failures_total", "Requests rejected for want of a usable credential.")
app := &App{
Config: cfg,
Log: log,
Store: st,
Cache: cache,
Auth: authenticator,
metrics: metrics,
}
app.Router = httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: cache,
Authenticator: authenticator,
Log: log,
Version: opts.Version,
Metrics: metrics,
StaticHandler: staticHandler(cfg.StaticDir),
SDKHandler: sdkHandler(cfg.SDKDir),
// Resolved per request rather than captured at start-up, so that changing the
// default theme in the admin UI takes effect without a restart.
//
// A missing setting yields the supplied default, which is the normal case.
// A database error is different: it would otherwise look like "no default
// configured" and every runtime request would fall through to asking the
// consumer for a theme. It is logged rather than propagated because this runs
// inside a request handler whose job is to serve a theme, and failing the
// whole request over a settings lookup would be a worse outcome than serving
// the fallback.
RuntimeDefaults: func() (string, domain.Appearance) {
slug, err := st.Setting(ctx, domain.SettingDefaultTheme, "")
if err != nil {
log.Warn("could not read the default theme setting", "error", err)
}
appearance, err := st.Setting(ctx, domain.SettingDefaultAppearance, string(domain.AppearanceLight))
if err != nil {
log.Warn("could not read the default appearance setting", "error", err)
}
return slug, domain.Appearance(appearance)
},
})
app.Server = &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router,
ReadTimeout: cfg.ReadTimeout,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
MaxHeaderBytes: 1 << 20,
ErrorLog: nil,
BaseContext: func(_ net.Listener) context.Context { return context.WithoutCancel(ctx) },
}
return app, nil
}
// buildAuthenticator constructs the auth stack for the configured mode.
func buildAuthenticator(ctx context.Context, cfg config.Config, st *store.Store, log logging.Logger) (*auth.Authenticator, error) {
switch cfg.AuthMode {
case config.AuthModeOIDC:
state := auth.NewStateStore(
cfg.SessionSecret,
"go-bananas-oidc-state",
cfg.SessionSecure,
sameSiteFrom(cfg.SessionSameSite),
10*time.Minute,
)
provider, err := auth.NewOIDCProvider(ctx, cfg, state)
if err != nil {
return nil, fmt.Errorf("identity provider: %w", err)
}
log.Info("identity provider configured",
"issuer", cfg.OIDCIssuer,
"clientId", cfg.OIDCClientID,
"redirectUrl", cfg.RedirectURL(),
"adminGroups", cfg.OIDCAdminGroups,
"editorGroups", cfg.OIDCEditorGroups,
)
return auth.New(cfg, provider, st)
case config.AuthModeDev:
log.Warn("AUTH MODE IS DEV: every request is authenticated as a local administrator. " +
"This mode is refused outside a development environment.")
return auth.New(cfg, nil, st)
case config.AuthModeNone:
log.Warn("AUTH MODE IS NONE: every request is granted administrator access. " +
"This mode is refused outside a development environment.")
return auth.New(cfg, nil, st)
default:
return nil, fmt.Errorf("unknown AUTH_MODE %q", cfg.AuthMode)
}
}
// sameSiteFrom maps a configuration value onto an http.SameSite.
func sameSiteFrom(raw string) (result sameSite) {
switch strings.ToLower(raw) {
case "strict":
return sameSiteStrict
case "none":
return sameSiteNone
default:
return sameSiteLax
}
}
// Run starts the server and blocks until ctx is cancelled.
//
// The shutdown sequence is the part worth reading: on cancellation the listener
// is closed first so no new connection is accepted, then in-flight requests get
// ShutdownTimeout to finish, and only then is the store closed. Closing the store
// while a request is still reading would surface as a confusing 500 to a user
// who did nothing wrong.
func (a *App) Run(ctx context.Context) error {
listenCfg := net.ListenConfig{}
listener, err := listenCfg.Listen(ctx, "tcp", a.Config.HTTPAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", a.Config.HTTPAddr, err)
}
a.listener = listener
a.Log.Info("starting Go Bananas!!",
"address", listener.Addr().String(),
"publicUrl", a.Config.Origin(),
"authMode", a.Config.AuthMode,
"database", a.Config.DatabasePath,
"environment", a.Config.Environment,
"version", Version,
"commit", Commit,
"built", BuildDate,
)
serverErrors := make(chan error, 1)
go func() {
err := a.Server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
serverErrors <- err
}()
select {
case err := <-serverErrors:
if err != nil {
return fmt.Errorf("serve: %w", err)
}
return nil
case <-ctx.Done():
a.Log.Info("shutdown signal received", "graceful", true)
}
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.Config.ShutdownTimeout)
defer cancel()
if err := a.Server.Shutdown(shutdownCtx); err != nil {
// A timeout here means a request outlived the grace period. Closing hard
// is better than hanging, and the error is logged so it is visible.
a.Log.Error("graceful shutdown timed out; closing connections", "error", err)
if closeErr := a.Server.Close(); closeErr != nil {
a.Log.Error("force close failed", "error", closeErr)
}
}
if err := <-serverErrors; err != nil {
return fmt.Errorf("serve: %w", err)
}
a.Log.Info("stopped cleanly")
return nil
}
// Addr returns the bound address, useful when the configured port is 0.
func (a *App) Addr() string {
if a.listener == nil {
return a.Config.HTTPAddr
}
return a.listener.Addr().String()
}
// Close releases the application's resources.
func (a *App) Close() error {
if a.Store == nil {
return nil
}
return a.Store.Close()
}
// Run is the top-level entrypoint: load configuration, build, run, shut down.
func Run(ctx context.Context) error {
cfg, err := config.Load()
if err != nil {
// Configuration errors are reported plainly rather than through the logger,
// because the logger's own configuration may be part of what is wrong.
return fmt.Errorf("%w", err)
}
application, err := New(ctx, Options{Config: cfg, Version: Version})
if err != nil {
return err
}
defer application.Close()
return application.Run(ctx)
}
// staticHandler serves the built admin UI, or nil when no build is present.
//
// A missing UI is not fatal: the API is independently useful, and a deployment
// that only serves the runtime endpoints has no reason to carry the SPA.
func staticHandler(dir string) http.Handler {
if dir == "" {
return nil
}
index := filepath.Join(dir, "index.html")
if _, err := os.Stat(index); err != nil {
return nil
}
fileServer := spaHandler{root: dir, files: http.FileServer(http.Dir(dir))}
return fileServer
}
// spaHandler serves the admin single-page application.
//
// Any GET that does not resolve to a real file falls through to index.html so
// that client-side routes survive a page reload. An unknown /api or /rt path must
// still 404 as JSON rather than returning HTML, which is handled by the router
// before this handler is reached.
type spaHandler struct {
root string
files http.Handler
}
// withinRoot reports whether path is root or lives beneath it.
//
// It compares path components rather than string prefixes, because a prefix test
// cannot tell "/srv/web" from "/srv/web-secrets": the first contains the second as
// a leading substring, and a request for the second would pass a HasPrefix check
// against the first.
func withinRoot(root, path string) bool {
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
// The root is resolved when it exists. When it does not, Abs still gives an
// absolute path to compare against, which is the best available answer.
if resolvedRoot, resolveErr := filepath.EvalSymlinks(absRoot); resolveErr == nil {
absRoot = resolvedRoot
}
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
// ".." is the only relative path that leaves the root. "." is the root itself,
// which is allowed; a path equal to the root is not a file but reaching it is
// not an escape.
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// ServeHTTP implements http.Handler.
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Anchoring the path with a leading slash and cleaning it removes every ".."
// segment, so Join below cannot escape the root. The check after it is a
// belt-and-braces assertion that the result really is inside, rather than a
// prefix test: a plain HasPrefix against the root would accept a sibling
// directory whose name merely starts with the root's.
cleaned := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
candidate := filepath.Join(h.root, cleaned)
if !withinRoot(h.root, candidate) {
http.Error(w, "not found", http.StatusNotFound)
return
}
// The static file server resolves symlinks, so a symlink inside the web
// directory pointing at /etc would otherwise be served. Resolving the real
// path and re-checking it is what actually closes that: the root itself is
// resolved too, so a root reached through a symlink does not trip the check.
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
// A path that does not exist is not an error here: the SPA fallback below
// serves the shell for it. Only a real failure is reported.
resolved = candidate
}
if !withinRoot(h.root, resolved) {
http.Error(w, "not found", http.StatusNotFound)
return
}
//nolint:gosec // G703: as on the ServeFile call below; the taint source is
// r.URL.Path and both paths it reaches are checked by withinRoot.
if info, err := os.Stat(resolved); err == nil && !info.IsDir() {
setStaticCacheHeaders(w, resolved)
// ServeFile rather than the file server, because the request URL is the
// uncleaned path and the file to serve is the resolved one. The directory
// case cannot reach here, which is the only case in which ServeFile
// redirects.
//
//nolint:gosec // G703: gosec's taint analysis cannot see withinRoot. The
// containment predicate compares path components rather than prefixes, so a
// sibling directory named "web-secrets" beside "web" is rejected, and
// resolved above is the symlink-resolved path that was re-checked against it.
// TestSPARejectsTraversal, TestSPARejectsSymlinkEscape and TestWithinRoot are
// what hold this in place.
http.ServeFile(w, r, resolved)
return
}
// Serve the shell for a client-side route. The request path is rewritten to
// the directory root rather than to index.html: http.FileServer answers a
// request for /index.html with a 301 to /, which would turn every deep link
// into a redirect loop against the SPA router.
w.Header().Set("Cache-Control", "no-cache")
h.files.ServeHTTP(w, indexRequest(r))
}
// indexRequest rewrites a request onto the SPA entry point.
func indexRequest(r *http.Request) *http.Request {
clone := r.Clone(r.Context())
clone.URL.Path = "/"
return clone
}
// staticAssetSuffixes are the file extensions that may be cached indefinitely,
// because Vite emits content-hashed names for them.
var staticAssetSuffixes = []string{".js", ".css", ".woff", ".woff2", ".svg", ".png", ".jpg", ".webp", ".avif"}
// setStaticCacheHeaders chooses a cache policy for a static asset.
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
for _, suffix := range staticAssetSuffixes {
if strings.HasSuffix(path, suffix) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
}
w.Header().Set("Cache-Control", "public, max-age=300")
}
// sameSite aliases keep the http.SameSite constants out of this file's imports,
// which keeps the mapping in one readable block.
type sameSite = http.SameSite
// Cookie SameSite modes, re-exported for the configuration mapping above.
const (
sameSiteLax = http.SameSiteLaxMode
sameSiteStrict = http.SameSiteStrictMode
sameSiteNone = http.SameSiteNoneMode
)
// sdkHandler serves the built runtime SDK bundles, or nil when no build is
// present.
//
// The SDK is served from the same origin as the runtime API so that a consuming
// application can load it with a plain URL and no build step, which is the whole
// point of delivering themes at runtime rather than through a package registry.
func sdkHandler(dir string) http.Handler {
if dir == "" {
return nil
}
if _, err := os.Stat(filepath.Join(dir, "runtime.js")); err != nil {
return nil
}
return http.StripPrefix("/rt/v1/", http.FileServer(http.Dir(dir)))
}

105
internal/app/backup.go Normal file
View file

@ -0,0 +1,105 @@
package app
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// RunBackup is the `go-bananas backup` subcommand: it writes a consistent copy of
// the SQLite database and exits.
//
// It exists as a subcommand rather than as a separate tool, or as a shell script
// copying the file, for two reasons.
//
// The first is correctness. `cp` on a live database produces a torn copy, and the
// result can open without complaint while being corrupt. SQLite's VACUUM INTO
// reads through the database's own backup machinery and produces a consistent
// snapshot while the service keeps serving.
//
// The second is packaging. The alternative is a container that ships a `sqlite3`
// binary, which means either a third-party image or installing a package into a
// container whose root filesystem is deliberately read-only. The SQLite library is
// already linked into this binary, at exactly the version the schema was written
// for, so using it removes both problems.
//
// The Helm chart's snapshot CronJob runs this command. A deployment that does not
// use the chart can run it from anywhere the binary is.
func RunBackup(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var (
destination = fs.String("to", "", "Path to write the snapshot to. Required.")
database = fs.String("database", "", "Path to the SQLite database. Defaults to DATABASE_PATH.")
timeout = fs.Duration("timeout", 5*time.Minute, "Give up after this long.")
)
fs.Usage = func() {
fmt.Fprintf(os.Stderr,
"usage: go-bananas backup --to <path> [--database <path>]\n\n"+
"Writes a consistent snapshot of the database using SQLite's online backup.\n"+
"It is safe to run against a database that is being written to.\n\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return fmt.Errorf("%w: %w", domain.ErrInvalid, err)
}
if *destination == "" {
fs.Usage()
return fmt.Errorf("%w: --to is required", domain.ErrInvalid)
}
if fs.NArg() > 0 {
return fmt.Errorf("%w: unexpected argument %q", domain.ErrInvalid, fs.Arg(0))
}
// The database path comes from the same environment variable the server reads,
// so a CronJob and the service cannot disagree about which file is being
// snapshotted. DATABASE_PATH alone is enough: the rest of the configuration is
// irrelevant to a backup, and requiring it would mean a snapshot job failing
// because an unrelated setting is absent.
path := *database
if path == "" {
path = os.Getenv("DATABASE_PATH")
}
if path == "" {
path = config.Defaults().DatabasePath
}
if !store.IsPersistentPath(path) {
return fmt.Errorf("%w: cannot snapshot an ephemeral database (%s); it holds no data once the process exits",
domain.ErrInvalid, path)
}
// Bounded, because a snapshot that hangs holds the CronJob's slot forever and a
// snapshot of a very large database is genuinely slow.
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
st, err := store.Open(ctx, store.Options{Path: path})
if err != nil {
return fmt.Errorf("open database for snapshot: %w", err)
}
defer func() { _ = st.Close() }()
target := *destination
if !filepath.IsAbs(target) {
// Made absolute so the log line says where the file actually is, rather than
// leaving the reader to resolve it against whatever the working directory was.
abs, absErr := filepath.Abs(target)
if absErr == nil {
target = abs
}
}
if err := st.Snapshot(ctx, target); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "snapshot written to %s\n", target)
return nil
}

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)
}
}
}