fix(security): close go/path-injection alerts via os.Root containment

The previous filepath.IsLocal-up-front pattern in safeJoin/safeJoin-equivalents
turned out not to satisfy CodeQL's go/path-injection rule — the post-validation
filepath.Join still constructs the joined string from tainted input, so the
analyser conservatively assumes the os.* sink that consumes it is tainted
too. Only one of 34 alerts closed on the previous attempt.

Switch to *os.Root (Go 1.24+, available on the project's 1.26.3 toolchain).
The Go runtime guarantees that operations on a Root cannot escape the
anchored directory regardless of what's in the relative path, and CodeQL has
a built-in model that recognises *os.Root.* methods as path-traversal
sanitisers. Result: every os.* sink in the datastore, marge, recorder,
mirror parity-mismatch writer, and docs handler is now reached only via a
*os.Root, which closes the rule-level alerts cleanly.

Changes per file:

* pkg/service/datastore/datastore.go — Adds a `root *os.Root` to DataStore,
  lazily opened at first use (after MkdirAll-ing baseDir) and closed by a
  new `(*DataStore).Close()`. Adds package-private helpers
  (rootStat / rootReadFile / rootWriteFile / rootMkdirAll / rootRemove /
  rootRemoveAll / rootRename / rootReadDir / rootOpen / rootExists) plus
  three exported wrappers (ReadDirUnderBase, MkdirAllUnderBase,
  WriteFileUnderBase) for the cross-package marge / handlers callers.
  Every os.* call that previously consumed safeJoin output now goes through
  these helpers. The post-join belt-and-suspenders prefix check inside
  safeJoin is preserved as a defence-in-depth fallback.

* pkg/service/marge/marge.go — Replaces the five `os.ReadDir(devicesDir)`
  call sites with `ds.ReadDirUnderBase(...)` so the datastore's root
  enforces containment.

* pkg/service/proxy/recorder.go — Mirrors the datastore pattern with its
  own `root *os.Root` anchored at Recorder.BaseDir, lazily opened. New
  helpers convert the eight existing `os.*` sites that consume sessionID
  / relPath / sanitizedSegments inputs. The earlier safeJoin (filepath.IsLocal
  pre-check) stays in place as the same belt-and-suspenders guard.

* pkg/service/handlers/handlers_docs.go — Opens a *os.Root at "docs" via
  sync.Once and reads file content (and SUMMARY.md sidebar) through it.
  Removes the prior filepath.IsLocal pre-check; the runtime now guarantees
  containment.

* pkg/service/handlers/mirror_middleware.go — Routes the parity-mismatch
  JSON write through `s.ds.WriteFileUnderBase` so the datastore's root
  performs the path-traversal sanitiser.

Behavioural fix: *os.File.ReadDir(-1) returns directory entries in
filesystem order, but os.ReadDir is documented to sort by name and at least
one regression test
(handlers.TestMargeAccountFullExcludesEmptyAmazonSource) depends on the
sorted contract. Both rootReadDir helpers explicitly sort by name to match.

All test suites pass for the touched packages; the unrelated
TestDocsConsistency failure about untracked working-tree docs is
pre-existing. golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-10 15:18:24 +02:00
co-authored by Claude Opus 4.7
parent f951fc92df
commit eab1b7a15a
5 changed files with 573 additions and 78 deletions
+325 -45
View File
@@ -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
}
+32 -11
View File
@@ -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 {
+4 -3
View File
@@ -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 {
+5 -5
View File
@@ -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<presets/>"), 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() {
+207 -14
View File
@@ -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)