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
|
@@ -21,6 +21,8 @@ module ApiKeys
|
|
|
21
21
|
# <%= scope %>
|
|
22
22
|
# </label>
|
|
23
23
|
# <% end %>
|
|
24
|
+
# (The helper prepends an empty `api_key[scopes][]` sentinel so that
|
|
25
|
+
# clearing every box submits "no scopes" instead of nothing.)
|
|
24
26
|
#
|
|
25
27
|
module FormBuilderExtensions
|
|
26
28
|
# Renders a select field for API key expiration presets.
|
|
@@ -62,6 +64,25 @@ module ApiKeys
|
|
|
62
64
|
# - :none - no scopes checked
|
|
63
65
|
# - Array - specific scopes to check
|
|
64
66
|
# - nil - uses the object's current scopes
|
|
67
|
+
# @param include_hidden [Boolean, nil] With a block, whether to render an
|
|
68
|
+
# empty `name="api_key[scopes][]" value=""` sentinel before the boxes.
|
|
69
|
+
# Browsers omit unchecked boxes, so without it clearing every box submits
|
|
70
|
+
# no scopes parameter at all, and a create would silently get the default
|
|
71
|
+
# scopes while an update would keep the old ones. With the sentinel the
|
|
72
|
+
# server receives `[""]`, which `ApiKey#scopes=` and `create_api_key!`
|
|
73
|
+
# read as "no scopes". The submitted list replaces the key's scopes, so
|
|
74
|
+
# render a box for every scope the key may hold.
|
|
75
|
+
# - nil (default): render it when there is at least one box and the
|
|
76
|
+
# form's key enforces its scopes (`ApiKey#scopes_enforced?`). Elsewhere
|
|
77
|
+
# an empty list would mean "unrestricted" and clearing the boxes would
|
|
78
|
+
# widen the key. Per-owner default scopes are read through the key's
|
|
79
|
+
# `owner`, so build the form's key from its owner
|
|
80
|
+
# (`owner.api_keys.build`): a form with no object, or on a bare
|
|
81
|
+
# `ApiKey.new`, cannot see them and renders no sentinel.
|
|
82
|
+
# - true: render it whenever there is at least one box, without asking
|
|
83
|
+
# the key. Use it on those ownerless forms when an empty list grants
|
|
84
|
+
# nothing in your app.
|
|
85
|
+
# - false: never render it.
|
|
65
86
|
# @return [String, Array] HTML if block given, otherwise array of scope data
|
|
66
87
|
#
|
|
67
88
|
# @example With block (recommended for custom styling)
|
|
@@ -72,27 +93,30 @@ module ApiKeys
|
|
|
72
93
|
# </label>
|
|
73
94
|
# <% end %>
|
|
74
95
|
#
|
|
75
|
-
# @example Simple rendering without block
|
|
96
|
+
# @example Simple rendering without block (render the sentinel yourself)
|
|
97
|
+
# <%= hidden_field_tag "api_key[scopes][]", "", id: nil %>
|
|
76
98
|
# <% form.api_key_scopes_checkboxes(@scopes).each do |scope_data| %>
|
|
77
99
|
# <%= check_box_tag "api_key[scopes][]", scope_data[:value], scope_data[:checked] %>
|
|
78
100
|
# <%= scope_data[:value] %>
|
|
79
101
|
# <% end %>
|
|
80
102
|
#
|
|
81
|
-
def api_key_scopes_checkboxes(scopes, checked: nil, &block)
|
|
103
|
+
def api_key_scopes_checkboxes(scopes, checked: nil, include_hidden: nil, &block)
|
|
82
104
|
# Determine which scopes should be checked
|
|
83
105
|
checked_scopes = resolve_checked_scopes(scopes, checked)
|
|
106
|
+
field_name = "#{object_name}[scopes][]"
|
|
84
107
|
|
|
85
108
|
scope_data = scopes.map do |scope|
|
|
86
109
|
{
|
|
87
110
|
value: scope,
|
|
88
111
|
checked: checked_scopes.include?(scope),
|
|
89
|
-
field_name:
|
|
112
|
+
field_name: field_name
|
|
90
113
|
}
|
|
91
114
|
end
|
|
92
115
|
|
|
93
116
|
if block_given?
|
|
94
117
|
# Yield each scope for custom rendering
|
|
95
118
|
safe_buffer = ActiveSupport::SafeBuffer.new
|
|
119
|
+
safe_buffer << @template.hidden_field_tag(field_name, "", id: nil) if render_scope_sentinel?(scopes, include_hidden)
|
|
96
120
|
scope_data.each do |data|
|
|
97
121
|
safe_buffer << @template.capture { yield(data[:value], data[:checked]) }
|
|
98
122
|
end
|
|
@@ -135,6 +159,19 @@ module ApiKeys
|
|
|
135
159
|
|
|
136
160
|
private
|
|
137
161
|
|
|
162
|
+
# The sentinel is only safe where an empty list grants nothing. With no
|
|
163
|
+
# boxes there is nothing to clear, so it is never rendered then. By
|
|
164
|
+
# default it also needs a key that enforces its scopes; where the key does
|
|
165
|
+
# not, an empty list would mean "unrestricted", so the parameter is left
|
|
166
|
+
# out and the key keeps the scopes it has. `include_hidden: true` skips
|
|
167
|
+
# that question for forms whose key cannot answer it.
|
|
168
|
+
def render_scope_sentinel?(scopes, include_hidden)
|
|
169
|
+
return false if include_hidden == false || scopes.empty?
|
|
170
|
+
return true if include_hidden == true
|
|
171
|
+
|
|
172
|
+
object.respond_to?(:scopes_enforced?) && object.scopes_enforced?
|
|
173
|
+
end
|
|
174
|
+
|
|
138
175
|
def safe_key_type(value)
|
|
139
176
|
case value.to_s
|
|
140
177
|
when "publishable" then :publishable
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/cache"
|
|
3
4
|
require "active_support/core_ext/numeric/time"
|
|
4
5
|
require "active_support/message_encryptor"
|
|
6
|
+
require "digest"
|
|
5
7
|
require "json"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require_relative "../logging"
|
|
6
10
|
|
|
7
11
|
module ApiKeys
|
|
8
12
|
module Helpers
|
|
@@ -16,6 +20,18 @@ module ApiKeys
|
|
|
16
20
|
# 2. On the success page, decrypt, retrieve, and clear the token
|
|
17
21
|
# 3. If the user refreshes, the token is gone
|
|
18
22
|
#
|
|
23
|
+
# Deleting the payload from the session only consumes it in that session.
|
|
24
|
+
# A copy of the session taken before retrieval (a copied CookieStore
|
|
25
|
+
# cookie, or two requests racing with the same cookie) would still carry
|
|
26
|
+
# it. So each handoff also has a random ID, and the first retrieval
|
|
27
|
+
# records an atomic consumption marker for that ID in `Rails.cache`
|
|
28
|
+
# (`write(..., unless_exist: true)`). Any later retrieval of the same
|
|
29
|
+
# handoff finds the marker and gets nil. This guarantee needs a cache store
|
|
30
|
+
# that every application process shares and that implements
|
|
31
|
+
# `unless_exist:` atomically. With `ActiveSupport::Cache::NullStore` the
|
|
32
|
+
# marker cannot be recorded, so deleting the payload from the session is
|
|
33
|
+
# the only protection, as it was before 0.5.1.
|
|
34
|
+
#
|
|
19
35
|
# @example In your controller
|
|
20
36
|
# # After creating a key:
|
|
21
37
|
# def create
|
|
@@ -31,13 +47,20 @@ module ApiKeys
|
|
|
31
47
|
# end
|
|
32
48
|
#
|
|
33
49
|
class TokenSession
|
|
50
|
+
extend ApiKeys::Logging
|
|
51
|
+
|
|
34
52
|
# Default session key for storing the token
|
|
35
53
|
DEFAULT_SESSION_KEY = :api_keys_new_token
|
|
36
54
|
MAX_TOKEN_BYTESIZE = 512
|
|
37
55
|
MAX_CIPHERTEXT_BYTESIZE = 4096
|
|
38
56
|
MAX_KEY_ID_BYTESIZE = 128
|
|
57
|
+
# The wire format is unchanged since 0.4.0, so processes on 0.5.0 and
|
|
58
|
+
# 0.5.1 can read each other's handoffs during a rolling deploy. 0.5.1
|
|
59
|
+
# adds a random handoff ID inside the encrypted payload.
|
|
39
60
|
HANDOFF_VERSION = 2
|
|
40
61
|
HANDOFF_TTL = 10.minutes
|
|
62
|
+
HANDOFF_ID_PATTERN = /\A[A-Za-z0-9_-]{22,64}\z/
|
|
63
|
+
CONSUMPTION_CACHE_NAMESPACE = "api_keys:v1:token_handoff_consumed"
|
|
41
64
|
ENCRYPTION_CIPHER = "aes-256-gcm"
|
|
42
65
|
ENCRYPTION_SALT = "api_keys/token_session/v2"
|
|
43
66
|
ENCRYPTION_PURPOSE = "api_keys.token_session"
|
|
@@ -68,7 +91,7 @@ module ApiKeys
|
|
|
68
91
|
|
|
69
92
|
api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
|
|
70
93
|
encrypted_payload = token_encryptor.encrypt_and_sign(
|
|
71
|
-
{ "token" => token, "api_key_id" => api_key_id },
|
|
94
|
+
{ "token" => token, "api_key_id" => api_key_id, "handoff_id" => SecureRandom.urlsafe_base64(24) },
|
|
72
95
|
expires_in: HANDOFF_TTL,
|
|
73
96
|
purpose: ENCRYPTION_PURPOSE
|
|
74
97
|
)
|
|
@@ -81,7 +104,8 @@ module ApiKeys
|
|
|
81
104
|
end
|
|
82
105
|
|
|
83
106
|
# Retrieve and clear the token from the session.
|
|
84
|
-
# Returns nil if no token is stored (e.g., page was refreshed)
|
|
107
|
+
# Returns nil if no token is stored (e.g., page was refreshed), or if
|
|
108
|
+
# this handoff was already retrieved through any copy of the session.
|
|
85
109
|
#
|
|
86
110
|
# @param session [ActionDispatch::Request::Session] The Rails session
|
|
87
111
|
# @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
|
|
@@ -96,6 +120,11 @@ module ApiKeys
|
|
|
96
120
|
decoded_payload = decode_payload(payload)
|
|
97
121
|
return nil unless decoded_payload
|
|
98
122
|
|
|
123
|
+
# Any authentic retrieval attempt uses the handoff up, matching the
|
|
124
|
+
# session deletion above: a mismatched or replayed attempt never
|
|
125
|
+
# leaves the handoff available to a later one.
|
|
126
|
+
return nil unless consume_handoff(decoded_payload)
|
|
127
|
+
|
|
99
128
|
token = decoded_payload["token"] || decoded_payload[:token]
|
|
100
129
|
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
101
130
|
return nil if expected_id && stored_id.to_s != expected_id.to_s
|
|
@@ -121,8 +150,9 @@ module ApiKeys
|
|
|
121
150
|
token = decoded_payload["token"] || decoded_payload[:token]
|
|
122
151
|
stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
|
|
123
152
|
return false if expected_id && stored_id.to_s != expected_id.to_s
|
|
153
|
+
return false unless valid_token_payload?(token)
|
|
124
154
|
|
|
125
|
-
|
|
155
|
+
!handoff_consumed?(decoded_payload)
|
|
126
156
|
end
|
|
127
157
|
|
|
128
158
|
private
|
|
@@ -146,6 +176,11 @@ module ApiKeys
|
|
|
146
176
|
inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
|
|
147
177
|
return nil unless outer_id == inner_id
|
|
148
178
|
|
|
179
|
+
# Handoffs written before 0.5.1 carry no ID; a present ID must be
|
|
180
|
+
# well formed, since it names the consumption marker.
|
|
181
|
+
handoff_id = decoded["handoff_id"]
|
|
182
|
+
return nil unless handoff_id.nil? || (handoff_id.is_a?(String) && handoff_id.match?(HANDOFF_ID_PATTERN))
|
|
183
|
+
|
|
149
184
|
decoded
|
|
150
185
|
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
|
|
151
186
|
nil
|
|
@@ -161,6 +196,60 @@ module ApiKeys
|
|
|
161
196
|
nil
|
|
162
197
|
end
|
|
163
198
|
|
|
199
|
+
# Records the consumption marker. Answers true only to the first
|
|
200
|
+
# consumer of a handoff; every later one, and any cache failure, is
|
|
201
|
+
# treated as "no token" (fail closed).
|
|
202
|
+
def consume_handoff(decoded_payload)
|
|
203
|
+
cache = consumption_cache
|
|
204
|
+
return true unless cache
|
|
205
|
+
|
|
206
|
+
marker = consumption_marker_key(decoded_payload)
|
|
207
|
+
return true unless marker
|
|
208
|
+
|
|
209
|
+
cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL) ? true : false
|
|
210
|
+
rescue StandardError => error
|
|
211
|
+
log_warn "[ApiKeys Security] Token handoff consumption could not be recorded (#{error.class}); withholding the token."
|
|
212
|
+
false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def handoff_consumed?(decoded_payload)
|
|
216
|
+
cache = consumption_cache
|
|
217
|
+
return false unless cache
|
|
218
|
+
|
|
219
|
+
marker = consumption_marker_key(decoded_payload)
|
|
220
|
+
return false unless marker
|
|
221
|
+
|
|
222
|
+
cache.exist?(marker)
|
|
223
|
+
rescue StandardError => error
|
|
224
|
+
log_warn "[ApiKeys Security] Token handoff consumption could not be checked (#{error.class})."
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The cache that can enforce one-time retrieval across copies of a
|
|
229
|
+
# session, or nil when there is none. NullStore accepts every write and
|
|
230
|
+
# remembers nothing, so it cannot enforce anything.
|
|
231
|
+
def consumption_cache
|
|
232
|
+
cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
|
|
233
|
+
return nil if cache.nil? || cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
234
|
+
|
|
235
|
+
cache
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Handoffs written by 0.5.1 are named by their random ID. Handoffs
|
|
239
|
+
# written before it have none; they are named by a domain-separated
|
|
240
|
+
# digest of their token, which the encryption authenticates and which
|
|
241
|
+
# is identical in every copy of the session. (Bare-string handoffs from
|
|
242
|
+
# before 0.4.0 are returned before decoding and have no marker.)
|
|
243
|
+
def consumption_marker_key(decoded_payload)
|
|
244
|
+
handoff_id = decoded_payload["handoff_id"]
|
|
245
|
+
return "#{CONSUMPTION_CACHE_NAMESPACE}:#{handoff_id}" if handoff_id
|
|
246
|
+
|
|
247
|
+
token = decoded_payload["token"]
|
|
248
|
+
return nil unless valid_token_payload?(token)
|
|
249
|
+
|
|
250
|
+
"#{CONSUMPTION_CACHE_NAMESPACE}:token-#{Digest::SHA256.hexdigest("api_keys token handoff\0#{token}")}"
|
|
251
|
+
end
|
|
252
|
+
|
|
164
253
|
def normalize_api_key_id(value)
|
|
165
254
|
return nil if value.nil?
|
|
166
255
|
|
|
@@ -64,16 +64,18 @@ module ApiKeys
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Accepts a Restrictions instance, a hash of lists, or nil. Hashes are
|
|
67
|
-
# normalized into the storage shape;
|
|
68
|
-
#
|
|
67
|
+
# normalized into the storage shape; a kind given as `nil` or blank text
|
|
68
|
+
# is cleared, as the list writers do. Anything malformed is stored
|
|
69
|
+
# untouched so the validation, rather than a silent coercion, reports it.
|
|
69
70
|
def restrictions=(value)
|
|
70
71
|
ensure_restrictions_column!
|
|
71
72
|
|
|
72
73
|
normalized = if value.nil?
|
|
73
74
|
{}
|
|
74
75
|
elsif value.is_a?(Hash)
|
|
75
|
-
|
|
76
|
-
wrapped
|
|
76
|
+
input = ApiKeys::Restrictions.normalize_input(value)
|
|
77
|
+
wrapped = ApiKeys::Restrictions.wrap(input)
|
|
78
|
+
wrapped.malformed? ? input : wrapped.to_h
|
|
77
79
|
elsif value.is_a?(ApiKeys::Restrictions)
|
|
78
80
|
value.malformed? ? { "__malformed__" => true } : value.to_h
|
|
79
81
|
else
|
|
@@ -212,9 +214,30 @@ module ApiKeys
|
|
|
212
214
|
|
|
213
215
|
# == Instance Methods ==
|
|
214
216
|
|
|
217
|
+
# Revocation is the emergency control, so it must not hinge on whether the
|
|
218
|
+
# rest of the row still satisfies today's policy. A key issued before its
|
|
219
|
+
# type's permission or restriction ceiling was narrowed keeps
|
|
220
|
+
# authenticating within the narrower ceiling; refusing to revoke it would
|
|
221
|
+
# leave a credential nobody can switch off.
|
|
222
|
+
#
|
|
223
|
+
# The revocability guard always applies. When the revocation is the only
|
|
224
|
+
# pending change, the row is saved without validations: only `revoked_at`
|
|
225
|
+
# (and `updated_at`) are written, and save callbacks run as before. Other
|
|
226
|
+
# unsaved edits on the same instance are never written unvalidated: in
|
|
227
|
+
# that case the whole save is validated, exactly as before 0.5.1.
|
|
228
|
+
#
|
|
229
|
+
# The authenticator's token cache holds only a key ID and reloads the row
|
|
230
|
+
# on every hit, so the next request is refused as revoked with no cache
|
|
231
|
+
# invalidation needed.
|
|
215
232
|
def revoke!
|
|
216
233
|
raise ApiKeys::Errors::KeyNotRevocableError unless revocable?
|
|
217
|
-
|
|
234
|
+
|
|
235
|
+
self.revoked_at = Time.current
|
|
236
|
+
if persisted? && (changes_to_save.keys - %w[revoked_at]).empty?
|
|
237
|
+
save!(validate: false)
|
|
238
|
+
else
|
|
239
|
+
save!
|
|
240
|
+
end
|
|
218
241
|
end
|
|
219
242
|
|
|
220
243
|
def revoked?
|
|
@@ -350,6 +373,34 @@ module ApiKeys
|
|
|
350
373
|
ceiling == :all || ceiling.include?(required)
|
|
351
374
|
end
|
|
352
375
|
|
|
376
|
+
# Whether this key's scope list is enforced, so an empty list grants no
|
|
377
|
+
# permissions. True for typed keys, whenever `key_types` or the global
|
|
378
|
+
# `default_scopes` are configured, and when the owner model's
|
|
379
|
+
# `has_api_keys` declares `default_scopes`. The last case is read through
|
|
380
|
+
# `owner`, so a key that does not know its owner yet (a bare `ApiKey.new`)
|
|
381
|
+
# cannot see it: build keys from their owner (`owner.api_keys.build`).
|
|
382
|
+
# False otherwise, where an empty list keeps its legacy meaning:
|
|
383
|
+
# unrestricted. Forms use it to decide whether "every box cleared" can be
|
|
384
|
+
# submitted as an empty list without widening the key.
|
|
385
|
+
#
|
|
386
|
+
# @return [Boolean]
|
|
387
|
+
def scopes_enforced?
|
|
388
|
+
scope_policy_enabled?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Whether a scope is inside this key's current permission ceiling: its
|
|
392
|
+
# type's `permissions:`, or the configured scopes for an untyped key.
|
|
393
|
+
# A held scope outside it (left behind when a ceiling was narrowed) is
|
|
394
|
+
# already denied by `allows_scope?` and fails the ceiling validation on
|
|
395
|
+
# the next save.
|
|
396
|
+
#
|
|
397
|
+
# @param scope [String, Symbol]
|
|
398
|
+
# @return [Boolean]
|
|
399
|
+
def scope_within_ceiling?(scope)
|
|
400
|
+
ceiling = permission_ceiling
|
|
401
|
+
ceiling == :all || ceiling.include?(scope.to_s)
|
|
402
|
+
end
|
|
403
|
+
|
|
353
404
|
# Alias for scopes - provides a more user-friendly API that matches
|
|
354
405
|
# the configuration DSL where key types use `permissions` for scope ceiling.
|
|
355
406
|
# Note: We use a method instead of alias_method because `scopes` is defined
|
|
@@ -590,8 +641,6 @@ module ApiKeys
|
|
|
590
641
|
return
|
|
591
642
|
end
|
|
592
643
|
|
|
593
|
-
errors.add(:restrictions, "must contain valid restriction data") if restrictions.malformed? && raw.keys.empty?
|
|
594
|
-
|
|
595
644
|
unknown_kinds = raw.keys.map(&:to_s) - ApiKeys::Restrictions::KIND_NAMES
|
|
596
645
|
if unknown_kinds.any?
|
|
597
646
|
errors.add(:restrictions, "contains unknown restriction kinds: #{unknown_kinds.sort.join(', ')}")
|
|
@@ -599,6 +648,12 @@ module ApiKeys
|
|
|
599
648
|
|
|
600
649
|
validate_restriction_list(:origins) { |entry| ApiKeys::Restrictions.valid_origin_entry?(entry) }
|
|
601
650
|
validate_restriction_list(:ips) { |entry| ApiKeys::Restrictions.valid_ip_entry?(entry) }
|
|
651
|
+
|
|
652
|
+
# Whatever made the policy malformed, it never saves: a policy that
|
|
653
|
+
# authentication would refuse must not be written in the first place.
|
|
654
|
+
if restrictions.malformed? && errors[:restrictions].empty?
|
|
655
|
+
errors.add(:restrictions, "must contain valid restriction data")
|
|
656
|
+
end
|
|
602
657
|
end
|
|
603
658
|
|
|
604
659
|
def validate_restriction_list(kind)
|
|
@@ -386,10 +386,18 @@ module ApiKeys
|
|
|
386
386
|
raise ArgumentError, "restrictions must be a Hash or ApiKeys::Restrictions"
|
|
387
387
|
end
|
|
388
388
|
|
|
389
|
-
|
|
389
|
+
# `nil` or blank text for a kind clears it, as the list writers do.
|
|
390
|
+
# A malformed policy goes to the model as it came, so validation
|
|
391
|
+
# rejects it. Normalizing it first could reduce it to `{}`, which
|
|
392
|
+
# would create the key unrestricted.
|
|
393
|
+
input = ApiKeys::Restrictions.normalize_input(restrictions)
|
|
394
|
+
wrapped = ApiKeys::Restrictions.wrap(input)
|
|
395
|
+
return input if wrapped.malformed?
|
|
396
|
+
|
|
397
|
+
attributes = wrapped.to_h
|
|
390
398
|
attributes["origins"] = ApiKeys::Restrictions.normalize_origins(allowed_origins) unless allowed_origins.nil?
|
|
391
399
|
attributes["ips"] = ApiKeys::Restrictions.normalize_ips(allowed_ips) unless allowed_ips.nil?
|
|
392
|
-
|
|
400
|
+
attributes
|
|
393
401
|
end
|
|
394
402
|
|
|
395
403
|
# True when any configured key type declares a restriction ceiling.
|
|
@@ -55,9 +55,11 @@ module ApiKeys
|
|
|
55
55
|
known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
|
|
56
56
|
known = known.to_h { |key, entries| [key.to_s, entries] }
|
|
57
57
|
|
|
58
|
+
# Only an absent kind means "no list". A kind that is present must
|
|
59
|
+
# hold a list; `"ips" => nil` is as malformed as `"ips" => false`.
|
|
58
60
|
new(
|
|
59
|
-
origins: coerce_list(known["origins"]),
|
|
60
|
-
ips: coerce_list(known["ips"]),
|
|
61
|
+
origins: known.key?("origins") ? coerce_list(known["origins"]) : [],
|
|
62
|
+
ips: known.key?("ips") ? coerce_list(known["ips"]) : [],
|
|
61
63
|
extras: extras.to_h
|
|
62
64
|
)
|
|
63
65
|
rescue StandardError
|
|
@@ -67,6 +69,27 @@ module ApiKeys
|
|
|
67
69
|
new(origins: [], ips: [], extras: {}, malformed: true)
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
# Reads a restrictions hash a caller passes in (`ApiKey#restrictions=`
|
|
73
|
+
# and `create_api_key!(restrictions:)`). A kind given as `nil` or blank
|
|
74
|
+
# text is cleared, exactly as the `allowed_origins=` / `allowed_ips=`
|
|
75
|
+
# writers clear it. Everything else passes through untouched, so
|
|
76
|
+
# `false`, wrong types, and malformed list members still fail
|
|
77
|
+
# validation. Stored rows never go through here: `wrap` reads them
|
|
78
|
+
# strictly, and a stored `null` refuses authentication.
|
|
79
|
+
#
|
|
80
|
+
# @param value [Hash, Object] Caller input; non-hashes are returned as is.
|
|
81
|
+
# @return [Hash, Object]
|
|
82
|
+
def normalize_input(value)
|
|
83
|
+
return value unless value.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
value.to_h do |kind, entries|
|
|
86
|
+
cleared = KIND_NAMES.include?(kind.to_s) && blank_input?(entries)
|
|
87
|
+
[kind, cleared ? [] : entries]
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
|
|
70
93
|
# The shared empty instance: no origins, no IPs, no restrictions at all.
|
|
71
94
|
# @return [ApiKeys::Restrictions]
|
|
72
95
|
def none
|
|
@@ -128,6 +151,11 @@ module ApiKeys
|
|
|
128
151
|
end
|
|
129
152
|
|
|
130
153
|
# Splits raw input into candidate entries without interpreting them.
|
|
154
|
+
#
|
|
155
|
+
# `nil` and blank text are the raw-input spellings of an empty list (a
|
|
156
|
+
# cleared dashboard field). Every other non-string value, including
|
|
157
|
+
# `false` and a `nil` inside an array, is kept as an entry so that
|
|
158
|
+
# validation rejects it: malformed input must never read as "unrestricted".
|
|
131
159
|
# @api private
|
|
132
160
|
def tokenize(value)
|
|
133
161
|
entries = case value
|
|
@@ -137,11 +165,14 @@ module ApiKeys
|
|
|
137
165
|
else [value]
|
|
138
166
|
end
|
|
139
167
|
|
|
140
|
-
entries.
|
|
141
|
-
|
|
168
|
+
entries.each_with_object([]) do |entry, tokens|
|
|
169
|
+
unless entry.is_a?(String)
|
|
170
|
+
tokens << entry
|
|
171
|
+
next
|
|
172
|
+
end
|
|
142
173
|
|
|
143
174
|
trimmed = entry.strip
|
|
144
|
-
trimmed unless trimmed.empty?
|
|
175
|
+
tokens << trimmed unless trimmed.empty?
|
|
145
176
|
end
|
|
146
177
|
end
|
|
147
178
|
|
|
@@ -190,26 +221,46 @@ module ApiKeys
|
|
|
190
221
|
nil
|
|
191
222
|
end
|
|
192
223
|
|
|
193
|
-
# Coerces one
|
|
194
|
-
#
|
|
195
|
-
#
|
|
224
|
+
# Coerces one present list (storage shape) into an array of entries.
|
|
225
|
+
#
|
|
226
|
+
# Nothing is ever dropped: `false`, `nil`, numbers, hashes, and blank
|
|
227
|
+
# strings inside an array stay as entries, and a scalar that is not a
|
|
228
|
+
# string (`false`, `nil`, `42`) becomes a single entry, so validation
|
|
229
|
+
# rejects them at write time and authentication refuses them at read
|
|
230
|
+
# time. The only spelling of an intentionally empty list is `[]` (or
|
|
231
|
+
# leaving the kind out). A string is accepted as a raw list
|
|
232
|
+
# ("a.com, b.com"), but it must contain at least one entry.
|
|
196
233
|
# @api private
|
|
197
234
|
def coerce_list(value)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
case value
|
|
236
|
+
when Array
|
|
237
|
+
value.map { |entry| coerce_entry(entry) }
|
|
238
|
+
when String
|
|
239
|
+
entries = value.split(ENTRY_SEPARATOR).reject(&:empty?).map { |entry| coerce_entry(entry) }
|
|
240
|
+
entries.empty? ? [value] : entries
|
|
241
|
+
else
|
|
242
|
+
[value]
|
|
243
|
+
end
|
|
244
|
+
rescue ArgumentError
|
|
245
|
+
# A string with invalid encoding cannot be split; keep it as the
|
|
246
|
+
# invalid entry it is.
|
|
247
|
+
[value]
|
|
248
|
+
end
|
|
204
249
|
|
|
205
|
-
|
|
206
|
-
|
|
250
|
+
# @api private
|
|
251
|
+
def blank_input?(entries)
|
|
252
|
+
entries.nil? || (entries.is_a?(String) && entries.strip.empty?)
|
|
253
|
+
rescue ArgumentError
|
|
254
|
+
false
|
|
255
|
+
end
|
|
207
256
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
257
|
+
# @api private
|
|
258
|
+
def coerce_entry(entry)
|
|
259
|
+
return entry unless entry.is_a?(String)
|
|
260
|
+
|
|
261
|
+
entry.strip.downcase
|
|
262
|
+
rescue ArgumentError
|
|
263
|
+
entry
|
|
213
264
|
end
|
|
214
265
|
|
|
215
266
|
# Whether a stored origin entry is shaped like a host or `*.host`.
|
|
@@ -278,7 +329,7 @@ module ApiKeys
|
|
|
278
329
|
|
|
279
330
|
# @return [Array<Symbol>] The restriction kinds actually in use.
|
|
280
331
|
def kinds
|
|
281
|
-
KINDS.
|
|
332
|
+
KINDS.reject { |kind| public_send(kind).empty? }
|
|
282
333
|
end
|
|
283
334
|
|
|
284
335
|
# The storage shape: known lists that have entries, plus any unrecognized
|
|
@@ -286,8 +337,8 @@ module ApiKeys
|
|
|
286
337
|
# @return [Hash]
|
|
287
338
|
def to_h
|
|
288
339
|
hash = {}
|
|
289
|
-
hash["origins"] = deep_copy(origins)
|
|
290
|
-
hash["ips"] = deep_copy(ips)
|
|
340
|
+
hash["origins"] = deep_copy(origins) unless origins.empty?
|
|
341
|
+
hash["ips"] = deep_copy(ips) unless ips.empty?
|
|
291
342
|
hash.merge(deep_copy(extras))
|
|
292
343
|
end
|
|
293
344
|
|
|
@@ -15,6 +15,12 @@ module ApiKeys
|
|
|
15
15
|
extend ApiKeys::Logging
|
|
16
16
|
|
|
17
17
|
MAX_TOKEN_BYTESIZE = 512
|
|
18
|
+
# The longest authentication header that can still carry a valid token:
|
|
19
|
+
# the token plus the "Bearer" scheme and generous separator whitespace.
|
|
20
|
+
MAX_CREDENTIAL_HEADER_BYTESIZE = MAX_TOKEN_BYTESIZE + 64
|
|
21
|
+
# Stands in for a header refused before parsing. It is not a String, so
|
|
22
|
+
# it can never match a key and always yields :invalid_token.
|
|
23
|
+
MALFORMED_CREDENTIAL = Object.new.freeze
|
|
18
24
|
MAX_BCRYPT_CANDIDATES = 32
|
|
19
25
|
MAX_KNOWN_PREFIXES = 1_024
|
|
20
26
|
TOKEN_CACHE_NAMESPACE = "api_keys:v2:token"
|
|
@@ -125,6 +131,15 @@ module ApiKeys
|
|
|
125
131
|
unless header_value.nil?
|
|
126
132
|
return header_value unless header_value.is_a?(String)
|
|
127
133
|
|
|
134
|
+
# Check the raw header before parsing it: a regular expression over
|
|
135
|
+
# invalid UTF-8 raises, and an oversized value should never be
|
|
136
|
+
# scanned. A refused header is not a String, so valid_token?
|
|
137
|
+
# answers with the ordinary invalid_token failure.
|
|
138
|
+
unless acceptable_raw_credential?(header_value)
|
|
139
|
+
log_debug "[ApiKeys Auth] Rejected a malformed authentication header before parsing it."
|
|
140
|
+
return MALFORMED_CREDENTIAL
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
# Handle "Bearer <token>" scheme
|
|
129
144
|
match = header_value.match(/\ABearer[ \t]+(.+)\z/i)
|
|
130
145
|
if match
|
|
@@ -342,6 +357,12 @@ module ApiKeys
|
|
|
342
357
|
end.uniq
|
|
343
358
|
end
|
|
344
359
|
|
|
360
|
+
# Encoding and size of the raw header, checked without any regular
|
|
361
|
+
# expression so that hostile bytes cannot raise or cost a scan.
|
|
362
|
+
def self.acceptable_raw_credential?(value)
|
|
363
|
+
value.bytesize <= MAX_CREDENTIAL_HEADER_BYTESIZE && value.valid_encoding?
|
|
364
|
+
end
|
|
365
|
+
|
|
345
366
|
def self.valid_token?(token)
|
|
346
367
|
return false unless token.is_a?(String)
|
|
347
368
|
return false if token.empty? || token.bytesize > MAX_TOKEN_BYTESIZE || !token.valid_encoding?
|
|
@@ -464,7 +485,7 @@ module ApiKeys
|
|
|
464
485
|
)
|
|
465
486
|
end
|
|
466
487
|
|
|
467
|
-
|
|
488
|
+
unless restrictions.origins.empty?
|
|
468
489
|
origin_host = ApiKeys::Restrictions.extract_origin_host(request)
|
|
469
490
|
unless restrictions.origin_allowed?(origin_host)
|
|
470
491
|
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed."
|
|
@@ -476,7 +497,7 @@ module ApiKeys
|
|
|
476
497
|
end
|
|
477
498
|
end
|
|
478
499
|
|
|
479
|
-
|
|
500
|
+
unless restrictions.ips.empty?
|
|
480
501
|
unless restrictions.ip_allowed?(resolve_client_ip(request, config))
|
|
481
502
|
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed."
|
|
482
503
|
return Result.failure(
|
|
@@ -510,6 +531,7 @@ module ApiKeys
|
|
|
510
531
|
:safe_cache_read, :safe_cache_write,
|
|
511
532
|
:normalized_cache_ttl, :cache_identifier?,
|
|
512
533
|
:safe_find_by_id, :sanitize_prefixes, :valid_token?,
|
|
534
|
+
:acceptable_raw_credential?,
|
|
513
535
|
:production_environment?, :secure_request?,
|
|
514
536
|
:check_key_type_configuration, :check_environment_configuration,
|
|
515
537
|
:check_environment_isolation, :check_request_restrictions,
|
data/lib/api_keys/version.rb
CHANGED
|
@@ -41,8 +41,11 @@ module ApiKeys
|
|
|
41
41
|
say " restrictions: [:origins] },"
|
|
42
42
|
say " secret: { prefix: 'sk', permissions: :all, restrictions: [:ips] }"
|
|
43
43
|
say " }"
|
|
44
|
-
say "\n 4.
|
|
44
|
+
say "\n 4. Make sure the client IP is truthful before relying on allowed_ips:"
|
|
45
|
+
say " # Behind a proxy that is the only way in: list it in trusted_proxies, keep the default."
|
|
45
46
|
say " config.action_dispatch.trusted_proxies = ..."
|
|
47
|
+
say " # Rails directly reachable: check the socket peer, or X-Forwarded-For can be forged."
|
|
48
|
+
say " config.client_ip_resolver = ->(request) { request.remote_addr }"
|
|
46
49
|
say " # Trust a vendor header only when ingress blocks requests that bypass that vendor."
|
|
47
50
|
say "\nSee the api_keys README for detailed usage and examples.", :cyan
|
|
48
51
|
end
|
|
@@ -300,14 +300,22 @@ ApiKeys.configure do |config|
|
|
|
300
300
|
# rails generate api_keys:add_restrictions && rails db:migrate
|
|
301
301
|
# ============================================================================
|
|
302
302
|
|
|
303
|
-
# How the client IP is resolved for `allowed_ips` checks.
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
#
|
|
309
|
-
#
|
|
310
|
-
#
|
|
303
|
+
# How the client IP is resolved for `allowed_ips` checks. Pick the line that
|
|
304
|
+
# matches how clients reach this Rails server:
|
|
305
|
+
#
|
|
306
|
+
# Behind a load balancer, reverse proxy, or CDN that is the ONLY way in and
|
|
307
|
+
# that overwrites or appends X-Forwarded-For, with its addresses covered by
|
|
308
|
+
# config.action_dispatch.trusted_proxies: keep the default.
|
|
309
|
+
# config.client_ip_resolver = ->(request) { request.remote_ip } # default
|
|
310
|
+
#
|
|
311
|
+
# Directly reachable (no proxy, or a proxy that can be bypassed): check the
|
|
312
|
+
# socket peer. With remote_ip, a client could send
|
|
313
|
+
# `X-Forwarded-For: <an allowed address>` and pass the check.
|
|
314
|
+
# config.client_ip_resolver = ->(request) { request.remote_addr }
|
|
315
|
+
#
|
|
316
|
+
# trusted_proxies alone is not a boundary: it does not stop a client that
|
|
317
|
+
# bypasses the proxy from sending the header. Trust a vendor header only
|
|
318
|
+
# when your network ingress rejects requests that bypass that vendor:
|
|
311
319
|
# config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
|
|
312
320
|
|
|
313
321
|
# ============================================================================
|