Files
Tobias GesellchenandClaude Opus 4.7 4507d82b4c fix(security): address CodeQL findings on Stockholm + SiriusXM stubs
Two of the eight CodeQL alerts on PR #313 had clean, low-cost fixes:

  - go/clear-text-logging (#141, #142): the SiriusXM stub logged the
    raw Authorization header value at INFO. The header carries a
    long-lived bearer token (margeAuthToken) — capturing service logs
    would yield replayable credentials. Switch to logging only the
    boolean presence (`authPresent=%t`).

  - go/bad-redirect-check (#138): the Stockholm handler's bare-path
    redirect uses cfg.BasePath verbatim. basePath is operator-provided
    (CLI flag / STOCKHOLM_BASE_PATH env), not request input — but a
    value like "//evil.com" would still produce a scheme-relative
    redirect to an external host. Reject any leading-double-slash or
    embedded backslash at construction time so the redirect target
    can only ever be an absolute local path.

The remaining CodeQL alerts are out of scope here:

  - go/request-forgery on proxy.go (#139, #140): the /api/http-proxy
    endpoint takes a user-provided url= parameter and fetches it by
    design — that's the whole point of the proxy. Mitigations
    already in place: isProxyLoop rejects self-references; the proxy
    is only reachable under a LAN trust model.

  - go/path-injection on static.go (#143, #144, #145): the
    path-traversal guard in resolveStaticFile (string-prefix check
    on absolute paths) is sound, but CodeQL doesn't trace it across
    the function boundary. A clearer refactor to filepath.Rel might
    silence the alert; deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00

154 lines
5.4 KiB
Go

package stockholm
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
// Handler is the main entry point for the Stockholm frontend backend.
type Handler struct {
cfg *Config
backendCfg *BackendConfig
state *NativeState
bridge *Bridge
stockholmDir string
}
// New initialises and returns a Stockholm Handler.
//
// stockholmDir is the path to the extracted Stockholm frontend (contains index.html).
// workspaceRoot is used to locate backend/state and backend/config directories.
// backendURL is the external URL of this service (used for config URL rewriting).
// basePath is the URL prefix at which the Stockholm UI is mounted (e.g. "/stockholm");
// pass "" to serve at the root.
func New(stockholmDir, workspaceRoot, backendURL, basePath string) (*Handler, error) {
if _, err := os.Stat(stockholmDir); err != nil {
return nil, fmt.Errorf("stockholm dir not found at %q: %w", stockholmDir, err)
}
cfg, err := LoadConfig(stockholmDir)
if err != nil {
return nil, fmt.Errorf("load stockholm config: %w", err)
}
if backendURL != "" {
margeURL := firstNonEmpty(os.Getenv("MARGE_URL"), backendURL)
authServiceURL := firstNonEmpty(os.Getenv("AUTH_SERVICE_URL"), backendURL)
if err := RewriteConfigURLs(stockholmDir, backendURL, margeURL, authServiceURL); err != nil {
log.Printf("[Stockholm] Warning: failed to rewrite config URLs: %v", err)
}
}
backendCfg := LoadBackendConfig(workspaceRoot)
stateDir := filepath.Join(workspaceRoot, "backend", "state")
if err := os.MkdirAll(stateDir, 0755); err != nil {
return nil, fmt.Errorf("create state dir: %w", err)
}
state := NewNativeState(stateDir)
if err := state.Load(); err != nil {
log.Printf("[Stockholm] Warning: failed to load native state: %v", err)
}
// Normalise basePath: no trailing slash, must start with "/" or be empty.
if basePath != "" && !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
basePath = strings.TrimRight(basePath, "/")
// Defence in depth: basePath is operator-provided (CLI flag /
// STOCKHOLM_BASE_PATH env var), not request input — but if it
// were ever set to "//evil.com" (typo or hostile env injection)
// the bare-path redirect below would go scheme-relative to
// evil.com. Reject any leading-double-slash and any backslash
// so the redirect target can only ever be an absolute local
// path. CodeQL go/bad-redirect-check raised the original
// concern.
if strings.HasPrefix(basePath, "//") || strings.HasPrefix(basePath, "/\\") || strings.ContainsAny(basePath, "\\") {
return nil, fmt.Errorf("invalid stockholm base path %q: must be an absolute path starting with a single '/'", basePath)
}
cfg.BasePath = basePath
state.SeedFromEnv(cfg)
bridge := newBridge(cfg, state)
return &Handler{
cfg: cfg,
backendCfg: backendCfg,
state: state,
bridge: bridge,
stockholmDir: stockholmDir,
}, nil
}
// Mount registers all Stockholm routes on the given chi router.
// API routes (/api/native/*, /api/http-proxy) are registered under cfg.BasePath
// because the patched JS uses window.__stockholmBase as a prefix for all API calls.
// Static content is served under cfg.BasePath (e.g. /stockholm) if set,
// otherwise at the root.
func (h *Handler) Mount(r chi.Router) {
apiBase := h.cfg.BasePath
r.Post(apiBase+"/api/native/appSend", h.bridge.HandleAppSend)
r.Get(apiBase+"/api/native/runQueue", h.bridge.HandleRunQueue)
r.HandleFunc(apiBase+"/api/http-proxy", h.handleProxy)
if h.cfg.BasePath != "" {
// Redirect bare /stockholm to /stockholm/ so the browser sets the correct
// base URL for relative asset references.
r.Get(h.cfg.BasePath, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, h.cfg.BasePath+"/", http.StatusMovedPermanently)
})
// Strip the base path prefix before passing to handleStatic so that
// resolveStaticFile sees paths like "/" or "/index.html", not "/stockholm/".
// r.Route does NOT strip r.URL.Path, so we must use http.StripPrefix explicitly.
stripped := http.StripPrefix(h.cfg.BasePath, http.HandlerFunc(h.handleStatic))
r.Get(h.cfg.BasePath+"/", stripped.ServeHTTP)
r.Head(h.cfg.BasePath+"/", stripped.ServeHTTP)
r.Get(h.cfg.BasePath+"/*", stripped.ServeHTTP)
r.Head(h.cfg.BasePath+"/*", stripped.ServeHTTP)
} else {
// Serve static content at the root (catch-all at the end).
r.Get("/*", h.handleStatic)
r.Head("/*", h.handleStatic)
r.Get("/", h.handleStatic)
}
}
func (h *Handler) handleProxy(w http.ResponseWriter, r *http.Request) {
HandleProxy(w, r, h.cfg, h.state)
}
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
}
// HandleStatic is the exported form of handleStatic, needed when mounting the
// Stockholm static handler inside sub-routers (e.g. to resolve the /setup/ path
// collision between the management API and the Stockholm setup wizard pages).
func (h *Handler) HandleStatic(w http.ResponseWriter, r *http.Request) {
ServeStatic(w, r, h.stockholmDir, h.backendCfg, h.state, h.cfg)
}
// Config returns the loaded Stockholm config (for integration with the proxy handler
// that may need to inject BMX/marge headers).
func (h *Handler) Config() *Config {
return h.cfg
}
// State returns the NativeState (for integration with handlers that need to read
// auth tokens or account IDs).
func (h *Handler) State() *NativeState {
return h.state
}