Documentation
¶
Overview ¶
Package security provides middleware for the daemon's HTTP surface: rate limiting, path allowlisting, and audit logging. The daemon is reachable off-host via Tailscale / Cloudflare Tunnel / SSH-forward, so every route that accepts user input or performs filesystem operations MUST be hardened.
Limits bound abusive or runaway clients by operation cost.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // UploadLimit: 100 upload or paste requests per minute per token. Body // limits independently bound each request's bytes. UploadLimit = NewRateLimiter(100, 100.0/60.0) // SessionCreateLimit: 10/min per token. Normal usage creates 1-2 tabs at // a time; 10 allows burst tab-creation without abuse. SessionCreateLimit = NewRateLimiter(10, 10.0/60.0) // 10 tokens, ~0.17/sec // HookIngestLimit: 100/sec per IP. Hooks fire 2-5 per agent turn; 100/sec // absorbs bursts from rapid tool-use without blocking legitimate hooks. HookIngestLimit = NewRateLimiter(100, 100.0) // 100 tokens, 100/sec // QuotaReadLimit: 30/min per token for the /api/<agent>/quota (+zai) routes. // These now back outbound OAuth calls to Anthropic/OpenAI/x.ai; without a // cap, a stolen bearer could fan out and burn the user's own provider rate // limits/quota. 30/min covers the iOS status-bar chip + Settings poll with // headroom while bounding abuse. See quota readers in internal/usage. QuotaReadLimit = NewRateLimiter(30, 30.0/60.0) // 30 tokens, ~0.5/sec // FileReadLimit: 60/min per token for /api/files/list + /api/files/get. // The files surface reads any non-sensitive file under PathAllowlist, so a // stolen bearer is one GET per file — this bounds the exfiltration rate and // the OOM risk of parallel 64 MiB reads (a server-side concurrency // semaphore in server_files.go bounds in-flight bodies on top of this). // 60/min covers interactive browsing with headroom. FileReadLimit = NewRateLimiter(60, 60.0/60.0) // 60 tokens, ~1/sec )
Default limiters for the daemon's sensitive routes. They are generous enough for legitimate use (iOS reconnect bursts, rapid keystrokes), tight enough to prevent abuse (runaway scripts, floods).
Functions ¶
func HashToken ¶
HashToken returns the first 8 hex characters of the bearer token — enough to identify a repeat client across log entries, NOT enough to replay the token. Used as the token_hash field in AuditEntry.
func IsSubpath ¶
IsSubpath reports whether path == parent or path is a descendant of parent. Both must be cleaned (filepath.Clean) before calling. The trailing-separator check is load-bearing: without it, parent "/home/user" matches sibling "/home/user-evil" via a plain HasPrefix, which is the prefix-collision class of traversal bug (git delete-untracked, etc.).
Types ¶
type AuditEntry ¶
type AuditEntry struct {
Timestamp string `json:"ts"` // RFC3339Nano
Method string `json:"method"` // HTTP method
Path string `json:"path"` // request path (no query — may leak tokens)
TokenHash string `json:"token_hash"` // first 8 chars of bearer (enough to identify, not to replay)
Status int `json:"status"` // HTTP status code
Action string `json:"action"` // human-readable (e.g., "git.commit", "upload")
}
AuditEntry is one line in the audit log. Field names are stable so external tooling (jq, grep) can parse them.
type AuditLog ¶
type AuditLog struct {
// contains filtered or unexported fields
}
AuditLog writes structured JSON entries to a file, with size-based rotation. Goroutine-safe via a single mutex — audit writes are synchronous (the caller waits) so a crash mid-request is still audited.
func LogTo ¶
LogTo writes to io.Discard — exported for tests that want to verify the Log call happens without touching the filesystem.
func NewAuditLog ¶
NewAuditLog opens (or creates) the audit log at the given path. The path's parent directory is created if missing (mode 0700). Returns an AuditLog that writes one JSON line per entry.
func (*AuditLog) Log ¶
func (a *AuditLog) Log(entry AuditEntry) error
Log writes one entry. The entry is JSON-encoded to a single line + "\n". Rotation: if the file exceeds maxBytes after the write, it's renamed to path+".1" (replacing the previous backup) and a fresh file starts. This keeps at most 2x maxBytes on disk.
Errors are returned but callers typically ignore them — a failed audit log write should NOT block the request (the request itself succeeded). The error is for diagnostics (the server logs it at warn level).
type PathAllowlist ¶
type PathAllowlist struct {
// contains filtered or unexported fields
}
PathAllowlist checks whether a given path is within the allowed set. The set is: $HOME (always) + entries from ~/.rmote/allowed-paths.txt (one absolute path per line, # comments allowed). The file is re-read on each Validate call so edits take effect without a daemon restart — matches the per-request secret re-read discipline in authMiddleware.
func DefaultAllowlist ¶
func DefaultAllowlist() *PathAllowlist
DefaultAllowlist returns the process-wide PathAllowlist, created lazily.
func NewPathAllowlist ¶
func NewPathAllowlist() *PathAllowlist
NewPathAllowlist builds the allowlist. homeDir defaults to os.UserHomeDir; if that fails (no HOME env), the allowlist is empty and Validate always returns false — fail-closed for safety.
func (*PathAllowlist) Validate ¶
func (a *PathAllowlist) Validate(path string) bool
Validate returns true if path is within $HOME or an explicitly-allowed subpath. Cleans the path (resolves .., symlinks not followed) before checking. Returns false for: relative paths, empty paths, paths outside the allowlist, or when $HOME is unset (fail-closed).
func (*PathAllowlist) ValidateCanonical ¶
func (a *PathAllowlist) ValidateCanonical(path string) bool
ValidateCanonical compares an existing path and every configured root after symlink resolution. Git uses this stricter form because repository discovery must neither escape through a symlink nor reject an explicitly allowed symlink root.
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter is a per-key token-bucket limiter. Keys are typically the bearer token (per-user) or the client IP (per-host). Each key gets an independent bucket with its own capacity + refill rate. Buckets are created lazily on first request and never garbage-collected — the key space is bounded by the number of distinct tokens/IPs, which is small for a personal daemon.
func NewRateLimiter ¶
func NewRateLimiter(capacity float64, refillPerSec float64) *RateLimiter
NewRateLimiter builds a limiter with the given capacity (burst size) and refill rate (tokens per second). A request consumes 1 token; an empty bucket returns false (caller should 429).
func (*RateLimiter) Allow ¶
func (r *RateLimiter) Allow(key string) bool
Allow attempts to consume 1 token for the given key. Returns true if allowed (token consumed), false if the bucket is empty (rate limit hit). Refills tokens based on elapsed time since the last request for this key.
func (*RateLimiter) Middleware ¶
func (r *RateLimiter) Middleware(keyFn func(r *http.Request) string, limiterName string) func(http.Handler) http.Handler
Middleware wraps an http.Handler with per-key rate limiting. Key is extracted by the keyFn (e.g., bearer token, client IP). On limit hit, returns 429 + a Retry-After header. limiterName is used in the response body so the client knows which limit was hit.