security

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

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

View Source
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

func HashToken(token string) string

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

func IsSubpath(path, parent string) bool

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.).

func PerIP

func PerIP(r *http.Request) string

PerIP extracts the socket peer IP, stripping the port. Forwarded headers are intentionally ignored because the daemon has no configured trusted-proxy boundary; accepting them would let clients choose their own rate-limit key.

func PerToken

func PerToken(r *http.Request) string

PerToken extracts the bearer token from the Authorization header for per-user rate limiting. Returns "" if no token (the middleware falls back to RemoteAddr).

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

func LogTo(w io.Writer) *AuditLog

LogTo writes to io.Discard — exported for tests that want to verify the Log call happens without touching the filesystem.

func NewAuditLog

func NewAuditLog(path string) (*AuditLog, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL