go-ratelimit/docs/security-attack-vector-mapping.md

45 lines
10 KiB
Markdown
Raw Normal View History

# 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 |