Skip to content

Request restrictions: lock a key to specific web origins and IP addresses - #37

Merged
rameerez merged 11 commits into
mainfrom
feat/request-restrictions
Aug 25, 2026
Merged

Request restrictions: lock a key to specific web origins and IP addresses#37
rameerez merged 11 commits into
mainfrom
feat/request-restrictions

Conversation

@rameerez

Copy link
Copy Markdown
Owner

Implements the request-restrictions PRD in full, targeted at v0.5.0.

What ships: a restrictions JSON column; the ApiKeys::Restrictions value object (hosts + *.subdomain wildcards, IPv4/IPv6 exact + CIDR via stdlib IPAddr; OR within a list, AND across lists, empty = unrestricted, every failure mode fails closed); enforcement inside Authenticator.call beside environment isolation, so it covers every key on every request including token-cache hits with no controller opt-in; 403 with origin_not_allowed / ip_not_allowed that never echo the allowlist; per-key-type restriction ceilings; config.client_ip_resolver (defaults to request.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):

  • 433 runs / 1358 assertions / 0 failures on the default suite, reproduced independently; full Appraisals matrix green (Rails 7.2 / 8.0 / 8.1), the 7.2 leg reproduced independently.
  • Gate-fail-once: with the authenticator wiring reverted, 12 failures + 4 errors, all in the restrictions integration tests, none elsewhere; restored and green.
  • 22 adversarial hand probes on the value object, all pass: wildcard matches subdomains at any depth but never the apex and admits no evilexample.com suffix 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 build unpacked and inspected: 59 files, no test/, no gemfiles, no stray .gem blobs.

Known follow-up, deliberately out of scope (found during this work): .simplecov fails to load (skip is 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_misconfigured branch 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

rameerez and others added 9 commits August 25, 2026 18:08
…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
Comment thread test/form_builder_extensions_test.rb Fixed
@rameerez
rameerez merged commit ab6cc19 into main Aug 25, 2026
13 checks passed
@rameerez
rameerez deleted the feat/request-restrictions branch August 25, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants