Skip to content

Feature set 130. - #57

Merged
jackfperryjr merged 1 commit into
mainfrom
features-130
Aug 4, 2026
Merged

Feature set 130.#57
jackfperryjr merged 1 commit into
mainfrom
features-130

Conversation

@jackfperryjr

Copy link
Copy Markdown
Owner

Enforce the premium rate limit against an allowlist

X-Api-Key was taken at face value — any non-empty value bought 600 req/min instead of 60, because the value was never compared against anything:

var limit = string.IsNullOrEmpty(apiKey) ? 60 : 600;
var key = string.IsNullOrEmpty(apiKey) ? $"ip:{...}" : $"key:{apiKey}";

The second line is the worse half. Partitioning on the raw header means varying it mints unlimited fresh windows, so the limit wasn't 10× too high — it was effectively absent for anyone who noticed.

Keys now come from an allowlist (ApiKeys__Keys__0, …), so a partition is only ever keyed on a credential we issued. An unrecognized key falls back to the anonymous limit rather than 401, keeping the documented "no API key required" contract intact. With no keys configured, everything is anonymous.

Also in here

The two named rate-limit policies are gone. No endpoint ever attached them with RequireRateLimiting, so they enforced nothing while appearing to — and the premium one carried the identical flaw, waiting for whoever wired it up. Only GlobalLimiter was ever live.

The request log had the same defect. IsPremium counted anyone who sent the header, valid or not, inflating the premium share in /api/stats. It now records recognized keys, and captures the value while the request is still live instead of resolving from a scope that the fire-and-forget write may find disposed.

Dashboard monster search now matches description as well as name, mirroring /api/monsters/search. Auditing the catalogue turned up 3,218 enemy-ability pages and 128 enemy-type pages that the wiki scrape filed as monsters — 47.5% of the 7,062 rows. What marks them (#REDIRECT … enemy abilities, may refer to, is a genus of) lives only in the description, so searching names alone left them unreachable from the dashboard that curates them.

Testing

ApiKeyTests covers the regression directly — 14 cases over unrecognized keys, case sensitivity, prefix matching, blank config values binding from unset env vars, and the normalization that stops one key holding several rate-limit windows.

324/324 pass; dotnet format clean.

Notes

  • Key comparison is an ordinal HashSet lookup, not constant-time. Proportionate for something granting a rate-limit tier rather than data access, but it is a bearer credential.
  • Description search is a %term% scan with no index — fine at 7k rows on an admin page. It also means searching a common word returns monsters that merely mention it.
  • Labelled release:minor: new configuration and changed documented behaviour, but no break to the public contract, since a key was never required.

🤖 Generated with Claude Code

Enforce the premium rate limit against an allowlist.

The X-Api-Key header was taken at face value: any non-empty value bought
600 req/min instead of 60, because the value was never compared against
anything. Worse, the limiter partitioned on the raw header, so varying it
minted unlimited fresh windows -- the limit was not 10x too high, it was
effectively absent for anyone who noticed.

Keys now come from an allowlist (ApiKeys__Keys__0, ...). An unrecognized key
falls back to the anonymous limit rather than 401, keeping the documented
"no API key required" contract intact.

Also removed the two named rate limit policies. No endpoint ever attached
them with RequireRateLimiting, so they enforced nothing while appearing to,
and the premium one carried the same flaw waiting to be wired up. Only
GlobalLimiter was ever live.

The request log had the same defect: IsPremium counted anyone who sent the
header, valid or not, inflating the premium share in /api/stats. It now
records recognized keys, captured while the request is still live rather
than resolved from a disposed scope inside the fire-and-forget write.

Separately, the dashboard's monster search now matches description as well
as name, mirroring /api/monsters/search. Auditing the catalogue turned up
~3,200 enemy-ability pages and ~130 enemy-type pages that the wiki scrape
filed as monsters, and what marks them -- "#REDIRECT ... enemy abilities",
"may refer to", "is a genus of" -- lives only in the description. Searching
names alone left them unreachable from the dashboard that curates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jackfperryjr jackfperryjr added bug Something isn't working release:minor labels Aug 4, 2026
@jackfperryjr
jackfperryjr merged commit 641c702 into main Aug 4, 2026
1 check passed
@jackfperryjr
jackfperryjr deleted the features-130 branch August 4, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working release:minor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant