commit
4d8afbcf68
7 changed files with 157 additions and 13 deletions
102
cache.go
102
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
|
|
|
|||
28
docs/api-contract.md
Normal file
28
docs/api-contract.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -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)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
29
docs/security-attack-vector-mapping.md
Normal file
29
docs/security-attack-vector-mapping.md
Normal file
|
|
@ -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.
|
||||
1
go.mod
1
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
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue