Files
David Gageot c4dc536ead fix(cache): address TOCTOU race in maybeReload and improve documentation
- Fix TOCTOU race condition in Cache.maybeReload by re-stating the file
  under the write lock. The previous implementation stat'd the file before
  acquiring the lock, then used the stale FileInfo after the lock, which
  could lead to storing an outdated mtime that doesn't match the loaded
  content. This could cause subsequent Lookups to miss updates in
  high-concurrency scenarios.

- Clarify Windows lock semantics: the mandatory LockFileEx lock is on the
  separate .lock file (not the data file), so both Unix and Windows achieve
  the same advisory-lock effect for the cache operations.

- Document mtimeOf zero-value semantics: the zero time.Time{} is a sentinel
  for 'file not found' and is safe to compare with time.Time.Equal.

- Add comment explaining Store's update-after-persist ordering: the in-memory
  map is updated after the disk write so that persistence failures still keep
  the entry in memory for the current process.

- Document resolveCachePath's lexical-only path traversal check: symlinks are
  not resolved, which is acceptable for the threat model (cache paths come
  from agent configs, not untrusted user input).

All tests pass with -race. No functional changes to the cache behavior.

Assisted-By: docker-agent
2026-04-28 09:23:27 +02:00

62 lines
2.1 KiB
Go

package teamloader
import (
"fmt"
"path/filepath"
"strings"
"github.com/docker/docker-agent/pkg/cache"
"github.com/docker/docker-agent/pkg/config/latest"
)
// buildAgentCache turns a per-agent CacheConfig into a [cache.Cache]
// instance, resolving any relative path against parentDir and rejecting
// paths that try to escape it.
//
// The caller is expected to gate this on cfg.Enabled; passing a disabled
// config still returns (nil, nil) so the caller can stay symmetric.
func buildAgentCache(agentName string, cfg *latest.CacheConfig, parentDir string) (*cache.Cache, error) {
if cfg == nil || !cfg.Enabled {
return nil, nil
}
path, err := resolveCachePath(cfg.Path, parentDir)
if err != nil {
return nil, fmt.Errorf("agent %q: %w", agentName, err)
}
c, err := cache.New(cache.Config{
Enabled: true,
CaseSensitive: cfg.CaseSensitive,
TrimSpaces: cfg.TrimSpaces,
Path: path,
})
if err != nil {
return nil, fmt.Errorf("agent %q: initializing response cache: %w", agentName, err)
}
return c, nil
}
// resolveCachePath returns path unchanged when it is empty (in-memory
// cache) or absolute; otherwise it joins it with parentDir, cleans the
// result, and refuses any path that escapes parentDir via "..".
//
// Note: This is a lexical check only and does not resolve symlinks. An
// attacker with write access to parentDir could create a symlink that
// points outside the directory tree. This is acceptable because:
// 1. The cache path comes from the agent config, not user input
// 2. An attacker with write access to the config directory already has
// significant privileges
// 3. The cache only stores agent responses, not sensitive data
func resolveCachePath(path, parentDir string) (string, error) {
if path == "" || filepath.IsAbs(path) {
return path, nil
}
resolved := filepath.Clean(filepath.Join(parentDir, path))
cleanParent := filepath.Clean(parentDir) + string(filepath.Separator)
if !strings.HasPrefix(resolved+string(filepath.Separator), cleanParent) {
return "", fmt.Errorf("cache path %q escapes parent directory", path)
}
return resolved, nil
}