go-store/docs/security-attack-vector-mapping.md
Virgil 959c792648 docs(security): verify attack-vector map
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 15:29:21 +00:00

13 KiB

Security Attack Vector Mapping

Scope: exported API entry points that accept caller-controlled input. Methods with no caller-controlled parameters ((*Store).Close, (*Store).PurgeExpired, (*ScopedStore).Namespace, EventType.String) and unexported helpers are omitted.

Verified against the current source tree on 2026-03-23. CODEX.md is not present under /workspace; CLAUDE.md is the only repo-local guidance file.

Cross-cutting observations:

  • SQL writes and reads use placeholders throughout; no direct SQL injection sink was found in store.go.
  • CountAll and Groups prefix searches escape ^, %, and _ before using LIKE, which blocks wildcard injection at that sink.
  • Outside namespace construction, there are no length limits or character restrictions on group, key, value, template strings, watcher filters, or callback registration.
  • Event payloads carry raw group, key, and value strings into watchers and callbacks without sanitisation.

Store API

Entry point File:line Input source What it flows into Current validation Potential attack vector
New(dbPath string) store.go:37 Caller-supplied SQLite path / DSN string sql.Open("sqlite", dbPath) followed by PRAGMA execution, schema creation, and migration queries No path or DSN allowlist; relies on driver/open errors Arbitrary database file selection or creation if the caller controls the path; attacker-owned or malformed DB files can force schema/migration work and denial of service on open.
(*Store).Get(group, key string) store.go:89 Caller-supplied group and key Parameterised SELECT on (grp, key); if expired, parameterised DELETE; raw group+"/"+key is included in the not-found error context No syntax or length checks; SQL placeholders prevent direct injection Key existence oracle; reads of expired keys become writes, which can be abused for write amplification; raw group/key strings can leak into logs or error surfaces.
(*Store).Set(group, key, value string) store.go:116 Caller-supplied group, key, and value Parameterised INSERT ... ON CONFLICT DO UPDATE; persisted in SQLite; mirrored into notify(EventSet{...}) No syntax, size, or character validation; SQL placeholders used Disk and memory exhaustion from unbounded values or high key churn; raw payload fan-out to watchers/callbacks can poison downstream logs or protocols; repeated same-value writes can be used to flood events.
(*Store).SetWithTTL(group, key, value string, ttl time.Duration) store.go:132 Caller-supplied group, key, value, and ttl time.Now().Add(ttl).UnixMilli(); parameterised upsert; mirrored into notify(EventSet{...}) No TTL bounds, size limits, or character validation; SQL placeholders used Negative or zero TTL produces immediately expired rows while still emitting EventSet; this enables event-driven side effects for data that is effectively unreadable and can create purge churn or temporary storage bloat.
(*Store).Delete(group, key string) store.go:147 Caller-supplied group and key Parameterised DELETE; unconditional notify(EventDelete{...}) after the exec call No existence, syntax, or length checks No-op deletes still emit delete events, so callers can spoof delete notifications for arbitrary keys; if exposed without authorisation, this is also a direct destructive surface.
(*Store).Count(group string) store.go:157 Caller-supplied group Parameterised COUNT(*) query on the selected group No syntax or length checks Group existence and cardinality oracle; brute-force probing can reveal tenant structure and consume DB time.
(*Store).DeleteGroup(group string) store.go:170 Caller-supplied group Parameterised DELETE FROM kv WHERE grp = ?; unconditional notify(EventDeleteGroup{...}) No existence, syntax, or length checks Mass-deletion surface for any reachable group; no-op deletes still emit delete_group events, which can mislead subscribers or audit trails.
(*Store).GetAll(group string) store.go:185 Caller-supplied group All(group) iterator results are collected into an in-memory map[string]string No syntax or length checks; expired rows are filtered Unbounded memory usage if the target group has many keys or large values; bulk exfiltration surface for the entire group.
(*Store).All(group string) store.go:197 Caller-supplied group Parameterised SELECT key, value ... WHERE grp = ? when the iterator is consumed No syntax or length checks; placeholders used; streaming avoids one big map allocation Full-group enumeration and sustained scan cost; attacker-controlled group population can drive long-running DB and CPU work.
(*Store).GetSplit(group, key, sep string) store.go:229 Caller-supplied group, key, and sep Get(group, key) result into strings.SplitSeq(val, sep) Same validation as Get; no separator validation Same key existence oracle as Get; attacker-chosen separators can amplify token count and CPU on large stored values.
(*Store).GetFields(group, key string) store.go:239 Caller-supplied group and key Get(group, key) result into strings.FieldsSeq(val) Same validation as Get Same key existence oracle as Get; large stored values can produce high CPU and large token streams.
(*Store).Render(tmplStr, group string) store.go:249 Caller-supplied template string and group; stored keys/values in the target group All(group) into map[string]string, then template.New("render").Parse(tmplStr) and Execute No template allowlist, output size limit, or escaping mode; parse and exec errors are returned Untrusted templates can enumerate or exfiltrate all values in the chosen group and generate unbounded output or CPU load; output is raw text/template, so downstream HTML, shell, JSON, or config injection remains possible if callers reuse it unsafely.
(*Store).CountAll(prefix string) store.go:271 Caller-supplied group prefix Parameterised LIKE ? ESCAPE '^' query using escapeLike(prefix) + "%", or a full-table count when prefix == "" escapeLike escapes ^, %, and _; SQL placeholders used Direct SQL wildcard injection is blocked, but empty or broad prefixes still enable whole-database counting and cross-namespace enumeration.
(*Store).Groups(prefix string) store.go:293 Caller-supplied group prefix GroupsSeq(prefix) results are collected into an in-memory []string Same escaping as CountAll Group-name disclosure across namespaces; unbounded memory use if there are many groups; empty prefix enumerates the whole store.
(*Store).GroupsSeq(prefix string) store.go:306 Caller-supplied group prefix Parameterised SELECT DISTINCT grp ... LIKE ? ESCAPE '^' when the iterator is consumed escapeLike escapes ^, %, and _; SQL placeholders used Group-name disclosure and sustained scan cost across the whole database; empty prefix scans all groups.

Event API

Entry point File:line Input source What it flows into Current validation Potential attack vector
(*Store).Watch(group, key string) events.go:73 Caller-supplied watcher filters Stored in Watcher{group, key} and evaluated by watcherMatches; matched events are queued on a buffered channel No validation; "*" is treated as wildcard by convention Wildcard subscriptions can observe every mutation and value; each watch allocates a buffer and adds O(n) work to every write, so unbounded watch creation is a memory and CPU denial-of-service surface; literal * names cannot be distinguished from wildcard filters.
(*Store).Unwatch(w *Watcher) events.go:92 Caller-supplied watcher pointer existing.id == w.id match inside the store's watcher slice, then close(w.ch) No nil check and no store-ownership check nil causes a panic; passing a watcher from another Store with a colliding id can unregister a local watcher while closing the caller-supplied channel instead, causing cross-store subscription denial of service and leaving the removed watcher's channel open.
(*Store).OnChange(fn func(Event)) events.go:116 Caller-supplied callback function Stored in callbacks; invoked synchronously by notify() while s.mu is held for reading No nil check, timeout, sandboxing, or re-entrancy guard Nil callback panics on the first mutation; slow or blocking callbacks stall writers; calling Watch, Unwatch, or OnChange from inside the callback deadlocks; callback registration is also a raw event exfiltration point.

ScopedStore API

Entry point File:line Input source What it flows into Current validation Potential attack vector
NewScoped(store *Store, namespace string) scope.go:34 Caller-supplied store pointer and namespace Namespace is stored and later used by prefix() to build namespace + ":" + group names Namespace must match ^[a-zA-Z0-9-]+$; no nil-store or length check Namespace validation blocks delimiter and wildcard breakout, but a nil store still yields later panics; attacker-chosen long namespaces inflate persisted group names and event payloads.
NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) scope.go:44 Caller-supplied store pointer, namespace, and quota config NewScoped(...), then quota values are stored and later consumed by checkQuota() Same namespace regex as NewScoped; no nil-store check; negative quota values are not rejected Negative MaxKeys or MaxGroups effectively disable enforcement because checks only run for values > 0; nil stores still panic later if used.
(*ScopedStore).Get(group, key string) scope.go:64 Caller-supplied group and key within a namespace s.prefix(group) then delegated to Store.Get(...) Namespace was validated at construction; group and key are otherwise unvalidated Same existence oracle and expired-read write amplification as Store.Get, but confined to the prefixed namespace.
(*ScopedStore).Set(group, key, value string) scope.go:70 Caller-supplied group, key, and value within a namespace checkQuota(group, key) then Store.Set(s.prefix(group), key, value) Namespace regex at construction; quota checks only if limits are positive; underlying SQL is parameterised Same storage and event-fan-out issues as Store.Set; quota enforcement is non-transactional, so concurrent writers can pass checkQuota() and then exceed MaxKeys or MaxGroups.
(*ScopedStore).SetWithTTL(group, key, value string, ttl time.Duration) scope.go:79 Caller-supplied group, key, value, and ttl within a namespace checkQuota(group, key) then Store.SetWithTTL(s.prefix(group), key, value, ttl) Same as Set; no TTL bounds Same as Store.SetWithTTL; concurrent quota bypass remains possible, and negative or zero TTL can create immediately expired rows that fall out of future quota counts until purge, enabling bursty quota evasion and storage churn.
(*ScopedStore).Delete(group, key string) scope.go:87 Caller-supplied group and key within a namespace Store.Delete(s.prefix(group), key) Namespace regex at construction only Same delete-event spoofing and destructive surface as Store.Delete, limited to the namespace prefix.
(*ScopedStore).DeleteGroup(group string) scope.go:92 Caller-supplied group within a namespace Store.DeleteGroup(s.prefix(group)) Namespace regex at construction only Same mass-delete and no-op event spoofing as Store.DeleteGroup, limited to the namespace prefix.
(*ScopedStore).GetAll(group string) scope.go:98 Caller-supplied group within a namespace Store.GetAll(s.prefix(group)) Namespace regex at construction only Same bulk-read memory pressure and group-wide data exposure as Store.GetAll, limited to the namespace prefix.
(*ScopedStore).All(group string) scope.go:104 Caller-supplied group within a namespace Store.All(s.prefix(group)) Namespace regex at construction only Same streaming enumeration and scan-cost surface as Store.All, limited to the namespace prefix.
(*ScopedStore).Count(group string) scope.go:109 Caller-supplied group within a namespace Store.Count(s.prefix(group)) Namespace regex at construction only Same group-cardinality oracle as Store.Count, limited to the namespace prefix.
(*ScopedStore).Render(tmplStr, group string) scope.go:115 Caller-supplied template string and group within a namespace Store.Render(tmplStr, s.prefix(group)) Namespace regex at construction only; same template handling as Store.Render Same template-driven disclosure, output injection, and resource-amplification surface as Store.Render, limited to the namespace prefix.

No additional exported callables with caller-controlled inputs were found beyond the rows above.