Request restrictions: lock a key to specific web origins and IP addresses - #37
Merged
Conversation
…sses Any key can now carry two allowlists: `allowed_origins` (bare hosts and `*.subdomain` wildcards, matched against the browser's Origin header with a Referer fallback) and `allowed_ips` (IPv4/IPv6, exact addresses or CIDR ranges). Both are enforced inside `Authenticator.call`, alongside environment isolation, so every host application gets the check for free and no endpoint can forget it. The token cache only shortcuts the lookup, so checks always read the current row: tightening a leaked publishable key's origins takes effect on the very next request. Within a list any entry admits the request; across lists every list that has entries must pass. Empty restrictions mean unrestricted, so every existing key behaves exactly as before. Refusals answer 403 with `origin_not_allowed` or `ip_not_allowed`, never echoing the configured allowlist back to the caller, and every failure mode fails closed: a locked list plus an unreadable origin, an unresolvable client IP, or an unparseable stored entry is a refusal. `ApiKeys::Restrictions` owns parsing, normalization, and matching, including the forgiving parsers a dashboard needs, so host applications can delete their own origin parsers. Key types may cap which restriction kinds their keys carry via `restrictions:`, mirroring the `permissions:` scope ceiling, and `config.client_ip_resolver` covers deployments where `remote_ip` is not the truth. Restrictions are deliberately absent from IMMUTABLE_IDENTITY_ATTRIBUTES: they are the one control a non-revocable key's owner still has. New installations get the column from the install generator; existing ones run `rails generate api_keys:add_restrictions`, and writing restrictions without the column raises an error naming that generator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
The key form now renders an "Allowed web origins" and an "Allowed IP addresses" text field, each shown only when the key's type permits that restriction kind, and each accepting the raw comma or newline separated string a user actually types. The model normalizes it, so the dashboard carries no parser of its own and invalid entries come back as ordinary validation errors. Keys carrying either list show a "Restricted" badge whose title names the kinds in play. Restriction edits stay permitted on non-revocable keys on purpose: tightening the allowlist is the only control the owner of an unrevocable public key has left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
Two new files. `restrictions_test.rb` pins the value object: exact, case insensitive, port blind and scheme blind host matching; wildcards that match subdomains at any depth but never the apex or a lookalike suffix; IPv4 and IPv6 exact addresses, CIDR boundaries, cross-family refusals, and IPv4-mapped addresses; the forgiving parsers against full URLs, ports, paths, duplicates and junk; wrap idempotence, nil safety, unknown-key preservation, and the Origin/Referer extraction path including garbage headers. `request_restrictions_test.rb` drives the authenticator and the controller concern end to end: unrestricted keys still work from anywhere, locked keys answer 403 with the right error code for a wrong origin, a missing origin, a garbage Origin header, an out-of-range address, an unreadable address, and a resolver that raises; both lists must pass when both are set; a custom client_ip_resolver decides the address; the token cache never stales a tightened allowlist; refusal messages never contain the allowlist and can be translated. Alongside them, the model and configuration surface: normalization of raw strings, per-list editing, the restricted/unrestricted scopes, entry count and size caps, malformed origin and IP rejection, key-type ceilings in all three shapes, and the missing-column guard. The in-memory test schema and the dummy application both gain the column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
README gains a top-level "Restrict where a key can be used (origins and IPs)" section: the pitch, the upgrade generator, usage, a semantics table (OR within a list, AND across lists, empty means unrestricted, the wildcard and fail-closed rules, the 403 error codes), per-key-type ceilings, the Cloudflare client_ip_resolver example, and the security notes on what browser-enforced headers can and cannot prove. The key types, dashboard, model scopes, instance methods, and upgrade sections point at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
Three tests for paths the matrix left untouched: a headers object that raises when read (extract_origin_host must answer nil, not propagate), a scalar where a list was expected, and the equality/hash contract that lets a Restrictions instance serve as a hash key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
The key form and the badge partial had no view-level coverage, so an ERB mistake in either could have shipped. These tests render new and edit, submit create and update through the controller, and assert what the user sees: the two fields appear, an existing key's list comes back in the field, a submitted raw string is normalized on the way in, a malformed entry is refused without overwriting what was there, and the "Restricted" badge shows up for restricted keys only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
The origins message surfaces verbatim in host-app form errors, where backtick markup reads as noise. example.com or *.example.com says the same thing by showing instead of describing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
The after_authentication callback context carried api_key_id on success and on scope refusals, but not on environment or restriction refusals: Result.failure dropped the key even when the authenticator had already identified it. From the outside, a key under a misconfigured origin lock was indistinguishable from a key nobody ever tried — last_used_at stays nil (stats only run on success, deliberately, so refused traffic cannot masquerade as integration), and the refusal callback was anonymous, so a host app could not even count refusals per key. Failure results from every identified-but-refused path (environment isolation in all its failure modes, origin and IP restrictions) now carry the key; the callback context picks it up unchanged. Lookup failures still have no key to name. Fail-once verified: the new attribution test fails against the previous authenticator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the request-restrictions PRD in full, targeted at v0.5.0.
What ships: a
restrictionsJSON column; theApiKeys::Restrictionsvalue object (hosts +*.subdomainwildcards, IPv4/IPv6 exact + CIDR via stdlib IPAddr; OR within a list, AND across lists, empty = unrestricted, every failure mode fails closed); enforcement insideAuthenticator.callbeside environment isolation, so it covers every key on every request including token-cache hits with no controller opt-in; 403 withorigin_not_allowed/ip_not_allowedthat never echo the allowlist; per-key-type restriction ceilings;config.client_ip_resolver(defaults torequest.remote_ip); generators for new and existing installs; dashboard fields + badge; README section, CHANGELOG, version 0.5.0.Verification (implementation by an Opus 5 agent, then an independent review pass):
evilexample.comsuffix trick; CIDR boundaries; cross-family lookups in both directions refuse without raising; IPv4-mapped IPv6 matches a v4 list; locked list + unreadable context refuses; normalization keeps invalid entries so validation reports them instead of silently unlocking.gem buildunpacked and inspected: 59 files, no test/, no gemfiles, no stray.gemblobs.Known follow-up, deliberately out of scope (found during this work):
.simplecovfails to load (skipis not a SimpleCov method), so branch coverage and the minimum-coverage gates have been inert for every run — the coverage gate cannot go red. Deserves its own housekeeping PR together with the untested:environment_misconfiguredbranch in the authenticator.Release: squash-merge only. Tag + GitHub Release (which auto-publishes to RubyGems) remain the owner's step, per the standing ritual.
🤖 Generated with Claude Code
https://claude.ai/code/session_015jjKjr2ajZ4etdoMN6eC6k