Skip to content

Commit 350bb92

Browse files
aksOpsclaude
andcommitted
fix(ui): replace manual SPA dispatch with spaFS wrapper
Sonar's go-security taint engine kept flagging the explicit distFS.Open(rel) call in the SPA-fallback handler even after path.Clean + ".." check. The pattern is structurally safe (embed.FS rejects ".." on its own) but the engine can't model it. Restructure: wrap the dist sub-FS with spaFS — when http.FileServer hits ErrNotExist on an extensionless path, the wrapper transparently serves index.html so the React router can claim the URL. Asset-shaped paths (anything with ".") still 404 normally, so a missing favicon doesn't surprise the browser with an HTML body. Net result: the user-controlled URL never crosses our own Open() call — http.FileServer is the only caller, and Sonar trusts that boundary. Verification * go vet ./... clean * go test ./... — 516 pass / 27 packages * go build ./... clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b1ef70 commit 350bb92

1 file changed

Lines changed: 33 additions & 30 deletions

File tree

internal/ui/ui.go

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package ui
22

33
import (
44
"embed"
5+
"errors"
56
"fmt"
67
"io/fs"
78
"net/http"
8-
"path"
99
"strings"
1010

1111
"github.com/RandomCodeSpace/otelcontext/internal/graph"
@@ -14,6 +14,33 @@ import (
1414
"github.com/RandomCodeSpace/otelcontext/internal/vectordb"
1515
)
1616

17+
// spaFS wraps an fs.FS so http.FileServer transparently serves index.html
18+
// for any extensionless path that doesn't resolve to a real file — the
19+
// usual single-page-app routing where the React router owns client-side
20+
// URLs. Asset-shaped paths (anything with a ".") still 404 normally so a
21+
// missing /favicon.ico doesn't surprise the browser with an HTML body.
22+
//
23+
// Wrapping the FS — rather than calling Open() against r.URL.Path in our
24+
// own handler — keeps the user-controlled name behind the stdlib
25+
// http.FileServer boundary, where path.Clean has already happened.
26+
type spaFS struct{ fs.FS }
27+
28+
func (s spaFS) Open(name string) (fs.File, error) {
29+
f, err := s.FS.Open(name)
30+
if err == nil {
31+
return f, nil
32+
}
33+
if !errors.Is(err, fs.ErrNotExist) {
34+
return nil, err
35+
}
36+
// SPA fallback only for extensionless paths (treated as client-side
37+
// routes); legitimate asset 404s still propagate.
38+
if strings.Contains(name, ".") {
39+
return nil, err
40+
}
41+
return s.FS.Open("index.html")
42+
}
43+
1744
//go:embed static/* dist
1845
var content embed.FS
1946

@@ -47,39 +74,15 @@ func (s *Server) SetMCPConfig(enabled bool, path string) {
4774
func (s *Server) RegisterRoutes(mux *http.ServeMux) error {
4875
mux.Handle("/static/", http.FileServer(http.FS(content)))
4976

50-
// Serve React SPA from dist/ for all non-API paths.
51-
// API routes are registered before this is called, so they take priority.
77+
// Serve React SPA from dist/ for all non-API paths. API routes are
78+
// registered before this is called, so they take priority. spaFS
79+
// converts extensionless 404s into index.html so the React router
80+
// can claim them.
5281
distFS, err := fs.Sub(content, "dist")
5382
if err != nil {
5483
return fmt.Errorf("ui: failed to create dist sub-fs: %w", err)
5584
}
56-
fileServer := http.FileServer(http.FS(distFS))
57-
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
58-
// Sanitize: clean the URL path and reject anything that would escape
59-
// the dist root. embed.FS already rejects ".." segments, but we
60-
// gate at the boundary so static analyzers don't have to taint-track
61-
// the user-supplied URL through the FS call.
62-
clean := path.Clean("/" + r.URL.Path)
63-
if strings.Contains(clean, "..") {
64-
http.NotFound(w, r)
65-
return
66-
}
67-
rel := strings.TrimPrefix(clean, "/")
68-
if rel == "" {
69-
rel = "index.html"
70-
}
71-
// Try the file as-is; if not found, fall back to index.html (SPA routing).
72-
f, openErr := distFS.Open(rel)
73-
if openErr == nil {
74-
_ = f.Close()
75-
fileServer.ServeHTTP(w, r)
76-
return
77-
}
78-
// SPA fallback — let the React router handle the path.
79-
r2 := r.Clone(r.Context())
80-
r2.URL.Path = "/"
81-
fileServer.ServeHTTP(w, r2)
82-
})
85+
mux.Handle("/", http.FileServer(http.FS(spaFS{distFS})))
8386

8487
return nil
8588
}

0 commit comments

Comments
 (0)