mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
806d1fc22c
commit
cd0841bfad
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user