10 KiB
10 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 |