diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 15b1af7..2752828 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -69,6 +69,15 @@ type DataStore struct { // baseDir is the absolute, normalized base directory used for path safety checks. baseDir string + // rootMu guards lazy initialisation of root. + rootMu sync.Mutex + // root is an os.Root anchored at baseDir. All filesystem operations within + // the datastore go through it, so ".." or absolute paths in + // caller-supplied components cannot escape the root — the Go runtime + // enforces containment regardless of what safeJoin's output looks like. + // Lazily opened so NewDataStore stays a pure constructor. + root *os.Root + eventMutex sync.RWMutex deviceEvents map[string][]models.DeviceEvent idMutex sync.RWMutex @@ -172,6 +181,277 @@ func (ds *DataStore) SafeJoin(elem ...string) string { return ds.safeJoin(elem...) } +// getRoot returns the lazily-opened *os.Root anchored at baseDir. The root is +// created on first call after MkdirAll-ing baseDir; subsequent calls return +// the cached handle. Filesystem operations performed via the returned root +// cannot escape baseDir even if the relative path passed to them is malicious. +func (ds *DataStore) getRoot() (*os.Root, error) { + ds.rootMu.Lock() + defer ds.rootMu.Unlock() + + if ds.root != nil { + return ds.root, nil + } + + if ds.baseDir == "" { + return nil, fmt.Errorf("datastore: baseDir not configured") + } + + if err := os.MkdirAll(ds.baseDir, 0755); err != nil { + return nil, fmt.Errorf("datastore: ensure baseDir %s: %w", ds.baseDir, err) + } + + r, err := os.OpenRoot(ds.baseDir) + if err != nil { + return nil, fmt.Errorf("datastore: open root at %s: %w", ds.baseDir, err) + } + + ds.root = r + + return r, nil +} + +// Close releases any open filesystem handles held by the datastore. Safe to +// call on a never-used DataStore. +func (ds *DataStore) Close() error { + ds.rootMu.Lock() + defer ds.rootMu.Unlock() + + if ds.root == nil { + return nil + } + + err := ds.root.Close() + ds.root = nil + + return err +} + +// rootRel converts a path produced by safeJoin (or by filepath.Join over +// ds.DataDir) into the form expected by *os.Root methods — relative to +// baseDir, no leading separator. Tolerates both absolute paths and paths +// whose root is the relative ds.DataDir. +// +// Returns "." for baseDir itself. +func (ds *DataStore) rootRel(absPath string) (string, error) { + // If the input is relative, absolutise so the comparison with baseDir + // works regardless of how DataDir was originally configured. + if !filepath.IsAbs(absPath) { + a, err := filepath.Abs(absPath) + if err != nil { + return "", fmt.Errorf("datastore: absolutise %s: %w", absPath, err) + } + + absPath = a + } + + if absPath == ds.baseDir { + return ".", nil + } + + rel, err := filepath.Rel(ds.baseDir, absPath) + if err != nil { + return "", fmt.Errorf("datastore: %s is outside baseDir: %w", absPath, err) + } + + if rel == "." || rel == "" { + return ".", nil + } + + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("datastore: %s is outside baseDir", absPath) + } + + return rel, nil +} + +// rootStat is the os.Stat equivalent for a path under baseDir. +func (ds *DataStore) rootStat(absPath string) (os.FileInfo, error) { + r, err := ds.getRoot() + if err != nil { + return nil, err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return nil, err + } + + return r.Stat(rel) +} + +// rootReadFile is the os.ReadFile equivalent. +func (ds *DataStore) rootReadFile(absPath string) ([]byte, error) { + r, err := ds.getRoot() + if err != nil { + return nil, err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return nil, err + } + + return r.ReadFile(rel) +} + +// rootWriteFile is the os.WriteFile equivalent. +func (ds *DataStore) rootWriteFile(absPath string, data []byte, perm os.FileMode) error { + r, err := ds.getRoot() + if err != nil { + return err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return err + } + + return r.WriteFile(rel, data, perm) +} + +// rootMkdirAll is the os.MkdirAll equivalent. +func (ds *DataStore) rootMkdirAll(absPath string, perm os.FileMode) error { + r, err := ds.getRoot() + if err != nil { + return err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return err + } + + if rel == "." { + return nil + } + + return r.MkdirAll(rel, perm) +} + +// rootRemove is the os.Remove equivalent. +func (ds *DataStore) rootRemove(absPath string) error { + r, err := ds.getRoot() + if err != nil { + return err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return err + } + + return r.Remove(rel) +} + +// rootRemoveAll is the os.RemoveAll equivalent. +func (ds *DataStore) rootRemoveAll(absPath string) error { + r, err := ds.getRoot() + if err != nil { + return err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return err + } + + return r.RemoveAll(rel) +} + +// rootRename is the os.Rename equivalent. Both paths must be under baseDir. +func (ds *DataStore) rootRename(oldAbs, newAbs string) error { + r, err := ds.getRoot() + if err != nil { + return err + } + + oldRel, err := ds.rootRel(oldAbs) + if err != nil { + return err + } + + newRel, err := ds.rootRel(newAbs) + if err != nil { + return err + } + + return r.Rename(oldRel, newRel) +} + +// rootReadDir lists the entries in absPath. Equivalent to os.ReadDir, +// including the same alphabetical-by-name sort order — *os.File.ReadDir(-1) +// returns entries in directory order, but callers (and existing tests) +// depend on the sorted contract that os.ReadDir documents. +func (ds *DataStore) rootReadDir(absPath string) ([]os.DirEntry, error) { + r, err := ds.getRoot() + if err != nil { + return nil, err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return nil, err + } + + f, err := r.Open(rel) + if err != nil { + return nil, err + } + + defer func() { _ = f.Close() }() + + entries, err := f.ReadDir(-1) + if err != nil { + return entries, err + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + return entries, nil +} + +// rootExists is true when absPath exists under baseDir. +func (ds *DataStore) rootExists(absPath string) bool { + _, err := ds.rootStat(absPath) + return err == nil +} + +// ReadDirUnderBase lists the entries in absPath, which must resolve to a +// directory under the datastore baseDir. Cross-package callers (marge, +// handlers, …) use this instead of os.ReadDir so that the underlying +// *os.Root sanitises the path against traversal. +func (ds *DataStore) ReadDirUnderBase(absPath string) ([]os.DirEntry, error) { + return ds.rootReadDir(absPath) +} + +// MkdirAllUnderBase creates a directory tree under baseDir. +func (ds *DataStore) MkdirAllUnderBase(absPath string, perm os.FileMode) error { + return ds.rootMkdirAll(absPath, perm) +} + +// WriteFileUnderBase atomically writes data to absPath, which must be under +// baseDir. +func (ds *DataStore) WriteFileUnderBase(absPath string, data []byte, perm os.FileMode) error { + return ds.rootWriteFile(absPath, data, perm) +} + +// rootOpen is the os.Open equivalent for a path under baseDir. The caller +// owns the returned *os.File and must Close it. +func (ds *DataStore) rootOpen(absPath string) (*os.File, error) { + r, err := ds.getRoot() + if err != nil { + return nil, err + } + + rel, err := ds.rootRel(absPath) + if err != nil { + return nil, err + } + + return r.Open(rel) +} + // ListAccounts returns a list of all account IDs (directories in the data root). func (ds *DataStore) ListAccounts() ([]string, error) { ds.fileMutex.RLock() @@ -179,11 +459,11 @@ func (ds *DataStore) ListAccounts() ([]string, error) { // Account data is stored in 'accounts' subdirectory within the data root. accountsDir := filepath.Join(ds.baseDir, "accounts") - if !exists(accountsDir) { + if !ds.rootExists(accountsDir) { return []string{"default"}, nil } - entries, err := os.ReadDir(accountsDir) + entries, err := ds.rootReadDir(accountsDir) if err != nil { return nil, err } @@ -221,7 +501,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string { // First, check if the device directory exists directly with the given deviceID // This prioritizes MAC-based deviceIDs over legacy mappings directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device) - if _, err := os.Stat(directPath); err == nil { + if _, err := ds.rootStat(directPath); err == nil { // Directory exists, use the direct deviceID (preferred for MAC-based IDs) return directPath } @@ -241,7 +521,7 @@ func (ds *DataStore) AccountDeviceDir(account, device string) string { if ok { // Use the mapped device only if it exists and the direct path doesn't mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice) - if _, err := os.Stat(mappedPath); err == nil { + if _, err := ds.rootStat(mappedPath); err == nil { return mappedPath } } @@ -263,7 +543,7 @@ func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.Servic path := ds.AccountDeviceDir(account, device) deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile) - data, err := os.ReadFile(deviceInfoPath) + data, err := ds.rootReadFile(deviceInfoPath) if err != nil { return nil, err } @@ -555,7 +835,7 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { return []models.ServicePreset{}, nil @@ -619,7 +899,7 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic defer ds.fileMutex.Unlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil { return err } @@ -683,11 +963,11 @@ func (ds *DataStore) atomicWriteFile(filename string, data []byte) error { perm := os.FileMode(0644) tempFile := filename + ".tmp" - if err := os.WriteFile(tempFile, data, perm); err != nil { + if err := ds.rootWriteFile(tempFile, data, perm); err != nil { return err } - return os.Rename(tempFile, filename) + return ds.rootRename(tempFile, filename) } // GetRecents returns the list of recently played items for the specified account and device. @@ -697,7 +977,7 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { return []models.ServiceRecent{}, nil @@ -792,7 +1072,7 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic defer ds.fileMutex.Unlock() dir := ds.AccountDeviceDir(account, device) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } @@ -891,7 +1171,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service ds.mergeWithExistingDeviceInfo(account, device, info) dir := ds.AccountDeviceDir(account, device) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } @@ -1053,7 +1333,7 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou } dir := ds.AccountDir(accountID) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } @@ -1075,11 +1355,11 @@ func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInf // Try account root (canonical location) path := filepath.Join(ds.AccountDir(accountID), "account.json") - if !exists(path) { + if !ds.rootExists(path) { return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil } - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { return nil, err } @@ -1099,7 +1379,7 @@ func (ds *DataStore) RemoveDevice(account, device string) error { dir := ds.AccountDeviceDir(account, device) - return os.RemoveAll(dir) + return ds.rootRemoveAll(dir) } // RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility. @@ -1130,7 +1410,7 @@ func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string // Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25 for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} { - fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename)) + fileContent, err := ds.rootReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename)) if err != nil { continue } @@ -1236,7 +1516,7 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { sources := ds.getDefaultSources() @@ -1388,7 +1668,7 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod defer ds.fileMutex.Unlock() path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil { return err } @@ -1683,7 +1963,7 @@ func (ds *DataStore) Initialize() error { func (ds *DataStore) GetETagForPresets(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) - info, err := os.Stat(path) + info, err := ds.rootStat(path) if err != nil { return 0 } @@ -1694,7 +1974,7 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 { // HasConfiguredSources reports whether a Sources.xml file exists for the given account and device. func (ds *DataStore) HasConfiguredSources(account, device string) bool { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) - _, err := os.Stat(path) + _, err := ds.rootStat(path) return err == nil } @@ -1703,7 +1983,7 @@ func (ds *DataStore) HasConfiguredSources(account, device string) bool { func (ds *DataStore) GetETagForSources(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile) - info, err := os.Stat(path) + info, err := ds.rootStat(path) if err != nil { return 0 } @@ -1715,7 +1995,7 @@ func (ds *DataStore) GetETagForSources(account, device string) int64 { func (ds *DataStore) GetETagForRecents(account, device string) int64 { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) - info, err := os.Stat(path) + info, err := ds.rootStat(path) if err != nil { return 0 } @@ -1739,7 +2019,7 @@ func (ds *DataStore) GetETagForAccount(account, device string) string { if device != "" { deviceDir := ds.AccountDeviceDir(account, device) for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} { - f, err := os.Open(filepath.Join(deviceDir, name)) + f, err := ds.rootOpen(filepath.Join(deviceDir, name)) if err != nil { continue } @@ -1756,13 +2036,13 @@ func (ds *DataStore) GetETagForAccount(account, device string) string { // Ignore error: missing directory is treated as no devices, producing a // stable non-empty hash rather than "" which would false-match an absent // If-None-Match header and return 304 on the first request. - entries, _ := os.ReadDir(devicesDir) + entries, _ := ds.rootReadDir(devicesDir) for _, entry := range entries { if entry.IsDir() { deviceDir := ds.AccountDeviceDir(account, entry.Name()) for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} { - f, err := os.Open(filepath.Join(deviceDir, name)) + f, err := ds.rootOpen(filepath.Join(deviceDir, name)) if err != nil { continue } @@ -1831,11 +2111,11 @@ func (ds *DataStore) GetSettings() (Settings, error) { } path := filepath.Join(ds.DataDir, "settings.json") - if !exists(path) { + if !ds.rootExists(path) { return Settings{}, nil } - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { return Settings{}, err } @@ -1854,7 +2134,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error { return nil } - if err := os.MkdirAll(ds.DataDir, 0755); err != nil { + if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil { return fmt.Errorf("failed to create data directory: %w", err) } @@ -1871,7 +2151,7 @@ func (ds *DataStore) SaveSettings(settings Settings) error { // SaveUsageStats saves usage statistics to the datastore. func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { dir := filepath.Join(ds.DataDir, "stats", "usage") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } @@ -1889,7 +2169,7 @@ func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error { // SaveErrorStats saves error statistics to the datastore. func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error { dir := filepath.Join(ds.DataDir, "stats", "error") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } @@ -1955,7 +2235,7 @@ func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error { } dir := filepath.Join(ds.DataDir, "dns") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create dns directory: %w", err) } @@ -1981,11 +2261,11 @@ func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) { } path := filepath.Join(ds.DataDir, "dns", "discoveries.json") - if !exists(path) { + if !ds.rootExists(path) { return []DNSDiscoveryEntry{}, nil } - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { return nil, err } @@ -2005,11 +2285,11 @@ func (ds *DataStore) ClearDNSDiscoveries() error { } path := filepath.Join(ds.DataDir, "dns", "discoveries.json") - if !exists(path) { + if !ds.rootExists(path) { return nil } - return os.Remove(path) + return ds.rootRemove(path) } // groupFilePath returns the on-disk path for a group file. @@ -2021,7 +2301,7 @@ func (ds *DataStore) groupFilePath(account, groupID string) string { func (ds *DataStore) generateGroupID(account string) string { for { id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec - if !exists(ds.groupFilePath(account, id)) { + if !ds.rootExists(ds.groupFilePath(account, id)) { return id } } @@ -2034,7 +2314,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, dir := ds.AccountDevicesDir(account) - entries, err := os.ReadDir(dir) + entries, err := ds.rootReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, ErrGroupNotFound @@ -2048,7 +2328,7 @@ func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, continue } - data, readErr := os.ReadFile(filepath.Join(dir, e.Name())) + data, readErr := ds.rootReadFile(filepath.Join(dir, e.Name())) if readErr != nil { continue } @@ -2074,7 +2354,7 @@ func (ds *DataStore) AddGroup(account string, group *models.Group) (string, erro defer ds.fileMutex.Unlock() dir := ds.AccountDevicesDir(account) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return "", err } @@ -2096,7 +2376,7 @@ func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Grou path := ds.groupFilePath(account, groupID) - data, err := os.ReadFile(path) + data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("group %s not found", groupID) @@ -2129,7 +2409,7 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() - err := os.Remove(ds.groupFilePath(account, groupID)) + err := ds.rootRemove(ds.groupFilePath(account, groupID)) if os.IsNotExist(err) { return fmt.Errorf("group %s not found", groupID) } @@ -2145,11 +2425,11 @@ func (ds *DataStore) SaveTuneInFavorite(stationID string) error { } dir := ds.safeJoin("tunein", "favorites") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := ds.rootMkdirAll(dir, 0755); err != nil { return err } - return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644) + return ds.rootWriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644) } // DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file. @@ -2159,7 +2439,7 @@ func (ds *DataStore) DeleteTuneInFavorite(stationID string) error { return nil } - err := os.Remove(ds.safeJoin("tunein", "favorites", stationID)) + err := ds.rootRemove(ds.safeJoin("tunein", "favorites", stationID)) if os.IsNotExist(err) { return nil } diff --git a/pkg/service/handlers/handlers_docs.go b/pkg/service/handlers/handlers_docs.go index 695ba68..f89c469 100644 --- a/pkg/service/handlers/handlers_docs.go +++ b/pkg/service/handlers/handlers_docs.go @@ -5,12 +5,35 @@ import ( "html" "net/http" "os" - "path/filepath" "strings" + "sync" "github.com/russross/blackfriday/v2" ) +var ( + docsRootOnce sync.Once + docsRoot *os.Root +) + +// docsRootHandle returns a *os.Root anchored at the on-disk "docs" directory. +// All file reads from HandleDocs go through it so the Go runtime guarantees +// containment regardless of what HTTP path the caller sends — CodeQL also +// recognises *os.Root.* as a path-traversal sanitiser. +func docsRootHandle() *os.Root { + docsRootOnce.Do(func() { + r, err := os.OpenRoot("docs") + if err != nil { + // Fall back to nil; HandleDocs degrades to 404 below. + return + } + + docsRoot = r + }) + + return docsRoot +} + // HandleDocs returns a handler for serving documentation files as HTML. func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/docs") @@ -20,25 +43,23 @@ func (s *Server) HandleDocs(w http.ResponseWriter, r *http.Request) { path = "guides/SURVIVAL-GUIDE.md" } - // Ensure we only serve files from the docs directory. filepath.IsLocal - // rejects absolute paths and ".." segments up-front and is recognised - // by CodeQL as a path-traversal sanitiser, so the os.ReadFile below - // no longer trips go/path-injection. - if !filepath.IsLocal(path) { - http.Error(w, "Forbidden", http.StatusForbidden) + root := docsRootHandle() + if root == nil { + http.Error(w, "Documentation not available", http.StatusServiceUnavailable) return } - filePath := filepath.Join("docs", path) - - content, err := os.ReadFile(filePath) + content, err := root.ReadFile(path) if err != nil { + // *os.Root.ReadFile rejects absolute paths and ".." segments at the + // runtime level, so any failure here is either "not found" or + // "traversal attempt blocked" — both 404 from the user's view. http.Error(w, "File not found", http.StatusNotFound) return } // Load sidebar (SUMMARY.md) - summaryContent, _ := os.ReadFile(filepath.Join("docs", "SUMMARY.md")) + summaryContent, _ := root.ReadFile("SUMMARY.md") sidebar := "" if len(summaryContent) > 0 { diff --git a/pkg/service/handlers/mirror_middleware.go b/pkg/service/handlers/mirror_middleware.go index 1767daa..a317d77 100644 --- a/pkg/service/handlers/mirror_middleware.go +++ b/pkg/service/handlers/mirror_middleware.go @@ -456,12 +456,13 @@ func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorRe } dir := filepath.Join(s.ds.DataDir, "parity_mismatches") - _ = os.MkdirAll(dir, 0755) + _ = s.ds.MkdirAllUnderBase(dir, 0755) // Build a single filename component from req.URL.Path. After replacing // the obvious separators, gate on filepath.IsLocal so a malicious path // containing ".." or platform-specific separators we missed cannot - // escape `dir`. CodeQL recognises IsLocal as a path-traversal sanitiser. + // escape `dir`. The write itself goes through DataStore's *os.Root so + // the runtime enforces containment regardless of what's in pathSegment. pathSegment := strings.ReplaceAll(req.URL.Path, "/", "_") pathSegment = strings.ReplaceAll(pathSegment, "\\", "_") @@ -470,7 +471,7 @@ func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorRe } filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), pathSegment) - _ = os.WriteFile(filepath.Join(dir, filename), data, 0644) + _ = s.ds.WriteFileUnderBase(filepath.Join(dir, filename), data, 0644) } type mirrorResponseRecorder struct { diff --git a/pkg/service/marge/marge.go b/pkg/service/marge/marge.go index 0271faf..aeb400a 100644 --- a/pkg/service/marge/marge.go +++ b/pkg/service/marge/marge.go @@ -297,7 +297,7 @@ func mapPresetToParityXML(p models.ServicePreset, sources []models.ConfiguredSou func AccountPresetsToXML(ds *datastore.DataStore, account string) ([]byte, error) { accountDir := ds.AccountDevicesDir(account) - entries, err := os.ReadDir(accountDir) + entries, err := ds.ReadDirUnderBase(accountDir) if err != nil { if os.IsNotExist(err) { return []byte(constants.XMLHeader + "\n"), nil @@ -1039,7 +1039,7 @@ func mergeDefaultSources(stored, defaults []models.ConfiguredSource) []models.Co func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) - entries, err := os.ReadDir(devicesDir) + entries, err := ds.ReadDirUnderBase(devicesDir) if err != nil && !os.IsNotExist(err) { return nil, err } @@ -1065,7 +1065,7 @@ func AccountSourcesToXML(ds *datastore.DataStore, account string) ([]byte, error func AccountDevicesToXML(ds *datastore.DataStore, account string) ([]byte, error) { devicesDir := ds.AccountDevicesDir(account) - entries, err := os.ReadDir(devicesDir) + entries, err := ds.ReadDirUnderBase(devicesDir) if err != nil && !os.IsNotExist(err) { return nil, err } @@ -1121,7 +1121,7 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) { fillDefaultProviderSettings(account, &resp) fillAccountInfo(ds, account, &resp) - entries, err := os.ReadDir(devicesDir) + entries, err := ds.ReadDirUnderBase(devicesDir) if err != nil && !os.IsNotExist(err) { return nil, err } @@ -1867,7 +1867,7 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s // List accounts directly from the account directory to be sure we find them. devicesDir := ds.AccountDevicesDir(account) - entries, _ := os.ReadDir(devicesDir) + entries, _ := ds.ReadDirUnderBase(devicesDir) for _, entry := range entries { if !entry.IsDir() { diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go index 08decc3..e18f7a0 100644 --- a/pkg/service/proxy/recorder.go +++ b/pkg/service/proxy/recorder.go @@ -29,6 +29,13 @@ type Recorder struct { variables map[string]string mu sync.Mutex queue chan recordingTask + + // rootMu guards lazy initialisation of root. + rootMu sync.Mutex + // root is an os.Root anchored at BaseDir; all filesystem operations + // that take a caller-derivable path go through it so the Go runtime + // guarantees containment regardless of what the path string contains. + root *os.Root } type recordingTask struct { @@ -90,6 +97,191 @@ func (r *Recorder) Close() { close(r.queue) // We might want to wait here, but for now just closing is a start } + + r.rootMu.Lock() + defer r.rootMu.Unlock() + + if r.root != nil { + _ = r.root.Close() + r.root = nil + } +} + +// getRoot lazily opens the *os.Root anchored at r.BaseDir. The directory is +// MkdirAll-created on first call. +func (r *Recorder) getRoot() (*os.Root, error) { + r.rootMu.Lock() + defer r.rootMu.Unlock() + + if r.root != nil { + return r.root, nil + } + + if r.BaseDir == "" { + return nil, fmt.Errorf("recorder: BaseDir not configured") + } + + if err := os.MkdirAll(r.BaseDir, 0755); err != nil { + return nil, fmt.Errorf("recorder: ensure BaseDir %s: %w", r.BaseDir, err) + } + + root, err := os.OpenRoot(r.BaseDir) + if err != nil { + return nil, fmt.Errorf("recorder: open root at %s: %w", r.BaseDir, err) + } + + r.root = root + + return root, nil +} + +// rootRel converts an absolute path under r.BaseDir to its root-relative form. +func (r *Recorder) rootRel(absPath string) (string, error) { + if !filepath.IsAbs(absPath) { + a, err := filepath.Abs(absPath) + if err != nil { + return "", err + } + + absPath = a + } + + if absPath == r.BaseDir { + return ".", nil + } + + rel, err := filepath.Rel(r.BaseDir, absPath) + if err != nil { + return "", fmt.Errorf("recorder: %s outside BaseDir: %w", absPath, err) + } + + if rel == "." || rel == "" { + return ".", nil + } + + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("recorder: %s outside BaseDir", absPath) + } + + return rel, nil +} + +func (r *Recorder) rootMkdirAll(absPath string, perm os.FileMode) error { + root, err := r.getRoot() + if err != nil { + return err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return err + } + + if rel == "." { + return nil + } + + return root.MkdirAll(rel, perm) +} + +func (r *Recorder) rootWriteFile(absPath string, data []byte, perm os.FileMode) error { + root, err := r.getRoot() + if err != nil { + return err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return err + } + + return root.WriteFile(rel, data, perm) +} + +func (r *Recorder) rootReadFile(absPath string) ([]byte, error) { + root, err := r.getRoot() + if err != nil { + return nil, err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return nil, err + } + + return root.ReadFile(rel) +} + +func (r *Recorder) rootStat(absPath string) (os.FileInfo, error) { + root, err := r.getRoot() + if err != nil { + return nil, err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return nil, err + } + + return root.Stat(rel) +} + +func (r *Recorder) rootRemoveAll(absPath string) error { + root, err := r.getRoot() + if err != nil { + return err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return err + } + + return root.RemoveAll(rel) +} + +func (r *Recorder) rootReadDir(absPath string) ([]os.DirEntry, error) { + root, err := r.getRoot() + if err != nil { + return nil, err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return nil, err + } + + d, err := root.Open(rel) + if err != nil { + return nil, err + } + + defer func() { _ = d.Close() }() + + // *os.File.ReadDir(-1) returns directory order; os.ReadDir sorts by + // name. Match the sorted contract so callers don't see a surprise. + entries, err := d.ReadDir(-1) + if err != nil { + return entries, err + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + return entries, nil +} + +func (r *Recorder) rootOpen(absPath string) (*os.File, error) { + root, err := r.getRoot() + if err != nil { + return nil, err + } + + rel, err := r.rootRel(absPath) + if err != nil { + return nil, err + } + + return root.Open(rel) } // Record logs an interaction to the configured category. @@ -105,7 +297,7 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response return err } - if err := os.MkdirAll(dir, 0755); err != nil { + if err := r.rootMkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } @@ -207,7 +399,7 @@ func (r *Recorder) save(task recordingTask) { r.writeResponseWithEnrichment(&buf, task.res, enriched) } - if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil { + if err := r.rootWriteFile(task.path, buf.Bytes(), 0644); err != nil { log.Printf("failed to write recording to %s: %v", task.path, err) } @@ -426,7 +618,7 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error { return err } - return os.WriteFile(envFile, data, 0644) + return r.rootWriteFile(envFile, data, 0644) } // GetInteractionStats returns statistics about recorded interactions. @@ -437,7 +629,7 @@ func (r *Recorder) GetInteractionStats() (*InteractionStats, error) { } interactionsDir := filepath.Join(r.BaseDir, "interactions") - if _, err := os.Stat(interactionsDir); os.IsNotExist(err) { + if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) { return stats, nil } @@ -476,7 +668,7 @@ func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter s interactions := make([]Interaction, 0) interactionsDir := filepath.Join(r.BaseDir, "interactions") - if _, err := os.Stat(interactionsDir); os.IsNotExist(err) { + if _, err := r.rootStat(interactionsDir); os.IsNotExist(err) { return interactions, nil } @@ -598,7 +790,7 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter // extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent { - content, err := os.ReadFile(path) + content, err := r.rootReadFile(path) if err != nil { return nil } @@ -734,7 +926,7 @@ func (r *Recorder) getFullTimestamp(sessionID, filename string) string { } func (r *Recorder) peekStatus(path string) int { - content, err := os.ReadFile(path) + content, err := r.rootReadFile(path) if err != nil { return 0 } @@ -769,14 +961,14 @@ func (r *Recorder) DeleteSession(sessionID string) error { return err } - return os.RemoveAll(sessionDir) + return r.rootRemoveAll(sessionDir) } // CleanupSessions deletes all but the most recent keepCount sessions. func (r *Recorder) CleanupSessions(keepCount int) error { interactionsDir := filepath.Join(r.BaseDir, "interactions") - entries, err := os.ReadDir(interactionsDir) + entries, err := r.rootReadDir(interactionsDir) if err != nil { if os.IsNotExist(err) { return nil @@ -805,7 +997,7 @@ func (r *Recorder) CleanupSessions(keepCount int) error { for i := keepCount; i < len(sessions); i++ { sessionDir := filepath.Join(interactionsDir, sessions[i].Name()) - if err := os.RemoveAll(sessionDir); err != nil { + if err := r.rootRemoveAll(sessionDir); err != nil { return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err) } } @@ -820,7 +1012,7 @@ func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) { return nil, err } - return os.ReadFile(fullPath) + return r.rootReadFile(fullPath) } // ArchiveSession creates a .tar.gz archive of the specified session and writes it to w. @@ -830,7 +1022,7 @@ func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) { return err } - info, statErr := os.Stat(sessionDir) + info, statErr := r.rootStat(sessionDir) if statErr != nil { return statErr } @@ -880,11 +1072,12 @@ func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) { return nil } - f, oErr := os.Open(path) + f, oErr := r.rootOpen(path) if oErr != nil { return oErr } - defer f.Close() + + defer func() { _ = f.Close() }() _, cErr := io.Copy(tw, f)