Snapshot Identifier Validation¶
- Authors
- Matt Cockayne, Claude (AI drafting assistant)
- Date
- 17 April 2026
- Status
- IN PROGRESS
Overview¶
The FileStore in pkg/chat/filestore.go constructs snapshot file paths via filepath.Join(s.dir, id+".json"). Today id is the Snapshot.ID field โ populated from google/uuid.New() when a snapshot is created, but arriving as a caller-supplied string on the Load and Delete paths and as a JSON-unmarshalled field on Save. A caller or a malicious JSON snapshot that supplies id = "../../../etc/passwd" (or any path-traversal variant) would cause reads, writes, or deletes outside s.dir.
This is identified as H-1 in docs/development/reports/security-audit-2026-04-17.md.
Threat Model¶
| Vector | Impact |
|---|---|
Attacker-controlled JSON passed to Save/Load (e.g. a tampered snapshot file on disk) |
Arbitrary read/write/delete on the local filesystem scoped to the GTB process's permissions |
Downstream tool author calling FileStore.Load(ctx, userInput) without their own validation |
Same |
Network-received snapshot bytes unmarshalled into a Snapshot struct before being stored |
Local file overwrite โ especially dangerous for config files inside the user's home directory |
The filesystem-scoped nature of the risk limits impact to the calling process's privileges, but a user running GTB can read/write anywhere that user can. A compromised snapshot store is a credible local-privilege-escalation primitive when combined with other vectors (e.g. a tool that later executes a file found in a "trusted" location).
Design Decisions¶
Two layers of defence โ input validation AND resolved-path containment. Validating the ID against a character class catches the most common attacks cleanly. Additionally checking that the cleaned absolute path remains a descendant of s.dir protects against edge cases (Windows \\..\\, Unicode normalisation tricks, absolute paths starting with / or a drive letter). Either defence alone is insufficient; both together are robust.
Strict UUID character class. The snapshot ID is generated by uuid.New() which emits canonical 8-4-4-4-12 hex form. The validator rejects anything that does not match ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$. This is stricter than "no path separators" and forecloses the entire class of traversal attacks at the shape level.
Validation is centralised in a single helper. validateSnapshotID(id string) error is called from every public path (Save, Load, Delete) so that adding a new path cannot accidentally omit the check.
Validation errors are ErrInvalidSnapshotID (sentinel). Callers that want to distinguish validation errors from I/O errors can errors.Is against it. The error message includes the offending ID truncated to its first 32 characters and quoted, to aid debugging without allowing log injection.
Path containment check is a filepath.Rel / strings.HasPrefix combo after cleaning both paths. Using Abs + Clean ensures symlink-free comparison; filepath.Rel(s.dir, target) returning a path that begins with .. indicates escape. This approach handles Windows and POSIX uniformly.
Save uses its own Snapshot.ID directly โ but we re-validate on the Save path too. A caller that constructs a Snapshot struct with a manually-set ID must also pass validation. This defends against the "JSON round-trip plus re-save" attack where a malicious snapshot is loaded (and stored under a validated ID) then re-saved under an attacker-chosen ID.
Public API Changes¶
New sentinel error¶
package chat
// ErrInvalidSnapshotID is returned when a snapshot identifier fails
// validation (not a canonical UUID, contains path separators, or
// produces a filesystem path outside the store directory).
var ErrInvalidSnapshotID = errors.New("invalid snapshot identifier")
Existing API โ no signature changes¶
FileStore.Save, FileStore.Load, FileStore.Delete retain their current signatures. The behaviour change is that they now return ErrInvalidSnapshotID (wrapped) for inputs that previously would have proceeded to a filesystem operation.
Callers that currently rely on the "anything goes" behaviour will break โ but that behaviour is the bug being fixed.
Optional: exported validator¶
For downstream tool authors who construct snapshot IDs themselves:
// ValidateSnapshotID returns nil if id is a canonical UUID that will
// be accepted by the FileStore methods, or an error wrapping
// ErrInvalidSnapshotID otherwise.
//
// Use this when accepting snapshot IDs from user input so that
// validation happens at the boundary of the system rather than being
// deferred to Save/Load/Delete.
func ValidateSnapshotID(id string) error
Internal Implementation¶
pkg/chat/filestore.go changes¶
import "regexp"
// uuidCanonicalPattern matches the canonical 8-4-4-4-12 hex form
// produced by google/uuid.New().
var uuidCanonicalPattern = regexp.MustCompile(
`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`,
)
// ValidateSnapshotID โ see exported doc above.
func ValidateSnapshotID(id string) error {
if id == "" {
return errors.WithHint(ErrInvalidSnapshotID, "snapshot ID is empty")
}
if !uuidCanonicalPattern.MatchString(id) {
return errors.WithHintf(ErrInvalidSnapshotID,
"snapshot ID %q is not a canonical UUID", truncate(id, 32))
}
return nil
}
// resolveStorePath joins id to the store directory and verifies the
// resolved absolute path does not escape s.dir. Used as a second line
// of defence after ValidateSnapshotID.
func (s *fileStore) resolveStorePath(id string) (string, error) {
if err := ValidateSnapshotID(id); err != nil {
return "", err
}
baseAbs, err := filepath.Abs(s.dir)
if err != nil {
return "", errors.Wrap(err, "resolving store directory")
}
baseAbs = filepath.Clean(baseAbs)
target := filepath.Clean(filepath.Join(baseAbs, id+".json"))
rel, err := filepath.Rel(baseAbs, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.WithHint(ErrInvalidSnapshotID,
"snapshot path escapes the store directory")
}
return target, nil
}
func (s *fileStore) Save(_ context.Context, snapshot *Snapshot) error {
path, err := s.resolveStorePath(snapshot.ID)
if err != nil {
return err
}
// ... existing encryption + write logic using path ...
}
func (s *fileStore) Load(_ context.Context, id string) (*Snapshot, error) {
path, err := s.resolveStorePath(id)
if err != nil {
return nil, err
}
// ... existing read + decrypt logic using path ...
}
func (s *fileStore) Delete(_ context.Context, id string) error {
path, err := s.resolveStorePath(id)
if err != nil {
return err
}
return errors.Wrap(s.fs.Remove(path), "removing snapshot file")
}
// truncate is a tiny helper to avoid log floods when logging invalid IDs.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "โฆ"
}
List method¶
fileStore.List iterates the store directory and returns each file's ID by stripping the .json extension. A file that has been manually placed in the store with a non-canonical name (e.g. ../evil.json via a directory traversal outside this package) would fail validation and be skipped with a DEBUG log โ NOT an error โ so the List operation remains robust in the face of corrupt-directory state.
Project Structure¶
| File | Change |
|---|---|
pkg/chat/filestore.go |
Add uuidCanonicalPattern, ValidateSnapshotID, resolveStorePath; route all public methods through resolveStorePath; add ErrInvalidSnapshotID; defensive skip-with-DEBUG in List. |
pkg/chat/filestore_test.go |
Add traversal-rejection tests, canonical-UUID acceptance tests, path-containment tests, List corrupt-directory robustness test. |
pkg/chat/filestore_fuzz_test.go |
New โ fuzz ValidateSnapshotID with random byte strings; assert no panic and consistent error-vs-nil behaviour. |
pkg/chat/persistence.go |
Confirm Snapshot.ID is set exclusively via uuid.New() in the constructor path; add a comment explaining why manual construction is discouraged. |
docs/components/chat.md |
Add "Snapshot storage security" subsection with the validation contract and ValidateSnapshotID helper. |
Error Handling¶
| Scenario | Error | Action |
|---|---|---|
id is empty |
ErrInvalidSnapshotID wrapped with hint |
Caller sees the validation error; no filesystem operation attempted. |
id contains path separator or non-hex characters |
ErrInvalidSnapshotID with truncated-id hint |
Same. |
id is canonical UUID but cleaned path escapes s.dir (defence-in-depth; should be unreachable through normal code paths) |
ErrInvalidSnapshotID with "escapes the store directory" hint |
Same. |
List encounters a file whose name does not validate |
DEBUG log, file skipped | List continues. |
Load on a valid-id file that doesn't exist |
Existing afero.ReadFile error |
Unchanged. |
All errors use cockroachdb/errors and are wrapped with WithHint for user-facing clarity.
Non-Functional Requirements¶
Testing & Quality Gates¶
| Requirement | Target |
|---|---|
| Line coverage | โฅ 95 % for filestore.go validation and path-resolution code |
| Branch coverage | โฅ 95 % for validateSnapshotID / resolveStorePath |
| Fuzz testing | FuzzValidateSnapshotID runs โฅ 60 s in CI; corpus seeded with canonical UUIDs, malformed UUIDs, traversal strings, Unicode tricks, NUL bytes |
| Race detector | go test -race ./pkg/chat/... must pass |
| Regression | Existing FileStore tests continue to pass; UUIDs produced by uuid.New() always validate |
| Security-specific | Test: Load("../../etc/passwd") โ ErrInvalidSnapshotID before any afero.ReadFile call (use a mock FS that records calls) |
| Security-specific | Test: a canonical-UUID ID that somehow produces an escaping path (simulated via s.dir = "") still fails containment |
Documentation Deliverables¶
| Artefact | Scope |
|---|---|
docs/components/chat.md |
Add "Snapshot storage security" subsection documenting the ID format, the containment invariant, and how to accept user input safely via ValidateSnapshotID. |
Package doc comment on pkg/chat/filestore.go |
Top-of-file block explaining the UUID-only contract and the two-layer defence. |
| Migration notes | Not required โ no public API signature change; only behaviour change is that invalid IDs now return an error instead of silently traversing. Callers relying on that behaviour were already buggy. |
Observability¶
| Event | Level | Fields |
|---|---|---|
List skips an invalid filename |
DEBUG | filename (truncated), reason |
| Save/Load/Delete rejected by validation | Returned error only; not logged automatically | Caller may log with their own fields |
Performance Bounds¶
| Metric | Bound |
|---|---|
ValidateSnapshotID |
O(1) โ single regex match on a 36-char input |
resolveStorePath |
O(1) โ two filepath.Clean/Abs/Rel calls on bounded paths |
List overhead |
One extra regex match per file; negligible compared to ReadDir |
Security Invariants¶
- Every exported
FileStoremethod callsresolveStorePathbefore touching the filesystem. resolveStorePathalways runs both layers: regex validation AND path containment.Listnever returns a filename that would fail validation โ so downstream callers who iterateListoutput and pass IDs back toLoadcannot re-enter a traversal state.Snapshot.IDis always generated viauuid.New()by GTB-provided constructors.
Migration & Compatibility¶
Behaviour change: methods that previously silently traversed on non-canonical IDs now return ErrInvalidSnapshotID. No known downstream consumer relies on the traversal behaviour (it was a bug). The breaking case is exclusively the security fix itself.
No public API signature change. FileStore.Save, Load, Delete, List all retain identical signatures.
API stability: pkg/chat is Beta-tier. The new ValidateSnapshotID function and ErrInvalidSnapshotID sentinel are additive.
Implementation Phases¶
Single phase โ this is a focused fix.
| Step | Description |
|---|---|
| 1 | Add uuidCanonicalPattern, ValidateSnapshotID, resolveStorePath, ErrInvalidSnapshotID to pkg/chat/filestore.go |
| 2 | Route Save, Load, Delete through resolveStorePath |
| 3 | Update List to skip non-validating filenames with a DEBUG log |
| 4 | Add unit tests including security-specific cases |
| 5 | Add FuzzValidateSnapshotID |
| 6 | Update docs/components/chat.md and package doc comments |
| 7 | just ci green on the changed packages |
Estimated effort: half a day.
Resolved Decisions¶
- UUID-only, not "no-separators". A strict shape-match is preferred over a permissive allow-list because it forecloses the entire traversal class (no
.., no/, no\, no NUL, no Unicode tricks) and also documents the contract โ everyFileStoreidentifier is a UUID. - Second-layer path containment is retained despite the first-layer regex making it unreachable through normal code paths. Defence-in-depth against Unicode normalisation quirks, future regex relaxation, and platform-specific path weirdness.
Listis robust, not strict. A malformed file in the store directory is logged and skipped rather than causingListto error โ the opposite would let one bad file permanently break snapshot enumeration for the user.- No migration for existing stored snapshots. Every snapshot that GTB has ever written has a canonical UUID ID (per
uuid.New()). Hand-crafted non-canonical files are not in scope.