From 6881997b912afc83729d1ca18bad951ad710cfa8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sun, 22 Mar 2026 17:25:57 +0000 Subject: [PATCH 1/4] test(cache): verify docker sandbox Co-Authored-By: Virgil From cabb7b1d0e47e30aa0ad0f053be01ab42b331ddb Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 07:35:31 +0000 Subject: [PATCH 2/4] fix(cache): resolve issue 4 findings --- cache.go | 102 ++++++++++++++++++++++++++++++++++++++----- cache_test.go | 2 + docs/architecture.md | 6 +-- go.mod | 1 + go.sum | 2 + 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/cache.go b/cache.go index f532b47..ec98738 100644 --- a/cache.go +++ b/cache.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + // Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium. package cache @@ -5,18 +7,25 @@ import ( "encoding/json" "errors" "os" - "path/filepath" - "strings" "time" + "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // DefaultTTL is the default cache expiry time. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", cache.DefaultTTL) const DefaultTTL = 1 * time.Hour // Cache represents a file-based cache. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", time.Minute) type Cache struct { medium coreio.Medium baseDir string @@ -24,6 +33,10 @@ type Cache struct { } // Entry represents a cached item with metadata. +// +// Usage example: +// +// entry := cache.Entry{CachedAt: time.Now(), ExpiresAt: time.Now().Add(time.Minute)} type Entry struct { Data json.RawMessage `json:"data"` CachedAt time.Time `json:"cached_at"` @@ -33,6 +46,10 @@ type Entry struct { // New creates a new cache instance. // If medium is nil, uses coreio.Local (filesystem). // If baseDir is empty, uses .core/cache in current directory. +// +// Usage example: +// +// c, err := cache.New(coreio.NewMockMedium(), "/tmp/cache", 30*time.Minute) func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error) { if medium == nil { medium = coreio.Local @@ -44,7 +61,7 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error if err != nil { return nil, coreerr.E("cache.New", "failed to get working directory", err) } - baseDir = filepath.Join(cwd, ".core", "cache") + baseDir = core.Path(cwd, ".core", "cache") } if ttl == 0 { @@ -65,20 +82,24 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error // Path returns the full path for a cache key. // Returns an error if the key attempts path traversal. +// +// Usage example: +// +// path, err := c.Path("github/acme/repos") func (c *Cache) Path(key string) (string, error) { - path := filepath.Join(c.baseDir, key+".json") + path := joinPath(c.baseDir, key+".json") // Ensure the resulting path is still within baseDir to prevent traversal attacks - absBase, err := filepath.Abs(c.baseDir) + absBase, err := pathAbs(c.baseDir) if err != nil { return "", coreerr.E("cache.Path", "failed to get absolute path for baseDir", err) } - absPath, err := filepath.Abs(path) + absPath, err := pathAbs(path) if err != nil { return "", coreerr.E("cache.Path", "failed to get absolute path for key", err) } - if !strings.HasPrefix(absPath, absBase+string(filepath.Separator)) && absPath != absBase { + if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } @@ -86,6 +107,10 @@ func (c *Cache) Path(key string) (string, error) { } // Get retrieves a cached item if it exists and hasn't expired. +// +// Usage example: +// +// found, err := c.Get("session/user-42", &dest) func (c *Cache) Get(key string, dest any) (bool, error) { path, err := c.Path(key) if err != nil { @@ -120,6 +145,10 @@ func (c *Cache) Get(key string, dest any) (bool, error) { } // Set stores an item in the cache. +// +// Usage example: +// +// err := c.Set("session/user-42", map[string]string{"name": "Ada"}) func (c *Cache) Set(key string, data any) error { path, err := c.Path(key) if err != nil { @@ -127,7 +156,7 @@ func (c *Cache) Set(key string, data any) error { } // Ensure parent directory exists - if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil { + if err := c.medium.EnsureDir(core.PathDir(path)); err != nil { return coreerr.E("cache.Set", "failed to create directory", err) } @@ -155,6 +184,10 @@ func (c *Cache) Set(key string, data any) error { } // Delete removes an item from the cache. +// +// Usage example: +// +// err := c.Delete("session/user-42") func (c *Cache) Delete(key string) error { path, err := c.Path(key) if err != nil { @@ -172,6 +205,10 @@ func (c *Cache) Delete(key string) error { } // Clear removes all cached items. +// +// Usage example: +// +// err := c.Clear() func (c *Cache) Clear() error { if err := c.medium.DeleteAll(c.baseDir); err != nil { return coreerr.E("cache.Clear", "failed to clear cache", err) @@ -180,6 +217,10 @@ func (c *Cache) Clear() error { } // Age returns how old a cached item is, or -1 if not cached. +// +// Usage example: +// +// age := c.Age("session/user-42") func (c *Cache) Age(key string) time.Duration { path, err := c.Path(key) if err != nil { @@ -202,11 +243,52 @@ func (c *Cache) Age(key string) time.Duration { // GitHub-specific cache keys // GitHubReposKey returns the cache key for an org's repo list. +// +// Usage example: +// +// key := cache.GitHubReposKey("acme") func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") + return core.JoinPath("github", org, "repos") } // GitHubRepoKey returns the cache key for a specific repo's metadata. +// +// Usage example: +// +// key := cache.GitHubRepoKey("acme", "widgets") func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") + return core.JoinPath("github", org, repo, "meta") +} + +func joinPath(segments ...string) string { + return normalizePath(core.JoinPath(segments...)) +} + +func pathAbs(path string) (string, error) { + path = normalizePath(path) + if core.PathIsAbs(path) { + return core.CleanPath(path, pathSeparator()), nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + return core.Path(cwd, path), nil +} + +func normalizePath(path string) string { + if pathSeparator() == "/" { + return path + } + return core.Replace(path, "/", pathSeparator()) +} + +func pathSeparator() string { + sep := core.Env("DS") + if sep == "" { + return "/" + } + return sep } diff --git a/cache_test.go b/cache_test.go index c33996e..15d122b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: EUPL-1.2 + package cache_test import ( diff --git a/docs/architecture.md b/docs/architecture.md index b2199b0..0f9f619 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,11 +162,11 @@ the GitHub key helpers work: ```go func GitHubReposKey(org string) string { - return filepath.Join("github", org, "repos") + return core.JoinPath("github", org, "repos") } func GitHubRepoKey(org, repo string) string { - return filepath.Join("github", org, repo, "meta") + return core.JoinPath("github", org, repo, "meta") } ``` @@ -178,7 +178,7 @@ the full path, it resolves both the base directory and the result to absolute paths, then checks that the result is still a prefix of the base: ```go -if !strings.HasPrefix(absPath, absBase) { +if !core.HasPrefix(absPath, absBase+pathSeparator()) && absPath != absBase { return "", coreerr.E("cache.Path", "invalid cache key: path traversal attempt", nil) } ``` diff --git a/go.mod b/go.mod index c7424fc..495911f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module dappco.re/go/core/cache go 1.26.0 require ( + dappco.re/go/core v0.6.0 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 ) diff --git a/go.sum b/go.sum index 76d01ec..3a59267 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk= +dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= From f9e1725c7d9f4c39d3b8dcf6ff8afbbcf2018eaf Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 12:59:56 +0000 Subject: [PATCH 3/4] docs(cache): map security attack vectors Co-Authored-By: Virgil --- docs/security-attack-vector-mapping.md | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/security-attack-vector-mapping.md diff --git a/docs/security-attack-vector-mapping.md b/docs/security-attack-vector-mapping.md new file mode 100644 index 0000000..852902e --- /dev/null +++ b/docs/security-attack-vector-mapping.md @@ -0,0 +1,29 @@ +# Security Attack Vector Mapping + +Scope: `dappco.re/go/core/cache` public API and backend read paths in `cache.go`. This package exposes a library surface only; it has no HTTP handlers or CLI argument parsing in-repo. + +| Function | File:line | Input source | Flows into | Current validation | Potential attack vector | +| --- | --- | --- | --- | --- | --- | +| `New` | `cache.go:36` | `medium` constructor argument from the consumer | Stored on `Cache.medium`; used immediately by `medium.EnsureDir(baseDir)` and later by all `Read`/`Write`/`Delete` calls | Only `nil` is replaced with `coreio.Local`; no capability or sandbox check in this package | Backend policy bypass. A caller can supply an unsafe medium, and `nil` falls back to unsandboxed local filesystem access (`io.Local` is rooted at `/`), increasing the impact of later key or `baseDir` misuse. | +| `New` | `cache.go:36` | `baseDir` constructor argument from the consumer | `medium.EnsureDir(baseDir)`; persisted on `Cache.baseDir`; later consumed by `Path` and `Clear` | Empty string defaults to `filepath.Join(cwd, ".core", "cache")`; otherwise no normalization, allowlist, or sandbox enforcement in this package | Arbitrary path selection. If `baseDir` is user-controlled or misconfigured, cache reads/writes/deletes can be redirected to attacker-chosen locations. With default `io.Local`, `Clear` can recurse-delete arbitrary directories other than `/` and `$HOME`, and `Set` can write cache JSON into unexpected filesystem locations. | +| `New` | `cache.go:41` | Process working directory from `os.Getwd()` when `baseDir == ""` | `filepath.Join(cwd, ".core", "cache")` | No validation beyond `Getwd` succeeding | Environment-controlled cache placement. Running the consumer from an attacker-influenced working directory redirects cache storage into that tree, which can expose data to other users/processes or alter which cache is later cleared. | +| `New` | `cache.go:36` | `ttl` constructor argument from the consumer | Stored on `Cache.ttl`; later used by `time.Now().Add(c.ttl)` in `Set` | Only `0` is replaced with `DefaultTTL`; negative or very large durations are accepted | Availability and data-staleness abuse. Negative TTL values force immediate misses; very large TTLs preserve stale or poisoned cache content longer than intended. | +| `Path` | `cache.go:68` | `key` method argument from the caller | `filepath.Join(c.baseDir, key+".json")`; returned path is later consumed by medium operations | Resolves `absBase` and `absPath` and rejects results outside `baseDir` prefix | Direct `../` traversal is blocked, but long or deeply nested keys can still create path-length issues, inode/file-count exhaustion, or namespace confusion within `baseDir`. Dot-segments and separators are normalized, which can collapse distinct logical keys into the same on-disk path. | +| `Get` | `cache.go:89` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard | Cache oracle and cross-tenant read risk inside the allowed namespace. An attacker who can choose keys can probe for existence/timing of other entries in a shared cache or read another principal's cached object if the consumer does not namespace keys. | +| `Get` | `cache.go:95` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)`, expiry check, then `json.Unmarshal(entry.Data, dest)` | Missing files become cache misses; invalid envelope JSON becomes a cache miss; there is no size limit, schema check, or integrity/authenticity check | Malicious or compromised storage can feed oversized JSON for memory/CPU exhaustion, forge `ExpiresAt` far into the future to keep poisoned data live, or substitute crafted `data` payloads that alter downstream program behavior after unmarshal. | +| `Get` | `cache.go:89` | `dest` method argument from the caller | `json.Unmarshal(entry.Data, dest)` | Relies entirely on Go's JSON decoder and the caller-provided destination type | Type-driven resource abuse or logic confusion. If storage is attacker-controlled, decoding into permissive targets such as `map[string]any`, slices, or interfaces can trigger large allocations or smuggle unexpected structure into the consumer. | +| `Set` | `cache.go:123` | `key` method argument from the caller | `Path(key)`, `EnsureDir(filepath.Dir(path))`, then `Write(path, string(entryBytes))` | Inherits `Path` traversal guard | Namespace collision or storage exhaustion inside `baseDir`. An attacker-controlled key can create many directories/files, overwrite another tenant's cache entry, or consume disk/inodes within the permitted cache root. | +| `Set` | `cache.go:123` | `data` method argument from the caller | `json.Marshal(data)` into `Entry.Data`, then `json.MarshalIndent(entry)` and `c.medium.Write(path, string(entryBytes))` | Only successful JSON marshaling is required; no content, sensitivity, or size validation | Large or adversarial objects can consume CPU/memory during marshal and write. Sensitive data is stored as plaintext JSON, and with the default local backend the write path uses default file mode `0644`, creating local disclosure risk for cache contents. | +| `Delete` | `cache.go:158` | `key` method argument from the caller | `Path(key)` then `c.medium.Delete(path)` | Inherits `Path` traversal guard; `os.ErrNotExist` is ignored | Attacker-chosen eviction of entries inside `baseDir`. In a shared cache namespace this enables targeted cache invalidation or poisoning by deleting another principal's cached item. | +| `Clear` | `cache.go:175` | `c.baseDir` set earlier by constructor input/environment | `c.medium.DeleteAll(c.baseDir)` | No validation at call time in this package | Destructive recursive delete. If `baseDir` is user-controlled or misconfigured, `Clear` removes whatever tree the medium resolves that path to. With default unsandboxed `io.Local`, only `/` and `$HOME` are explicitly protected in the backend, leaving other directories in scope. | +| `Age` | `cache.go:183` | `key` method argument from the caller | `Path(key)` then `c.medium.Read(path)` | Inherits `Path` traversal guard; any error returns `-1` | Metadata oracle within `baseDir`. An attacker can probe whether specific keys exist and silently suppress backend/path failures because all errors collapse to `-1`. | +| `Age` | `cache.go:189` | Backend content returned by `c.medium.Read(path)` | `json.Unmarshal([]byte(dataStr), &entry)` then `time.Since(entry.CachedAt)` | Invalid JSON returns `-1`; no size limit or timestamp sanity check | Malicious storage can return oversized JSON for resource exhaustion or forge timestamps, producing misleading negative or extreme ages that can distort caller refresh decisions. | +| `GitHubReposKey` | `cache.go:205` | `org` argument from the caller | `filepath.Join("github", org, "repos")`, typically later consumed as a cache key by `Path`/`Set`/`Get` | No validation | Key normalization and collision risk. Inputs containing separators or dot-segments are normalized by `filepath.Join`, so unexpected values can collapse into another logical cache key. Direct traversal only gets blocked later if the resulting key reaches `Path`. | +| `GitHubRepoKey` | `cache.go:210` | `org` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as `GitHubReposKey`; a crafted org component can collapse onto another key path before the cache methods apply traversal checks. | +| `GitHubRepoKey` | `cache.go:210` | `repo` argument from the caller | `filepath.Join("github", org, repo, "meta")` | No validation | Same collision/normalization issue as the org input; crafted repo names containing separators or dot-segments can steer multiple logical repos onto the same cache key. | + +## Notes + +- The package's strongest built-in control is the path-traversal guard in `Cache.Path()`. It protects `Get`, `Set`, `Delete`, and `Age` against simple `../` escapes relative to `baseDir`. +- The highest-impact residual risk is not `key` traversal but unchecked control over `baseDir` and backend choice in `New()`, especially because the default `coreio.Local` medium is unsandboxed. +- Read-side trust is weak by design: cache files are accepted without integrity protection, size limits, or schema validation, so any actor that can modify the backing medium can turn the cache into a poisoning or denial-of-service surface. From 2455be1aef5045d0ba56103028fb25e7f441cdb4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 23 Mar 2026 13:57:40 +0000 Subject: [PATCH 4/4] docs(cache): add API contract report Co-Authored-By: Virgil --- docs/api-contract.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/api-contract.md diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..3410d48 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,28 @@ +--- +title: API Contract +description: Exported API contract for dappco.re/go/core/cache. +--- + +# API Contract + +This table lists every exported constant, type, function, and method in +`dappco.re/go/core/cache`. + +`Test coverage` is `yes` when the export is directly exercised by +`cache_test.go`. `Usage-example comment` is `yes` only when the symbol has its +own usage example in a doc comment or Go example test. + +| Name | Signature | Package Path | Description | Test Coverage | Usage-Example Comment | +|------|-----------|--------------|-------------|---------------|-----------------------| +| `DefaultTTL` | `const DefaultTTL = 1 * time.Hour` | `dappco.re/go/core/cache` | Default cache expiry time. | no | no | +| `Cache` | `type Cache struct { /* unexported fields */ }` | `dappco.re/go/core/cache` | File-based cache handle. | yes | no | +| `Entry` | `type Entry struct { Data json.RawMessage; CachedAt time.Time; ExpiresAt time.Time }` | `dappco.re/go/core/cache` | Cached item envelope with payload and timestamps. | no | no | +| `New` | `func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error)` | `dappco.re/go/core/cache` | Creates a cache instance, applying default medium, base directory, and TTL when zero-valued inputs are provided. | yes | no | +| `(*Cache).Path` | `func (c *Cache) Path(key string) (string, error)` | `dappco.re/go/core/cache` | Returns the full path for a cache key and rejects path traversal. | yes | no | +| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no | +| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no | +| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no | +| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no | +| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no | +| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no | +| `GitHubRepoKey` | `func GitHubRepoKey(org, repo string) string` | `dappco.re/go/core/cache` | Returns the cache key for a specific repo's metadata. | yes | no |