diff --git a/pkg/service/stockholm/static.go b/pkg/service/stockholm/static.go index 05702ef..87c3851 100644 --- a/pkg/service/stockholm/static.go +++ b/pkg/service/stockholm/static.go @@ -3,10 +3,10 @@ package stockholm import ( "encoding/json" "fmt" + "io" "log" "net/http" "os" - "path/filepath" "strings" ) @@ -44,6 +44,10 @@ func contentTypeFor(name string) string { } // ServeStatic handles all static file requests for the Stockholm frontend. +// All file operations are performed via an os.Root anchored at stockholmDir, +// which prevents path traversal at the OS level (go/path-injection, alerts +// 143–145). The URL-path → relative-path mapping is handled by +// resolveStaticRel; os.Root rejects any path that would escape stockholmDir. func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, backendCfg *BackendConfig, state *NativeState, cfg *Config) { method := strings.ToUpper(r.Method) if method != http.MethodGet && method != http.MethodHead { @@ -51,27 +55,46 @@ func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, ba return } - file, rel, err := resolveStaticFile(r.URL.Path, stockholmDir) + root, err := os.OpenRoot(stockholmDir) if err != nil { - log.Printf("[Stockholm static] Path traversal rejected: %s", sanitizeLog(r.URL.Path)) - http.Error(w, "Forbidden", http.StatusForbidden) - + http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + defer root.Close() - info, err := os.Stat(file) - if err != nil || info.IsDir() { + rel := resolveStaticRel(r.URL.Path) + + info, err := root.Stat(rel) + if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - body, err := os.ReadFile(file) + // Directory → serve index.html inside it. + if info.IsDir() { + rel = rel + "/index.html" + + info, err = root.Stat(rel) + if err != nil || info.IsDir() { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + } + + f, err := root.Open(rel) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer f.Close() + + body, err := io.ReadAll(f) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ct := contentTypeFor(file) + ct := contentTypeFor(rel) if ct == "text/html; charset=UTF-8" && isBootstrapTarget(rel) { body = injectBootstrap(body, state, cfg) @@ -97,36 +120,21 @@ func ServeStatic(w http.ResponseWriter, r *http.Request, stockholmDir string, ba _, _ = w.Write(body) } -func resolveStaticFile(rawPath, stockholmDir string) (filePath, relPath string, err error) { +// resolveStaticRel converts a URL path to a relative path for use with an +// os.Root anchored at the Stockholm static-files directory. It handles the +// root-path → index.html default and strips the leading slash; it does not +// validate for traversal because os.Root enforces containment at the OS level. +func resolveStaticRel(rawPath string) string { if rawPath == "" || rawPath == "/" { - rawPath = "/index.html" + return "index.html" } - // Strip leading slash, resolve relative to stockholmDir - clean := filepath.Clean(strings.TrimPrefix(rawPath, "/")) - resolved := filepath.Join(stockholmDir, clean) - - // Security: reject path traversal - absStockholm, _ := filepath.Abs(stockholmDir) - absResolved, _ := filepath.Abs(resolved) - - if !strings.HasPrefix(absResolved+string(filepath.Separator), absStockholm+string(filepath.Separator)) && - absResolved != absStockholm { - return "", "", fmt.Errorf("path outside stockholm root") + rel := strings.TrimPrefix(rawPath, "/") + if rel == "" { + return "index.html" } - rel := strings.TrimPrefix(absResolved, absStockholm+string(filepath.Separator)) - rel = strings.ReplaceAll(rel, string(filepath.Separator), "/") - - // Directory → try index.html - info, statErr := os.Stat(resolved) - if statErr == nil && info.IsDir() { - resolved = filepath.Join(resolved, "index.html") - rel = strings.TrimPrefix(resolved, absStockholm+string(filepath.Separator)) - rel = strings.ReplaceAll(rel, string(filepath.Separator), "/") - } - - return resolved, rel, nil + return rel } func isBootstrapTarget(relPath string) bool { diff --git a/pkg/service/stockholm/static_test.go b/pkg/service/stockholm/static_test.go index 9d7663d..789d6a5 100644 --- a/pkg/service/stockholm/static_test.go +++ b/pkg/service/stockholm/static_test.go @@ -77,74 +77,65 @@ func TestIsBootstrapTarget(t *testing.T) { } } -// ---- resolveStaticFile ---- - -func TestResolveStaticFile_Normal(t *testing.T) { - dir := t.TempDir() - _ = os.WriteFile(filepath.Join(dir, "app.js"), []byte("js"), 0644) - - file, rel, err := resolveStaticFile("/app.js", dir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !strings.HasSuffix(file, "app.js") { - t.Errorf("expected file path to end with app.js, got %q", file) - } +// ---- resolveStaticRel ---- +func TestResolveStaticRel_Normal(t *testing.T) { + rel := resolveStaticRel("/app.js") if rel != "app.js" { t.Errorf("expected rel = %q, got %q", "app.js", rel) } } -func TestResolveStaticFile_RootMapsToIndexHTML(t *testing.T) { - dir := t.TempDir() - _ = os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0644) - - file, rel, err := resolveStaticFile("/", dir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !strings.HasSuffix(file, "index.html") { - t.Errorf("expected file path to end with index.html, got %q", file) - } - +func TestResolveStaticRel_RootMapsToIndexHTML(t *testing.T) { + rel := resolveStaticRel("/") if rel != "index.html" { t.Errorf("expected rel = %q, got %q", "index.html", rel) } } -func TestResolveStaticFile_DirectoryMapsToIndexHTML(t *testing.T) { - dir := t.TempDir() - subDir := filepath.Join(dir, "setup") - _ = os.MkdirAll(subDir, 0755) - _ = os.WriteFile(filepath.Join(subDir, "index.html"), []byte(""), 0644) - - file, rel, err := resolveStaticFile("/setup", dir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !strings.HasSuffix(file, filepath.Join("setup", "index.html")) { - t.Errorf("expected file path to end with setup/index.html, got %q", file) - } - - if rel != "setup/index.html" { - t.Errorf("expected rel = %q, got %q", "setup/index.html", rel) +func TestResolveStaticRel_EmptyMapsToIndexHTML(t *testing.T) { + rel := resolveStaticRel("") + if rel != "index.html" { + t.Errorf("expected rel = %q, got %q", "index.html", rel) } } -func TestResolveStaticFile_PathTraversalRejected(t *testing.T) { +// ---- ServeStatic path-traversal and directory tests ---- + +func TestServeStatic_DirectoryMapsToIndexHTML(t *testing.T) { dir := t.TempDir() + subDir := filepath.Join(dir, "setup") + _ = os.MkdirAll(subDir, 0755) + _ = os.WriteFile(filepath.Join(subDir, "index.html"), []byte("setup"), 0644) - _, _, err := resolveStaticFile("/../../../etc/passwd", dir) + state := NewNativeState(t.TempDir()) + cfg := &Config{} + backendCfg := &BackendConfig{} - if err == nil { - t.Error("expected error for path traversal, got nil") + req := httptest.NewRequest(http.MethodGet, "/setup", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } +} + +func TestServeStatic_PathTraversalRejected(t *testing.T) { + dir := t.TempDir() + state := NewNativeState(t.TempDir()) + cfg := &Config{} + backendCfg := &BackendConfig{} + + // os.Root rejects any path that would escape the root directory. + req := httptest.NewRequest(http.MethodGet, "/../../../etc/passwd", nil) + rec := httptest.NewRecorder() + + ServeStatic(rec, req, dir, backendCfg, state, cfg) + + if rec.Code == http.StatusOK { + t.Errorf("expected non-200 for path traversal attempt, got 200") } }