go-ratelimit/docs/security-attack-vector-mapping.md
Virgil 1ec0ea4d28 fix(ratelimit): align module metadata and repo guidance
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 04:23:34 +00:00

11 KiB

Security Attack Vector Mapping

Scope: external inputs that cross into this package from callers, persisted storage, or the network. This is a mapping only; it does not propose or apply fixes.

Note: CODEX.md was not present anywhere under /workspace during this scan, so conventions were taken from CLAUDE.md and the existing repository layout.

Caller-Controlled API Inputs

Function File:Line Input Source What It Flows Into Current Validation Potential Attack Vector
NewWithConfig(cfg Config) ratelimit.go:145 Caller-controlled cfg.FilePath, cfg.Providers, cfg.Quotas cfg.FilePath is stored in rl.filePath and later used by Load() / Persist(); cfg.Providers selects built-in profiles; cfg.Quotas is copied straight into rl.Quotas Empty FilePath defaults to ~/.core/ratelimits.yaml; unknown providers are ignored; cfg.Backend is not read here; quota values and model keys are copied verbatim Untrusted FilePath can later steer YAML reads and writes to arbitrary local paths because the package uses unsandboxed coreio.Local; negative quota values act like "unlimited" because enforcement only checks > 0; very large maps or model strings can drive memory and disk growth
(*RateLimiter).SetQuota(model string, quota ModelQuota) ratelimit.go:183 Caller-controlled model and quota Direct write to rl.Quotas[model]; later consumed by CanSend(), Stats(), Persist(), and SQLite/YAML persistence None beyond Go type checking Negative quota fields disable enforcement for that dimension; extreme values can skew blocking behaviour; arbitrary model names expand the in-memory and persisted keyspace
(*RateLimiter).AddProvider(provider Provider) ratelimit.go:191 Caller-controlled provider selector Looks up DefaultProfiles()[provider] and overwrites matching entries in rl.Quotas via maps.Copy Unknown providers are silently ignored; no authorisation or policy guard in this package If a higher-level service exposes this call, an attacker can overwrite stricter runtime quotas for a provider's models with the shipped defaults and relax the intended rate-limit policy
(*RateLimiter).BackgroundPrune(interval time.Duration) ratelimit.go:328 Caller-controlled interval Passed to time.NewTicker(interval) and drives a background goroutine that repeatedly locks and prunes state None interval <= 0 causes a panic; very small intervals can create CPU and lock-contention DoS; repeated calls without using the returned cancel function leak goroutines
(*RateLimiter).CanSend(model string, estimatedTokens int) ratelimit.go:350 Caller-controlled model and estimatedTokens model indexes rl.Quotas / rl.State; estimatedTokens is added to the current token total before the TPM comparison Unknown models are allowed immediately; no non-negative or range checks on estimatedTokens Passing an unconfigured model name bypasses throttling entirely; negative or overflowed token values can undercount the TPM check and permit oversend
(*RateLimiter).RecordUsage(model string, promptTokens, outputTokens int) ratelimit.go:396 Caller-controlled model, promptTokens, outputTokens Creates or updates rl.State[model]; stores promptTokens + outputTokens in the token window and increments DayCount None Arbitrary model names create unbounded state that will later persist to YAML/SQLite; negative or overflowed token totals poison accounting and can reduce future TPM totals below the real usage
(*RateLimiter).WaitForCapacity(ctx context.Context, model string, tokens int) ratelimit.go:414 Caller-controlled ctx, model, tokens Calls CanSend(model, tokens) once per second until capacity is available or ctx.Done() fires No direct validation; relies on downstream CanSend() and caller-supplied context cancellation Inherits the unknown-model and negative-token bypasses from CanSend(); repeated calls with long-lived contexts can accumulate goroutines and lock pressure
(*RateLimiter).Reset(model string) ratelimit.go:433 Caller-controlled model model == "" replaces the entire rl.State map; otherwise delete(rl.State, model) Empty string is treated as a wildcard reset If reachable by an untrusted actor, an empty string clears all rate-limit history and targeted resets erase throttling state for chosen models
(*RateLimiter).Stats(model string) ratelimit.go:484 Caller-controlled model Prunes rl.State[model], reads rl.Quotas[model], and returns a usage snapshot None If exposed through a service boundary, it discloses per-model quota ceilings and live usage counts that can help an attacker tune evasion or timing
NewWithSQLite(dbPath string) ratelimit.go:567 Caller-controlled dbPath Thin wrapper that forwards dbPath into NewWithSQLiteConfig() and then newSQLiteStore() No additional validation in the wrapper Untrusted dbPath can steer database creation/opening to unintended local filesystem locations, including companion -wal and -shm files
NewWithSQLiteConfig(dbPath string, cfg Config) ratelimit.go:576 Caller-controlled dbPath, cfg.Providers, cfg.Quotas dbPath goes straight to newSQLiteStore(); provider and quota inputs are copied into rl.Quotas exactly as in NewWithConfig() cfg.Backend is ignored; unknown providers are ignored; no path, range, or size checks Combines arbitrary database-path selection with the same quota poisoning risks as NewWithConfig() and SetQuota()

Filesystem And YAML Inputs

Function File:Line Input Source What It Flows Into Current Validation Potential Attack Vector
(*RateLimiter).Load() (YAML backend) ratelimit.go:210 File bytes read from rl.filePath coreio.Local.Read(rl.filePath) feeds yaml.Unmarshal(..., rl), which overwrites rl.Quotas and rl.State Missing file is ignored; YAML syntax and type mismatches return an error; no semantic checks on counts, limits, model names, or slice sizes A malicious YAML file can inject negative quotas or counters to bypass enforcement, preload very large maps/slices for memory DoS, or replace the in-memory policy/state with attacker-chosen values
MigrateYAMLToSQLite(yamlPath, sqlitePath string) ratelimit.go:617 Caller-controlled yamlPath and sqlitePath, plus YAML file bytes from yamlPath Reads YAML with coreio.Local.Read(yamlPath), unmarshals into a temporary RateLimiter, then opens sqlitePath and writes the imported quotas/state into SQLite Read/open errors and YAML syntax/type errors are surfaced; no path restrictions and no semantic validation of imported quotas/state Untrusted paths enable arbitrary local file reads and database creation/clobbering; attacker-controlled YAML can permanently seed the SQLite backend with quota-bypass values or oversized state

SQLite Inputs

Function File:Line Input Source What It Flows Into Current Validation Potential Attack Vector
newSQLiteStore(dbPath string) sqlite.go:20 Caller-controlled database path passed from NewWithSQLite*() or MigrateYAMLToSQLite() sql.Open("sqlite", dbPath) opens or creates the database, then applies PRAGMAs and creates tables and indexes Only driver/open/PRAGMA/schema errors are checked; there is no allowlist, path normalisation, or sandboxing in this package An attacker-chosen dbPath can redirect state into unintended files or directories and create matching SQLite sidecar files, which is useful for tampering, data placement, or storage-exhaustion attacks
(*RateLimiter).Load() (SQLite backend via loadSQLite()) ratelimit.go:223 SQLite content reachable through the already-open rl.sqlite handle loadQuotas() and loadState() results are copied into rl.Quotas and rl.State; loaded quotas override in-memory defaults Only lower-level scan/query errors are checked; no semantic validation after load A tampered SQLite database can override intended quotas/state, including negative limits and poisoned counters, before any enforcement call runs
(*sqliteStore).loadQuotas() sqlite.go:110 Rows from the quotas table (model, max_rpm, max_tpm, max_rpd) Scanned into map[string]ModelQuota, then copied into rl.Quotas by loadSQLite() SQL scan and row iteration errors only; no range or length checks Negative or extreme values disable or destabilise later quota enforcement; a large number of rows or large model strings can cause memory growth
(*sqliteStore).loadState() sqlite.go:194 Rows from the daily, requests, and tokens tables Scanned into UsageStats maps/slices and then copied into rl.State by loadSQLite() SQL scan and row iteration errors only; no bounds, ordering, or semantic checks on timestamps and counts Crafted counts or timestamps can poison later CanSend() / Stats() results; oversized tables can drive memory and CPU exhaustion during load

Network Inputs

Function File:Line Input Source What It Flows Into Current Validation Potential Attack Vector
CountTokens(ctx, apiKey, model, text) (request construction) ratelimit.go:650 Caller-controlled ctx, apiKey, model, text model is interpolated directly into the Google API URL path; text is marshalled into JSON request body; apiKey goes into x-goog-api-key; ctx governs request lifetime JSON marshalling must succeed; http.NewRequestWithContext() rejects some malformed URLs; there is no path escaping, length check, or output-size cap on the request body Untrusted prompt text is exfiltrated to a remote API; very large text can consume memory/bandwidth; unescaped model strings can alter the path or query on the fixed Google host; repeated calls burn external quota
CountTokens(ctx, apiKey, model, text) (response handling) ratelimit.go:681 Remote HTTP status, response body, and JSON totalTokens value from generativelanguage.googleapis.com Non-200 bodies are read fully with io.ReadAll() and embedded into the returned error; 200 responses are decoded into result.TotalTokens and returned to the caller Checks for HTTP 200 and JSON decode errors only; no response-body size limit and no sanity check on TotalTokens A very large error body can cause memory pressure and log/telemetry pollution; a negative or extreme totalTokens value would be returned unchanged and could poison downstream rate-limit accounting if the caller trusts it