fix(security): use os.Root in Stockholm static-file handler

Replace the filepath.Abs + string-prefix path-traversal check followed
by os.Stat / os.ReadFile calls with an os.Root anchored at stockholmDir.
CodeQL (go/path-injection, alerts 143–145) did not recognise the
string-based validation as a sanitiser boundary; os.Root is the same
OS-level barrier used in the sec3 datastore and recorder refactors.

Changes:
- Open os.OpenRoot(stockholmDir) in ServeStatic; all file ops go
  through root.Stat / root.Open instead of os.Stat / os.ReadFile.
- Replace resolveStaticFile (returned absolute + relative paths) with
  resolveStaticRel (URL path → relative path only; no filesystem
  access, no traversal logic — the Root handles containment).
- Directory → index.html fallback moved into ServeStatic via root.Stat.
- Drop path/filepath import from static.go (no longer needed).
- Update tests: resolveStaticFile unit tests become resolveStaticRel
  unit tests; directory and traversal cases become ServeStatic
  integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-25 11:28:26 +02:00
co-authored by Claude Sonnet 4.6
parent 806d1fc22c
commit cd0841bfad
2 changed files with 84 additions and 85 deletions
+42 -34
View File
@@ -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
// 143145). 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 {
+42 -51
View File
@@ -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("<html>"), 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("<html>"), 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("<html><head></head><body>setup</body></html>"), 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")
}
}