api_keys 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +44 -9
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +99 -4
- data/app/views/api_keys/keys/_form.html.erb +35 -13
- data/app/views/api_keys/keys/_restriction_fields.html.erb +4 -2
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
- data/lib/api_keys/configuration.rb +11 -6
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +62 -7
- data/lib/api_keys/models/concerns/has_api_keys.rb +10 -2
- data/lib/api_keys/restrictions.rb +75 -24
- data/lib/api_keys/services/authenticator.rb +24 -2
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +4 -1
- data/lib/generators/api_keys/templates/initializer.rb +16 -8
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54964496d525a092f7d1c9759e10bc4e73cce2633040c3f1e3b9a172609d02c3
|
|
4
|
+
data.tar.gz: 4cf7aabd74db8edf00e6ba06fc3cfe1536c26dc606e0485e452b7e920750ad1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 05f939482ae7258c7bc57c2cab1b4c971e58b5db4a74338640ae063b53862cbfdbc8d5e8b4385444595d21edf6c6754842d40ba0c42b6e7afc50fc543e9ca542
|
|
7
|
+
data.tar.gz: 03addc813dbd92704cc1dce4adcc2db22c44d5cd364c2949d83ac19c903e390e1521d389ff95b2d0536652700d16b58a91bf357133bdab55ab655a3e4fc5b5be
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## [0.5.1] - 2026-10-18
|
|
2
|
+
|
|
3
|
+
### Security
|
|
4
|
+
|
|
5
|
+
- Clearing every permission in the dashboard now removes the key's scopes. With every box unchecked the form submitted no `scopes` parameter, so an update kept the old scopes and a create applied the default scopes. Both checkbox groups now render an empty `api_key[scopes][]` sentinel: an empty selection means "no scopes" and the model's validations decide, while defaults still apply when the parameter is absent. The edit form changes only the scopes it offers (the owner's default scopes) and keeps every other scope a key holds, so a rename no longer strips a scope the form never showed. A failed create re-renders the boxes as submitted instead of re-checking all of them.
|
|
6
|
+
- `FormBuilderExtensions#api_key_scopes_checkboxes` renders the same sentinel before the boxes when a block is given. Host forms will see one extra hidden input. By default it is rendered only when there is at least one box and the form's key enforces its scopes (new `ApiKey#scopes_enforced?`), because where scopes are not enforced an empty list still means "unrestricted". Per-owner default scopes are read through the key's owner, so a form with no object or on a bare `ApiKey.new` renders no sentinel unless you pass `include_hidden: true`; build the key with `owner.api_keys.build`. `include_hidden: false` turns it off.
|
|
7
|
+
- `ApiKey#revoke!` no longer depends on unrelated validations. A key issued before its type's permission or restriction ceiling was narrowed kept authenticating but could not be revoked. Revocation keeps the revocability guard and the save callbacks, writes only `revoked_at` (and `updated_at`), and still validates any other unsaved edits on the same instance. The next request is refused as `revoked_key`, including through the token cache.
|
|
8
|
+
- Malformed restriction values fail closed. Coercion dropped `false`, `nil`, and blank entries, so stored `{"ips": false}`, `{"ips": [false]}`, or `{"ips": [null]}` read as unrestricted and `update!(allowed_ips: false)` stored `{}`; the same applied to origins. Such values are now kept, rejected by validation, and refuse authentication with `restriction_misconfigured` when found in the database; a stored `null` or blank kind is refused too. Input keeps its 0.5.0 meaning where it was unambiguous: a kind given as `nil` or blank text to `restrictions=`, `create_api_key!(restrictions:)`, or the `allowed_origins=` / `allowed_ips=` writers clears that list, so `create_api_key!(restrictions: { ips: nil })` still creates an unrestricted key. `false`, wrong types, and `null` or blank members inside a list are rejected on input. A malformed `restrictions:` argument to `create_api_key!` now reaches validation instead of being normalized to `{}`. The dashboard shows a stored policy's malformed entries instead of empty fields and leaves an untouched field's list as stored, so a rename can no longer turn a malformed policy into an unrestricted one; the save is refused until the owner corrects it.
|
|
9
|
+
- The authentication header is checked for encoding and size before it is parsed. An invalid UTF-8 header raised `ArgumentError` from the Bearer match; it now answers `invalid_token`, and a header longer than the token limit plus 64 bytes is refused without being scanned.
|
|
10
|
+
- The one-time token handoff is now one-time across copies of the session. Retrieval deleted the payload only from the session it was given, so a copied pre-retrieval cookie, or two concurrent requests, could recover the token twice within its ten-minute lifetime. Each handoff now carries a random ID, and the first retrieval records it with an atomic `Rails.cache.write(..., unless_exist: true)`; later retrievals get `nil` while the cache keeps that record, and a cache error withholds the token. The guarantee needs both a shared store with atomic `unless_exist:` and retention of the record for the ten-minute handoff lifetime: if the cache is flushed, restarted, or evicts the record within that window, a copied pre-retrieval cookie recovers the token again. Durable consumption state is planned for 0.6.0. With `ActiveSupport::Cache::NullStore` or no cache, the session deletion remains the only protection. With Solid Cache on PostgreSQL, two simultaneous first retrievals can both succeed; sequential replays are stopped. The handoff format is unchanged, so 0.5.0 and 0.5.1 processes can read each other's handoffs during a rolling deploy.
|
|
11
|
+
- IP restrictions: the README, SECURITY.md, the generated initializer, and the `add_restrictions` generator now state that the default `request.remote_ip` resolver trusts `X-Forwarded-For`. Behind a proxy that is the only way in, keep it; when the Rails server is directly reachable, use `->(request) { request.remote_addr }`. `trusted_proxies` alone is not a boundary. The suite covers both shapes through Rails' `ActionDispatch::RemoteIp`. The default is unchanged.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The dashboard's security page no longer says publishable keys cannot be revoked individually; since 0.5.0 they can, unless their key type sets `revocable: false`.
|
|
16
|
+
- The README's prefix table no longer says a key can be created without a type when key types are configured. Such a key takes `default_key_type`, or creation raises.
|
|
17
|
+
|
|
1
18
|
## [0.5.0] - 2026-08-25
|
|
2
19
|
|
|
3
20
|
### Added
|
data/README.md
CHANGED
|
@@ -159,7 +159,7 @@ config.authenticate_owner_method = :ensure_team_access!
|
|
|
159
159
|
Once configured, your users can:
|
|
160
160
|
- self-issue new API keys
|
|
161
161
|
- set expiration dates
|
|
162
|
-
- attach scopes / permissions to individual keys
|
|
162
|
+
- attach scopes / permissions to individual keys (the edit form changes only the scopes it shows, the owner's default scopes, and keeps any other scope a key holds)
|
|
163
163
|
- add and edit the key names
|
|
164
164
|
- lock a key to specific web origins or IP addresses
|
|
165
165
|
- revoke instantly
|
|
@@ -392,7 +392,7 @@ current_org.can_create_api_key?(key_type: :publishable)
|
|
|
392
392
|
@api_key = current_org.create_api_key!(
|
|
393
393
|
name: "My Key",
|
|
394
394
|
key_type: :secret, # or :publishable
|
|
395
|
-
scopes: ["read", "write"], # Blank values auto-removed
|
|
395
|
+
scopes: ["read", "write"], # Blank values auto-removed; [] means none, omit for defaults
|
|
396
396
|
expires_at: 30.days.from_now, # Explicit date
|
|
397
397
|
expires_at_preset: "30_days", # OR use preset (takes precedence)
|
|
398
398
|
environment: :live, # Defaults to current_environment
|
|
@@ -470,7 +470,23 @@ ApiKeys::TokenSession.store(session, @api_key, key: :my_custom_key)
|
|
|
470
470
|
)
|
|
471
471
|
```
|
|
472
472
|
|
|
473
|
-
Treat the session payload as private implementation detail; use `store`, `available?`, and `retrieve_once` rather than reading it directly. A failed, expired, tampered, mismatched, or reused handoff returns `nil` and fails closed.
|
|
473
|
+
Treat the session payload as private implementation detail; use `store`, `available?`, and `retrieve_once` rather than reading it directly. A failed, expired, tampered, mismatched, or reused handoff returns `nil` and fails closed. Any retrieval attempt uses the handoff up, including one bound to the wrong key.
|
|
474
|
+
|
|
475
|
+
#### What "one-time" guarantees
|
|
476
|
+
|
|
477
|
+
Deleting the payload makes the handoff one-time within that session: a refresh, or a second visit with the same session, shows nothing. A copy of the session taken *before* the token was shown is a different session. That is what a copied `CookieStore` cookie is, and what two requests racing with the same cookie are. Deleting the payload from one copy does nothing to the other.
|
|
478
|
+
|
|
479
|
+
So each handoff also carries a random ID inside its encrypted payload, and the first retrieval records that ID in `Rails.cache` with `write(key, true, unless_exist: true, expires_in: 10.minutes)`. While the cache still holds that record, any later retrieval of the same handoff, from any copy of the session, finds it and returns `nil`.
|
|
480
|
+
|
|
481
|
+
- **The token is released at most once only when both conditions hold:** every application process shares a store that implements `unless_exist:` atomically (Redis, Memcached), and that store keeps the record for the whole ten-minute handoff lifetime.
|
|
482
|
+
- **The record lives only in the cache.** If the cache is flushed, restarted without persistence, or evicts the record within those ten minutes, a copy of the session taken before retrieval can recover the token again for the rest of that window. Recording consumption durably, in a database column, is the stronger design and is planned for 0.6.0.
|
|
483
|
+
- **`MemoryStore`** only covers the process that holds it. **`FileStore`** checks and then writes, so it stops a later replay on the same host but not two requests that arrive at the same instant.
|
|
484
|
+
- **Solid Cache** (Rails 8's default production store) implements `unless_exist:` as a `SELECT … FOR UPDATE` followed by an upsert. On PostgreSQL that locks nothing while the key does not exist yet, so two simultaneous first retrievals can both succeed. Sequential replays are still stopped.
|
|
485
|
+
- **With `ActiveSupport::Cache::NullStore`** (what Rails' generated development and test environments use unless you toggle caching on), or with no `Rails.cache` at all, nothing can be recorded. The gem keeps the pre-0.5.1 behaviour: the session deletion is the only protection, and a copy of the session taken before retrieval can show the token again during its ten-minute lifetime.
|
|
486
|
+
- **If the cache raises or refuses the write**, the token is withheld. The owner sees the "shown only once" message and creates a new key, which is safer than risking a second release.
|
|
487
|
+
- **With a shared cache, a second request for the token page with the same cookie gets nothing**, even if the first response never reached the browser. A client that drops the first response (a timeout or a cancelled load) sees "shown only once" and needs a new key. That is the intended trade-off: a token can be lost, never shown twice.
|
|
488
|
+
|
|
489
|
+
None of this protects a session that has been stolen. Whoever holds a copy of the owner's session cookie can use the dashboard as that owner; the handoff is one-time, the session is not.
|
|
474
490
|
|
|
475
491
|
---
|
|
476
492
|
|
|
@@ -527,7 +543,9 @@ Renders a select dropdown with all expiration presets:
|
|
|
527
543
|
|
|
528
544
|
#### `api_key_scopes_checkboxes`
|
|
529
545
|
|
|
530
|
-
Renders scope checkboxes with a block for custom markup:
|
|
546
|
+
Renders scope checkboxes with a block for custom markup. Before the boxes it can render one empty `api_key[scopes][]` field: browsers omit unchecked boxes, so without it a form with every box cleared would send no scopes at all, and a new key would silently get the default scopes while an edited key would keep its old ones. With it, clearing every box submits `[""]`, which `create_api_key!` and `ApiKey#scopes=` read as "no scopes". The submitted list replaces the key's scopes, so render a box for every scope the key may hold.
|
|
547
|
+
|
|
548
|
+
By default the field is rendered only when there is at least one box and the form's key enforces its scopes (`@api_key.scopes_enforced?`). That is true for typed keys, whenever `key_types` or the global `config.default_scopes` are configured, and when the owner model's `has_api_keys` declares `default_scopes`. The key reads that last setting through its owner, so build it from the owner (`current_organization.api_keys.build`). A form with no object (`form_with scope: :api_key`) or on a bare `ApiKeys::ApiKey.new` cannot see per-owner defaults and renders no field: pass `include_hidden: true` there to render it whenever there is at least one box. Where scopes are not enforced at all, an empty list still means "unrestricted", so the default leaves the key's scopes as they were rather than widening it. Pass `include_hidden: false` only if you render the field yourself. When you iterate the returned data without a block, render `hidden_field_tag "api_key[scopes][]", "", id: nil` before the boxes under the same conditions.
|
|
531
549
|
|
|
532
550
|
```erb
|
|
533
551
|
<%# With block - you control the HTML, gem handles the logic %>
|
|
@@ -816,6 +834,8 @@ You can check if an API key is allowed to do actions by checking its scopes:
|
|
|
816
834
|
# => true
|
|
817
835
|
```
|
|
818
836
|
|
|
837
|
+
An empty scope list grants nothing whenever scopes are enforced: on typed keys, and when `key_types` or `default_scopes` are configured. On an untyped key in an app that configures neither, an empty list keeps its legacy meaning and allows every scope. So a key saved from the dashboard with every permission cleared becomes unrestricted if you later remove the owner's `default_scopes` (and the global ones) without using key types.
|
|
838
|
+
|
|
819
839
|
## Controllers: secure your API endpoints
|
|
820
840
|
|
|
821
841
|
To add the `api_keys` functionality to your controllers, just use the `ApiKeys::Controller` concern and you'll have all controller methods available:
|
|
@@ -939,11 +959,12 @@ When using key types, here's how the prefix is determined:
|
|
|
939
959
|
| `key_types` Config | `key_type` Param | Resulting Prefix |
|
|
940
960
|
|-------------------|------------------|------------------|
|
|
941
961
|
| `{}` (disabled) | any | Uses `token_prefix` → `"ak_..."` |
|
|
942
|
-
| Configured | `nil` | Uses
|
|
962
|
+
| Configured | `nil`, with `default_key_type` set | Uses the default type's config → e.g. `"sk_live_..."` |
|
|
963
|
+
| Configured | `nil`, no `default_key_type` | Not created: `create_api_key!` raises `ArgumentError` ("key_type is required when key types are configured") |
|
|
943
964
|
| Configured | `:publishable` | Uses type config → `"pk_test_..."` |
|
|
944
965
|
| Configured | `:secret` | Uses type config → `"sk_live_..."` |
|
|
945
966
|
|
|
946
|
-
In short: `token_prefix` is only used when key types are not configured
|
|
967
|
+
In short: `token_prefix` is only used when key types are not configured. Once you configure `key_types`, every new key needs a type, either passed as `key_type` or taken from `config.default_key_type`, and its prefix comes entirely from that type's configuration. Untyped keys issued before you configured key types keep the prefix they were issued with and keep authenticating.
|
|
947
968
|
|
|
948
969
|
See the [Key Types](#key-types-stripe-style-publishable--secret-keys) section below for full details.
|
|
949
970
|
|
|
@@ -1362,7 +1383,8 @@ Origin input is deliberately forgiving: full URLs, trailing slashes, ports, comm
|
|
|
1362
1383
|
|---|---|
|
|
1363
1384
|
| Within one list | **OR** — any entry that matches admits the request |
|
|
1364
1385
|
| Across both lists | **AND** — every list that has entries must pass |
|
|
1365
|
-
| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected |
|
|
1386
|
+
| Empty (or absent) lists | **Unrestricted** — presence is the toggle, so existing keys are unaffected. A kind given as `nil` or blank text (to `allowed_origins=` / `allowed_ips=`, `restrictions=`, or `create_api_key!(restrictions:)`) clears that list. In a stored row only `[]` or an absent kind means empty: a stored `null` is malformed |
|
|
1387
|
+
| Malformed values | It depends on the path. The `allowed_origins=` / `allowed_ips=` writers parse raw text, so `nil`, blank text, and blank members such as `[""]` clear the list, while `false`, numbers, objects, and `nil` members fail validation. A restrictions hash (`restrictions=`, `create_api_key!(restrictions:)`) clears a kind given as `nil` or blank text, and refuses a kind given as `false`, a number, or an object, and `null`, `false`, numbers, objects, or blank strings inside a list. Stored rows are read strictly: a row holding any of these, or a `null` or blank kind, refuses every request with `restriction_misconfigured`, and the dashboard shows the stored entries so a save is refused until they are corrected. Malformed never means unrestricted |
|
|
1366
1388
|
| `example.com` | Matches that exact host. Case-insensitive, port-blind, scheme-blind |
|
|
1367
1389
|
| `*.example.com` | Matches `a.example.com` and `a.b.example.com`, but **not** the apex `example.com`. List both to cover both |
|
|
1368
1390
|
| `*` alone | Invalid. An empty list already means "anywhere" |
|
|
@@ -1389,7 +1411,20 @@ Omitting `restrictions:` allows both kinds. `restrictions: []` forbids restricti
|
|
|
1389
1411
|
|
|
1390
1412
|
### Resolving the client IP
|
|
1391
1413
|
|
|
1392
|
-
IP
|
|
1414
|
+
An IP allowlist is only as truthful as the address it is checked against, and the gem cannot see how your servers are deployed. The default resolver is `request.remote_ip`. Rails computes it from the `X-Forwarded-For` header whenever that header is present, so it is right in one deployment shape and spoofable in the other.
|
|
1415
|
+
|
|
1416
|
+
**Behind a proxy (keep the default).** Your load balancer, reverse proxy, or CDN is the only way to reach Rails. It overwrites `X-Forwarded-For`, or appends the address it actually saw, and its addresses are covered by `config.action_dispatch.trusted_proxies` (Rails already trusts private and loopback ranges). Rails then skips the trusted hops and resolves the real client, and an entry the client forged ends up behind the one your proxy added.
|
|
1417
|
+
|
|
1418
|
+
**Directly reachable (use the socket peer).** Clients connect straight to Puma or another Rails server, or your proxy can be bypassed. Then a client can send `X-Forwarded-For: 203.0.113.7` and `request.remote_ip` answers `203.0.113.7`, passing an allowlist that contains it. Resolve the TCP peer instead, which a client cannot choose:
|
|
1419
|
+
|
|
1420
|
+
```ruby
|
|
1421
|
+
# config/initializers/api_keys.rb
|
|
1422
|
+
config.client_ip_resolver = ->(request) { request.remote_addr }
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
**`trusted_proxies` alone is not a boundary.** It tells Rails which hops to skip; it does not stop a client that reaches Rails directly from sending the header. If your proxy can be bypassed, close that path at the network layer (firewall or security group) or use the socket-peer resolver.
|
|
1426
|
+
|
|
1427
|
+
The test suite exercises both shapes through Rails' real `ActionDispatch::RemoteIp` middleware.
|
|
1393
1428
|
|
|
1394
1429
|
Only read a vendor header directly when your network ingress rejects requests that did not come through that vendor. Otherwise a client can send the same header and choose the address your allowlist sees:
|
|
1395
1430
|
|
|
@@ -1407,7 +1442,7 @@ The mounted dashboard renders only the expiration and request-restriction fields
|
|
|
1407
1442
|
### Security notes
|
|
1408
1443
|
|
|
1409
1444
|
- `Origin` and `Referer` are **browser-enforced** headers. They are trustworthy coming from a real browser and trivially forged by `curl`. Origin restrictions are a browser-context control: they stop a lifted public key from working on someone else's *website*. They are not secrecy. Pair them with keys that cannot spend anything dangerous.
|
|
1410
|
-
- IP restrictions inherit the truthfulness of their resolver.
|
|
1445
|
+
- IP restrictions inherit the truthfulness of their resolver. Behind a proxy that is the only way in, keep `request.remote_ip` and list the proxy in `trusted_proxies`. If Rails is reachable directly, resolve `request.remote_addr`, or callers can choose the address being checked with `X-Forwarded-For` (see [Resolving the client IP](#resolving-the-client-ip)). Only trust a CDN-supplied header when direct access to the origin is blocked.
|
|
1411
1446
|
- Everything fails closed: a locked list plus an unreadable request context, an unknown policy kind, or malformed stored policy data is a refusal, never a pass.
|
|
1412
1447
|
- Refusals never echo the configured allowlist back to the caller. Reflecting your domains to an unauthenticated attacker would be a reconnaissance gift. If you want a more explicit message, override it through i18n (`api_keys.errors.origin_not_allowed`).
|
|
1413
1448
|
- Restriction checks read the current database row on every request, cache or no cache. Tightening the origins of a leaked publishable key takes effect on the very next call.
|
data/SECURITY.md
CHANGED
|
@@ -23,11 +23,30 @@ Do not open a public issue for an undisclosed vulnerability. We will acknowledge
|
|
|
23
23
|
|
|
24
24
|
Applications remain responsible for:
|
|
25
25
|
|
|
26
|
-
- trusted TLS/proxy configuration, endpoint authorization, and request rate limiting;
|
|
27
|
-
- encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff;
|
|
26
|
+
- trusted TLS/proxy configuration, including a `client_ip_resolver` that matches how clients reach the Rails server (see below), endpoint authorization, and request rate limiting;
|
|
27
|
+
- encrypted, `Secure`, `HttpOnly`, appropriately `SameSite` session cookies for the one-time dashboard token handoff, and a shared cache store if that handoff must be one-time across copies of a session (see below);
|
|
28
28
|
- database, cache, queue, log, backup, and observability access controls;
|
|
29
29
|
- filtering any query-parameter credential name from application and proxy logs if that opt-in transport is enabled;
|
|
30
30
|
- classifying every permission on a `public: true` key type as safe for an untrusted public client and maintaining a disable/replacement procedure for non-revocable identifiers; and
|
|
31
31
|
- prompt dependency/runtime patching and a durable background-job backend where callbacks or usage statistics are required.
|
|
32
32
|
|
|
33
33
|
The gem bounds authentication work, but applications should still rate-limit credential endpoints. Do not treat a key prefix, last four characters, cache entry, or public key as proof of authorization.
|
|
34
|
+
|
|
35
|
+
### Client IP restrictions depend on your deployment
|
|
36
|
+
|
|
37
|
+
`allowed_ips` is checked against `config.client_ip_resolver`, which defaults to `request.remote_ip`. Rails derives that value from `X-Forwarded-For`, so it is only truthful in one of two shapes:
|
|
38
|
+
|
|
39
|
+
- **Behind a proxy that is the only way in.** The proxy overwrites `X-Forwarded-For` or appends the address it saw, and its addresses are covered by `config.action_dispatch.trusted_proxies`. Keep the default resolver.
|
|
40
|
+
- **Directly reachable.** If clients can connect to the Rails server itself, a client can send `X-Forwarded-For: <an allowed address>` and pass the check. Set `config.client_ip_resolver = ->(request) { request.remote_addr }` so the socket peer is checked.
|
|
41
|
+
|
|
42
|
+
Configuring `trusted_proxies` alone is not a boundary: it tells Rails which hops to skip, and does nothing about a client that bypasses the proxy. Block that path at the network layer or use the socket-peer resolver. The gem does not change the default, because the right answer depends on a network it cannot see.
|
|
43
|
+
|
|
44
|
+
### What the one-time token handoff guarantees
|
|
45
|
+
|
|
46
|
+
The dashboard shows a new secret key once, through an encrypted session handoff that expires after ten minutes and is deleted on first retrieval. Deletion covers only the session that retrieved it. From 0.5.1 the first retrieval also records the handoff's random ID in `Rails.cache` with an atomic `write(..., unless_exist: true)`, so a copy of the session taken before retrieval (a copied cookie, or concurrent requests) gets nothing while the cache keeps that record.
|
|
47
|
+
|
|
48
|
+
- That holds only when both conditions are met: every application process shares a cache store that implements `unless_exist:` atomically, such as Redis or Memcached, and the store retains the record for the ten-minute handoff lifetime. The record exists only in the cache, so a flush, a restart without persistence, or an eviction within that window lets a copy of the session taken before retrieval recover the token again until the handoff expires. Durable consumption state (a database column) is the stronger design and is planned for 0.6.0.
|
|
49
|
+
- `MemoryStore` covers one process; `FileStore` stops later replays on one host but not simultaneous requests. Solid Cache (Rails 8's default production store) implements `unless_exist:` as `SELECT … FOR UPDATE` then an upsert, which on PostgreSQL locks nothing while the key is absent, so two simultaneous first retrievals can both succeed; sequential replays are still stopped.
|
|
50
|
+
- With `ActiveSupport::Cache::NullStore`, or no `Rails.cache`, the gem cannot record anything and the session deletion is the only protection, as before 0.5.1.
|
|
51
|
+
- If the cache raises or refuses the write, the token is withheld rather than risk a second release.
|
|
52
|
+
- It does not protect a stolen session: whoever holds the owner's session cookie can use the dashboard as that owner.
|
|
@@ -4,7 +4,8 @@ module ApiKeys
|
|
|
4
4
|
# Controller for managing API keys belonging to the current owner.
|
|
5
5
|
class KeysController < ApplicationController
|
|
6
6
|
before_action :set_api_key, only: [:show, :edit, :update, :revoke]
|
|
7
|
-
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds
|
|
7
|
+
helper_method :key_types_feature_enabled?, :api_keys_allowed_restriction_kinds,
|
|
8
|
+
:dashboard_scope_choices, :restriction_field_text
|
|
8
9
|
|
|
9
10
|
# GET /keys
|
|
10
11
|
def index
|
|
@@ -54,6 +55,9 @@ module ApiKeys
|
|
|
54
55
|
# POST /keys
|
|
55
56
|
def create
|
|
56
57
|
submitted_params = api_key_params
|
|
58
|
+
# If creation fails, the re-rendered form shows these instead of
|
|
59
|
+
# re-checking every scope the owner had cleared.
|
|
60
|
+
@submitted_scopes = submitted_params[:scopes]
|
|
57
61
|
|
|
58
62
|
# Use the HasApiKeys helper method to create the key
|
|
59
63
|
begin
|
|
@@ -100,7 +104,17 @@ module ApiKeys
|
|
|
100
104
|
|
|
101
105
|
# PATCH/PUT /keys/:id
|
|
102
106
|
def update
|
|
103
|
-
|
|
107
|
+
attributes = api_key_update_params
|
|
108
|
+
if attributes.key?(:scopes)
|
|
109
|
+
attributes[:scopes] = scopes_after_editing_offered_choices(attributes[:scopes])
|
|
110
|
+
elsif held_scopes_within_ceiling != Array(@api_key.scopes)
|
|
111
|
+
# No scope boxes were shown, but the key still holds a scope its type
|
|
112
|
+
# no longer allows; without dropping it no edit could ever be saved.
|
|
113
|
+
attributes[:scopes] = held_scopes_within_ceiling
|
|
114
|
+
end
|
|
115
|
+
skip_untouched_restriction_fields!(attributes)
|
|
116
|
+
|
|
117
|
+
if @api_key.update(attributes)
|
|
104
118
|
redirect_to keys_path, notice: "API key updated successfully."
|
|
105
119
|
else
|
|
106
120
|
flash.now[:alert] = "Failed to update API key: #{@api_key.errors.full_messages.join(', ')}"
|
|
@@ -139,10 +153,91 @@ module ApiKeys
|
|
|
139
153
|
|
|
140
154
|
permitted_params = submitted.permit(:name, :expires_at_preset, :key_type,
|
|
141
155
|
:allowed_origins, :allowed_ips, scopes: [])
|
|
142
|
-
|
|
156
|
+
normalize_submitted_scopes!(permitted_params)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Each scope checkbox group renders an empty sentinel (`scopes[]=""`), so a
|
|
160
|
+
# submitted form always carries the parameter. Stripping blanks turns the
|
|
161
|
+
# sentinel alone into `[]`: the owner cleared every box and means "none",
|
|
162
|
+
# and the model's validations decide whether that is allowed. Only an
|
|
163
|
+
# absent parameter (`nil`) means "not specified": create_api_key! then
|
|
164
|
+
# applies the default scopes and update leaves the current ones alone.
|
|
165
|
+
def normalize_submitted_scopes!(permitted_params)
|
|
166
|
+
permitted_params[:scopes]&.reject!(&:blank?)
|
|
143
167
|
permitted_params
|
|
144
168
|
end
|
|
145
169
|
|
|
170
|
+
# The scopes the dashboard offers as checkboxes: the owner's configured
|
|
171
|
+
# default scopes. The form renders exactly this list, and an update edits
|
|
172
|
+
# only scopes in it.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array<String>]
|
|
175
|
+
def dashboard_scope_choices
|
|
176
|
+
Array(current_api_keys_owner.class.api_keys_settings.dig(:default_scopes))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The edit form can only speak for the scopes it showed. Checked choices
|
|
180
|
+
# are granted and unchecked ones removed; any other scope the key holds
|
|
181
|
+
# (for example one granted programmatically, or a type permission outside
|
|
182
|
+
# the owner's defaults) is kept, in its current order. A submitted value
|
|
183
|
+
# that was never offered is ignored.
|
|
184
|
+
#
|
|
185
|
+
# A held scope outside the key's current ceiling is dropped, whether
|
|
186
|
+
# offered or not: authentication already denies it, so no access changes,
|
|
187
|
+
# and keeping it would make every save of the key (a rename, or tightening
|
|
188
|
+
# a non-revocable key's origins) fail the ceiling validation. A newly
|
|
189
|
+
# checked scope outside the ceiling is kept, so validation reports it.
|
|
190
|
+
#
|
|
191
|
+
# @param submitted [Array<String>] Checked boxes, sentinel already stripped.
|
|
192
|
+
# @return [Array<String>]
|
|
193
|
+
def scopes_after_editing_offered_choices(submitted)
|
|
194
|
+
offered = dashboard_scope_choices
|
|
195
|
+
held = Array(@api_key.scopes)
|
|
196
|
+
kept = held_scopes_within_ceiling.select { |scope| !offered.include?(scope) || submitted.include?(scope) }
|
|
197
|
+
added = offered.select { |scope| submitted.include?(scope) && !held.include?(scope) }
|
|
198
|
+
kept + added
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The key's scopes without those its current ceiling excludes.
|
|
202
|
+
#
|
|
203
|
+
# @return [Array<String>]
|
|
204
|
+
def held_scopes_within_ceiling
|
|
205
|
+
Array(@api_key.scopes).select { |scope| @api_key.scope_within_ceiling?(scope) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The text the form puts in a restriction field. Usable entries appear as
|
|
209
|
+
# they are stored; anything else a stored policy holds (null, false, a
|
|
210
|
+
# number, a blank string) is shown as JSON, so the owner can see it.
|
|
211
|
+
#
|
|
212
|
+
# @return [String]
|
|
213
|
+
def restriction_field_text(api_key, kind)
|
|
214
|
+
api_key.restrictions.public_send(kind).map do |entry|
|
|
215
|
+
if entry.is_a?(String) && entry.valid_encoding? && !entry.strip.empty?
|
|
216
|
+
entry
|
|
217
|
+
else
|
|
218
|
+
begin
|
|
219
|
+
JSON.generate(entry)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
entry.inspect
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end.join(", ")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A restriction field submitted exactly as it was rendered was not
|
|
228
|
+
# touched, so its kind is left alone. Re-parsing the text would rewrite
|
|
229
|
+
# the stored list, and a malformed stored entry could come back as an
|
|
230
|
+
# empty (unrestricted) list or as a different, valid one. Left alone, a
|
|
231
|
+
# malformed stored policy keeps failing validation, so the save is refused
|
|
232
|
+
# until the owner corrects the field.
|
|
233
|
+
def skip_untouched_restriction_fields!(attributes)
|
|
234
|
+
{ allowed_origins: :origins, allowed_ips: :ips }.each do |field, kind|
|
|
235
|
+
next unless attributes.key?(field)
|
|
236
|
+
|
|
237
|
+
attributes.delete(field) if attributes[field] == restriction_field_text(@api_key, kind)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
146
241
|
# Only allow updating name, scopes, and request restrictions.
|
|
147
242
|
# Restriction edits stay available on non-revocable keys so an owner can
|
|
148
243
|
# still tighten the policy even when lifecycle operations are disabled.
|
|
@@ -151,7 +246,7 @@ module ApiKeys
|
|
|
151
246
|
raise ActionController::ParameterMissing, :api_key unless submitted.respond_to?(:permit)
|
|
152
247
|
|
|
153
248
|
permitted_params = submitted.permit(:name, :allowed_origins, :allowed_ips, scopes: [])
|
|
154
|
-
|
|
249
|
+
normalize_submitted_scopes!(permitted_params)
|
|
155
250
|
permitted_params.delete(:allowed_origins) unless ApiKeys::ApiKey.restrictions_column?
|
|
156
251
|
permitted_params.delete(:allowed_ips) unless ApiKeys::ApiKey.restrictions_column?
|
|
157
252
|
permitted_params
|
|
@@ -64,20 +64,26 @@
|
|
|
64
64
|
%>
|
|
65
65
|
</div>
|
|
66
66
|
|
|
67
|
-
<%#
|
|
68
|
-
<%#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<% @available_scopes = current_api_keys_owner.class.api_keys_settings.dig(:default_scopes) %>
|
|
72
|
-
<% if defined?(@available_scopes) && @available_scopes.present? %>
|
|
67
|
+
<%# The offered scopes are the owner's default scopes. The controller reads %>
|
|
68
|
+
<%# the same list (dashboard_scope_choices) when it saves the form. %>
|
|
69
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
70
|
+
<% if @available_scopes.present? %>
|
|
73
71
|
<div>
|
|
74
72
|
<%= form.label :scopes, "Permissions" %>
|
|
73
|
+
<%# Browsers omit unchecked boxes, so clearing every box would send no scopes %>
|
|
74
|
+
<%# parameter at all and the key would silently get the defaults. This empty %>
|
|
75
|
+
<%# sentinel makes "none" explicit; blank values are stripped server-side. %>
|
|
76
|
+
<%# (Scopes are configured here, so an empty list grants nothing.) %>
|
|
77
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
78
|
+
<%# A fresh form starts with every scope checked; after a failed submission %>
|
|
79
|
+
<%# it shows exactly what was submitted, so a cleared box stays cleared. %>
|
|
80
|
+
<% submitted_scopes = @submitted_scopes %>
|
|
75
81
|
<% @available_scopes.each do |scope| %>
|
|
76
82
|
<div class="form-check">
|
|
77
83
|
<%= form.check_box :scopes,
|
|
78
84
|
{ multiple: true, # Submits as an array
|
|
79
85
|
class: "form-check-input",
|
|
80
|
-
checked:
|
|
86
|
+
checked: submitted_scopes.nil? || submitted_scopes.include?(scope),
|
|
81
87
|
id: "api_key_scopes_#{scope.parameterize}" }, # Unique ID
|
|
82
88
|
scope, # Value submitted when checked
|
|
83
89
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -99,19 +105,35 @@
|
|
|
99
105
|
<%= form.text_field :name %>
|
|
100
106
|
</div>
|
|
101
107
|
|
|
102
|
-
<%#
|
|
103
|
-
<%#
|
|
104
|
-
<%#
|
|
105
|
-
<% @available_scopes =
|
|
106
|
-
<%
|
|
108
|
+
<%# The edit form offers the owner's default scopes and edits only those: %>
|
|
109
|
+
<%# saving keeps every other scope the key holds, except scopes its type no %>
|
|
110
|
+
<%# longer allows (see the controller's scopes_after_editing_offered_choices). %>
|
|
111
|
+
<% @available_scopes = dashboard_scope_choices %>
|
|
112
|
+
<% dropped_scopes = Array(api_key.scopes).reject { |scope| api_key.scope_within_ceiling?(scope) } %>
|
|
113
|
+
<% if dropped_scopes.any? %>
|
|
114
|
+
<small class="api-keys-form-help">
|
|
115
|
+
<%= safe_join(dropped_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>
|
|
116
|
+
<%= dropped_scopes.one? ? "is" : "are" %> no longer allowed for this key and will be removed when you save.
|
|
117
|
+
</small>
|
|
118
|
+
<% end %>
|
|
119
|
+
<% if @available_scopes.present? %>
|
|
107
120
|
<div>
|
|
108
121
|
<%= form.label :scopes, "Permissions / Scopes" %>
|
|
122
|
+
<%# Empty sentinel: clearing every box must remove every offered scope, not keep the old ones. %>
|
|
123
|
+
<%= hidden_field_tag "#{form.object_name}[scopes][]", "", id: nil if api_key.scopes_enforced? %>
|
|
124
|
+
<% kept_scopes = Array(api_key.scopes) - @available_scopes - dropped_scopes %>
|
|
125
|
+
<% if kept_scopes.any? %>
|
|
126
|
+
<small class="api-keys-form-help">
|
|
127
|
+
This key also holds <%= safe_join(kept_scopes.map { |scope| content_tag(:code, scope) }, ", ") %>,
|
|
128
|
+
which this form does not change.
|
|
129
|
+
</small>
|
|
130
|
+
<% end %>
|
|
109
131
|
<% @available_scopes.each do |scope| %>
|
|
110
132
|
<div class="form-check">
|
|
111
133
|
<%= form.check_box :scopes,
|
|
112
134
|
{ multiple: true, # Submits as an array
|
|
113
135
|
class: "form-check-input",
|
|
114
|
-
checked: api_key.scopes&.include?(scope)
|
|
136
|
+
checked: api_key.scopes&.include?(scope) && !dropped_scopes.include?(scope),
|
|
115
137
|
id: "api_key_scopes_#{scope.parameterize}_edit" }, # Unique ID for edit form
|
|
116
138
|
scope, # Value submitted when checked
|
|
117
139
|
nil # Value submitted when unchecked (not needed here)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<%# Optional request restriction fields: where a key may be used from. %>
|
|
2
2
|
<%# Locals: form (required), api_key (required) %>
|
|
3
3
|
<%# Only rendered when the restrictions column exists and the key's type allows the kind. %>
|
|
4
|
+
<%# Values come from restriction_field_text: unusable stored entries (null, false, numbers, %>
|
|
5
|
+
<%# blanks) show as JSON, and an untouched field leaves the stored list as it is on save. %>
|
|
4
6
|
|
|
5
7
|
<% if ApiKeys::ApiKey.restrictions_column? %>
|
|
6
8
|
<% allowed_kinds = api_keys_allowed_restriction_kinds(api_key) %>
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
<div data-api-keys-restriction-kind="origins">
|
|
10
12
|
<%= form.label :allowed_origins, "Allowed web origins (optional)" %>
|
|
11
13
|
<%= form.text_field :allowed_origins,
|
|
12
|
-
value: api_key
|
|
14
|
+
value: restriction_field_text(api_key, :origins),
|
|
13
15
|
placeholder: "example.com, *.example.com" %>
|
|
14
16
|
<small class="api-keys-form-help">
|
|
15
17
|
Leave empty to allow any origin. Requests from a browser must come from one of these hosts.
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
<div data-api-keys-restriction-kind="ips">
|
|
23
25
|
<%= form.label :allowed_ips, "Allowed IP addresses (optional)" %>
|
|
24
26
|
<%= form.text_field :allowed_ips,
|
|
25
|
-
value: api_key
|
|
27
|
+
value: restriction_field_text(api_key, :ips),
|
|
26
28
|
placeholder: "203.0.113.7, 10.0.0.0/8" %>
|
|
27
29
|
<small class="api-keys-form-help">
|
|
28
30
|
Leave empty to allow any address. Accepts single IPv4/IPv6 addresses and CIDR ranges.
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
<li><strong>Assume anyone can copy and abuse them</strong> from browser JavaScript, mobile apps, or public code</li>
|
|
25
25
|
<li><strong>Grant only public-safe operations</strong> and enforce authorization server-side for every permission</li>
|
|
26
26
|
<li><strong>Always visible</strong> — you can view the full key anytime in your dashboard</li>
|
|
27
|
-
<li><strong>
|
|
27
|
+
<li><strong>Revoke and replace a leaked key</strong> — publishable keys can be revoked like any other key, unless their key type is configured with <code>revocable: false</code>; keep an application-level disable/replacement procedure for those</li>
|
|
28
28
|
</ul>
|
|
29
29
|
</section>
|
|
30
30
|
<% end %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Adversarial security review of api_keys 0.5.0 (Codex Astra, 2026-10-18, at main ab6cc19)
|
|
2
|
+
|
|
3
|
+
Scope: `lib/`, the dashboard under `app/`, generators and migration templates, SECURITY.md and the README's security claims, read-only, against the threat model in the prompt (an unauthenticated client, a low-privilege or other-tenant key holder, a timing observer, a misconfigured host app). Verdict: BLOCK; every finding below was answered in 0.5.1 (PR #64), see 02 and 03.
|
|
4
|
+
|
|
5
|
+
Reviewed `ab6cc19` read-only, including all requested source files. Confirmed the principal edge cases with isolated, in-memory Ruby checks; did not run the full suite.
|
|
6
|
+
|
|
7
|
+
## Blocking
|
|
8
|
+
|
|
9
|
+
1. **IP restrictions are bypassable when Rails is directly reachable without a sanitizing proxy.** The default resolver trusts `request.remote_ip` ([configuration.rb:597](lib/api_keys/configuration.rb:597)), and authentication accepts its result ([authenticator.rb:479](lib/api_keys/services/authenticator.rb:479)).
|
|
10
|
+
|
|
11
|
+
**Attack:** Hold a valid key restricted to `203.0.113.7`; connect from a disallowed address; send `X-Forwarded-For: 203.0.113.7`. In the reproduction, adding that header changed authentication from `ip_not_allowed` to success. This is deployment-dependent and falls within the stated misconfiguration threat model. Rails explicitly documents this behavior in [RemoteIp](https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html).
|
|
12
|
+
|
|
13
|
+
**Fix:** Document and test direct-server deployments explicitly. Without a proxy, resolve the socket peer or strip forwarding headers before Rails. Behind a proxy, prevent direct access and sanitize forwarding headers; configuring `trusted_proxies` alone does not establish that boundary.
|
|
14
|
+
|
|
15
|
+
2. **Clearing every dashboard scope silently preserves permissions.** Both checkbox groups suppress unchecked values ([form:77](app/views/api_keys/keys/_form.html.erb:77), [form:111](app/views/api_keys/keys/_form.html.erb:111)). Consequently, clearing everything submits no `scopes` parameter. Updating leaves existing scopes untouched ([keys_controller.rb:103](app/controllers/api_keys/keys_controller.rb:103)); creation substitutes default scopes ([has_api_keys.rb:283](lib/api_keys/models/concerns/has_api_keys.rb:283)).
|
|
16
|
+
|
|
17
|
+
**Attack:** A client holds a read-scoped key. Its owner unchecks every permission and saves successfully. The client continues accessing read-protected endpoints. Creating a supposedly permissionless key similarly grants defaults.
|
|
18
|
+
|
|
19
|
+
**Fix:** Submit an explicit empty-array sentinel and normalize it to `[]`. Test the actual rendered form submission.
|
|
20
|
+
|
|
21
|
+
## Should fix
|
|
22
|
+
|
|
23
|
+
- **Malformed restriction values can become unrestricted.** `filter_map` drops `false` and `nil` rather than preserving them for validation ([restrictions.rb:140](lib/api_keys/restrictions.rb:140), [restrictions.rb:205](lib/api_keys/restrictions.rb:205)). Verified that `{"ips":false}`, `{"ips":[false]}`, and `{"ips":[null]}` become unrestricted; `key.update!(allowed_ips: false)` persists `{}`. The same applies to origins. This contradicts [CHANGELOG.md:21](CHANGELOG.md:21). Preserve invalid entries and reject them; distinguish an intentionally empty list from malformed data.
|
|
24
|
+
|
|
25
|
+
- **Policy changes can prevent revocation of an active key.** `revoke!` performs a fully validated update ([api_key.rb:215](lib/api_keys/models/api_key.rb:215)). After reducing a ceiling from `read,write` to `read`, an existing `read,write` key fails revocation validation ([api_key.rb:652](lib/api_keys/models/api_key.rb:652)) while remaining active and authorized for `read` ([api_key.rb:349](lib/api_keys/models/api_key.rb:349)). Confirmed in memory. Make revocation an atomic operation independent of unrelated validations, retaining the explicit revocability guard.
|
|
26
|
+
|
|
27
|
+
- **The token handoff is replayable, despite the one-time claim.** Retrieval only deletes the payload from the supplied session ([token_session.rb:89](lib/api_keys/helpers/token_session.rb:89)). Replaying a pre-consumption CookieStore cookie—or making concurrent requests with it—can recover the same token during its ten-minute lifetime. A copied-session reproduction returned the token twice. This contradicts the reused-handoff guarantee at [README.md:473](README.md:473); it does not bypass dashboard ownership. Use an atomic server-side consume operation for a genuine one-time guarantee, or qualify the documentation.
|
|
28
|
+
|
|
29
|
+
- **Header parsing precedes encoding and size validation.** The Bearer regex executes at [authenticator.rb:129](lib/api_keys/services/authenticator.rb:129), before the checks at [authenticator.rb:345](lib/api_keys/services/authenticator.rb:345). An invalid UTF-8 header string raises an uncaught `ArgumentError`; oversized headers are scanned before rejection. Validate the raw header first and return a controlled credential failure. Internet reachability of the encoding case depends on the server/middleware’s string handling.
|
|
30
|
+
|
|
31
|
+
## Nits
|
|
32
|
+
|
|
33
|
+
- The dashboard security page says publishable keys cannot be revoked individually ([best_practices:27](app/views/api_keys/security/best_practices.html.erb:27)), although revocability defaults to true ([api_key.rb:402](lib/api_keys/models/api_key.rb:402)).
|
|
34
|
+
- The prefix table still promises untyped creation when key types are configured ([README.md:942](README.md:942)); creation now requires a supplied or default type ([has_api_keys.rb:257](lib/api_keys/models/concerns/has_api_keys.rb:257)).
|
|
35
|
+
|
|
36
|
+
## Missing tests
|
|
37
|
+
|
|
38
|
+
- **Real proxy resolution:** restriction tests supply an already-resolved fake `remote_ip`, missing forwarding-header spoofing and proxy-chain behavior ([request_restrictions_test.rb:12](test/request_restrictions_test.rb:12)).
|
|
39
|
+
- **Dashboard abuse:** actual all-unchecked form submissions; CSRF rejection with protection enabled; cross-owner update/revoke attempts. Current tests disable CSRF and exercise cross-owner show only ([keys_controller_test.rb:50](test/controllers/keys_controller_test.rb:50), [keys_controller_test.rb:330](test/controllers/keys_controller_test.rb:330)).
|
|
40
|
+
- **Malformed restriction matrices:** false/null scalars and array members. Existing non-string tests use integers ([restrictions_test.rb:344](test/restrictions_test.rb:344)).
|
|
41
|
+
- **Handoff replay/concurrency and browser history restoration:** current retrieval tests mutate one Hash ([token_session_test.rb:61](test/helpers/token_session_test.rb:61)).
|
|
42
|
+
- **Revocation after ceiling changes and malformed header encodings:** existing tests cover ordinary revocation and oversized tokens separately ([api_key_test.rb:486](test/models/api_key_test.rb:486), [authenticator_test.rb:272](test/services/authenticator_test.rb:272)).
|
|
43
|
+
- **Production database behavior:** quota concurrency, generated constraints, and authentication-index execution on PostgreSQL/MySQL. The suite uses an independently defined in-memory SQLite schema ([test_helper.rb:51](test/test_helper.rb:51)).
|
|
44
|
+
|
|
45
|
+
## Blue team
|
|
46
|
+
|
|
47
|
+
- **Enforce tenant and role authorization yourself.** Tenant resolution returns an object or `nil`; it does not compare against a requested tenant ([tenant_resolution.rb:26](lib/api_keys/tenant_resolution.rb:26)). Every dashboard owner can select every configured key type ([form:23](app/views/api_keys/keys/_form.html.erb:23)). Gate dashboard membership and type issuance appropriately, and scope resource queries to the authenticated owner.
|
|
48
|
+
- **Treat origins as browser-context restrictions.** Non-browser clients can forge them, as acknowledged at [README.md:1409](README.md:1409). Public permissions must remain safe under arbitrary replay.
|
|
49
|
+
- **Rate-limit before authentication and queue insertion.** Bcrypt guesses matching a prefix/last-four bucket trigger expensive comparisons, exposing bucket existence through timing ([authenticator.rb:218](lib/api_keys/services/authenticator.rb:218)). Distinct revoked/expired responses identify already-known complete tokens, not their missing characters ([authenticator.rb:104](lib/api_keys/services/authenticator.rb:104)).
|
|
50
|
+
- **Do not use callbacks or counters as exactly-once business events.** Attempts enqueue callbacks; statistics debounce depends on a completed database update, so queue lag permits bursts. Duplicate job delivery increments counters again ([authentication.rb:154](lib/api_keys/authentication.rb:154), [authentication.rb:186](lib/api_keys/authentication.rb:186), [update_stats_job.rb:53](lib/api_keys/jobs/update_stats_job.rb:53)). Monitor queue depth and make consequential consumers idempotent.
|
|
51
|
+
- **Apply deployment controls independently:** TLS, protected sessions, credential filtering, and maintained dependencies. Installable dependency floors are broader than security support ([api_keys.gemspec:40](api_keys.gemspec:40), [SECURITY.md:7](SECURITY.md:7)); operational requirements are listed at [SECURITY.md:26](SECURITY.md:26).
|
|
52
|
+
|
|
53
|
+
VERDICT: BLOCK
|
|
54
|
+
|
|
55
|
+
The blocking concerns are authorization behavior at integration boundaries: spoofable client-IP resolution in an explicitly in-scope deployment and a dashboard action that silently fails to remove permissions. The malformed-policy, revocation, and handoff findings also weaken stated security guarantees. Address these with focused regression tests before treating 0.5.0 as ready under this threat model.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Re-review of the 0.5.1 fixes (Codex Astra, 2026-10-18, at f247984, PR #64)
|
|
2
|
+
|
|
3
|
+
Scope: `git diff fd598aa..f247984`, the answer to review 01, read-only. Verdict: BLOCK only on the handoff guarantee being overstated (the consumption marker lives in the cache, so cache loss within the handoff lifetime re-opens replay for a copied cookie), plus one documentation nit; both were applied in the next commit (wording in README, SECURITY.md and the CHANGELOG; a test that pins the cache-loss replay; the blank-member sentence qualified). Durable consumption state is planned for 0.6.0. Every original attack is closed; the client-IP finding is closed by documentation and tests of the deployment shapes, with the default unchanged.
|
|
4
|
+
|
|
5
|
+
## Blocking
|
|
6
|
+
|
|
7
|
+
**Scope:** The requested checkout remains at `fd598aa`; its diff is empty. I reviewed the available candidate `fd598aa..f247984` in `/Users/javi/GitHub/api_keys-security` instead. This was code inspection only; “would fail on 0.5.0” below is a static assessment.
|
|
8
|
+
|
|
9
|
+
- **IP spoofing: documented mitigation, unchanged default.** The real middleware tests perform the original attack and explicitly expect spoofed authentication to succeed; the socket-peer and proxy-chain tests verify the documented deployment configurations. These tests would also pass on 0.5.0: they establish deployment requirements, not changed protection. This satisfies the requested documentation/test remedy, but directly reachable installations must apply it. No new runtime behavior. [request_restrictions_test.rb:852](test/request_restrictions_test.rb:852)
|
|
10
|
+
|
|
11
|
+
- **All-unchecked dashboard scopes: closed.** Tests serialize the rendered form after removing every checkbox’s checked attribute, submit it, and verify permissions disappear on creation and update. Both would fail on 0.5.0. Updates now preserve scopes outside the displayed choices and ignore submitted additions outside those choices; the form explicitly identifies retained scopes. [keys_controller_test.rb:357](test/controllers/keys_controller_test.rb:357), [keys_controller.rb:181](app/controllers/api_keys/keys_controller.rb:181), [_form.html.erb:118](app/views/api_keys/keys/_form.html.erb:118)
|
|
12
|
+
|
|
13
|
+
## Should fix
|
|
14
|
+
|
|
15
|
+
- **Malformed restrictions: original attacks closed.** False/null members survive coercion and fail validation; stored malformed values refuse authentication. Tests exercise both stored corruption and writer updates, including the original false/null cases, and would fail on 0.5.0. Scalar `nil` remains an intentional clearing operation on input. Untouched malformed dashboard fields retain their stored meaning instead of becoming unrestricted. [restrictions.rb:232](lib/api_keys/restrictions.rb:232), [request_restrictions_test.rb:698](test/request_restrictions_test.rb:698), [keys_controller.rb:214](app/controllers/api_keys/keys_controller.rb:214)
|
|
16
|
+
|
|
17
|
+
- **Revocation after policy changes: closed for clean loaded records.** The tests narrow an issued key’s ceiling, establish that validation fails, then revoke it—including through a warm authentication cache. They would fail on 0.5.0. The revocability guard and save callbacks remain; instances carrying other pending edits still validate and can fail revocation. That limitation is explicit and tested, rather than silently saving those edits unvalidated. [api_key.rb:232](lib/api_keys/models/api_key.rb:232), [authenticator_test.rb:319](test/services/authenticator_test.rb:319), [api_key_test.rb:553](test/models/api_key_test.rb:553)
|
|
18
|
+
|
|
19
|
+
- **Handoff replay: improved, but the guarantee remains overstated.** Copied-session and threaded tests perform the attack and would fail on 0.5.0. However, consumption exists only as a cache marker: consume once, clear the cache, then replay the still-valid copied session, and the write succeeds again. Shared, atomic storage alone therefore does not establish the README’s “at most once” guarantee; marker retention is also required. Document cache-loss replay or use durable consumption state. Cache failure now withholds the token—a deliberate availability change. [token_session.rb:202](lib/api_keys/helpers/token_session.rb:202), [token_session_test.rb:215](test/helpers/token_session_test.rb:215), [README.md:481](README.md:481)
|
|
20
|
+
|
|
21
|
+
- **Header parsing: original attacks closed.** Raw size/encoding checks precede the regex. Tests supply invalid UTF-8 and explicitly forbid matching an oversized header; both would fail on 0.5.0. The new compatibility boundary is a 576-byte complete header limit, including Bearer whitespace. [authenticator.rb:138](lib/api_keys/services/authenticator.rb:138), [authenticator_test.rb:350](test/services/authenticator_test.rb:350)
|
|
22
|
+
|
|
23
|
+
## Nits
|
|
24
|
+
|
|
25
|
+
- Both previous documentation errors are corrected. [best_practices.html.erb:27](app/views/api_keys/security/best_practices.html.erb:27), [README.md:961](README.md:961)
|
|
26
|
+
- The new claim that blank array members fail validation is too broad: the writer test explicitly accepts `allowed_ips: [""]` and clears restrictions. Qualify the input-path distinction. [README.md:1386](README.md:1386), [request_restrictions_test.rb:768](test/request_restrictions_test.rb:768)
|
|
27
|
+
|
|
28
|
+
## Missing tests
|
|
29
|
+
|
|
30
|
+
- Add consumption-marker loss followed by replay. Current concurrency coverage uses one process and `MemoryStore`, not a production shared backend. [token_session_test.rb:225](test/helpers/token_session_test.rb:225)
|
|
31
|
+
- CSRF rejection and cross-owner writes are now meaningfully covered; these establish existing protections and should pass on 0.5.0. Browser-history restoration remains uncovered by the controller replay test. [keys_controller_test.rb:562](test/controllers/keys_controller_test.rb:562), [keys_controller_test.rb:596](test/controllers/keys_controller_test.rb:596), [keys_controller_test.rb:638](test/controllers/keys_controller_test.rb:638)
|
|
32
|
+
- PostgreSQL/MySQL concurrency, migration constraints, and index execution remain unverified; the suite still defines an in-memory SQLite schema. [test_helper.rb:51](test/test_helper.rb:51)
|
|
33
|
+
|
|
34
|
+
VERDICT: BLOCK
|
|
35
|
+
|
|
36
|
+
The candidate substantially addresses the original review, but I would correct the remaining handoff guarantee and add cache-loss replay coverage before publishing it as the security-fix release. The documented IP configuration remains mandatory. This assessment covers `f247984`, not the requested merged `main`, which currently contains none of these fixes.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Independent review of the 0.5.1 fixes (Claude Opus 5.5, 2026-10-18, at 0a3183b then f247984, PR #64)
|
|
2
|
+
|
|
3
|
+
Scope: the branch against Astra's findings (01), with the suite run and the attack tests checked against the 0.5.0 sources (35 of the 53 added tests fail there). Verdict: PASS, with four should-fix items that were applied in the second commit: dashboard edits change only the scopes the form offered and keep the rest (a rename no longer strips a scope the owner's defaults do not list); nil and blank restriction inputs clear a kind as the list writers do, while stored rows stay strictly read; the form builder's sentinel takes an explicit `include_hidden:` override and the README says per-owner defaults need a key built from its owner; Solid Cache's `unless_exist` (SELECT FOR UPDATE then upsert, which locks nothing for an absent key on PostgreSQL) is named next to FileStore. Nits applied: a stored malformed policy round-trips through the edit form as JSON and refuses to save until corrected; a README line on a cleared key becoming unrestricted if scope configuration is later removed; a README line on a second GET of the token page getting nothing; a test for `available?` when the cache read raises. Noted for 0.6: resolving the client IP through `remote_ip` only when `remote_addr` is a trusted proxy.
|
|
4
|
+
|
|
5
|
+
The review's own text is summarised above; its numbered findings and verdict were delivered in the session transcript rather than as a file.
|
|
@@ -46,12 +46,17 @@ module ApiKeys
|
|
|
46
46
|
#
|
|
47
47
|
# @!attribute [rw] client_ip_resolver
|
|
48
48
|
# @return [#call] Callable receiving the request and returning the client
|
|
49
|
-
# IP address used to evaluate a key's `allowed_ips` list. The default
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
49
|
+
# IP address used to evaluate a key's `allowed_ips` list. The default,
|
|
50
|
+
# `request.remote_ip`, is right only behind a proxy that is the sole way
|
|
51
|
+
# in, rewrites `X-Forwarded-For`, and is covered by
|
|
52
|
+
# `config.action_dispatch.trusted_proxies`. When the Rails server is
|
|
53
|
+
# directly reachable, a client can forge `X-Forwarded-For`; resolve the
|
|
54
|
+
# socket peer with `request.remote_addr` instead. A resolver that trusts
|
|
55
|
+
# a vendor header is safe only when network ingress rejects requests
|
|
56
|
+
# that bypass that vendor.
|
|
57
|
+
# @example Directly reachable server
|
|
58
|
+
# config.client_ip_resolver = ->(request) { request.remote_addr }
|
|
59
|
+
# @example Behind Cloudflare, with direct origin access blocked
|
|
55
60
|
# config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
|
|
56
61
|
attr_reader :client_ip_resolver
|
|
57
62
|
|