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.
@@ -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: "#{object_name}[scopes][]"
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
- valid_token_payload?(token)
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; anything else is stored untouched so
68
- # the validation, rather than a silent coercion, is what reports it.
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
- wrapped = ApiKeys::Restrictions.wrap(value)
76
- wrapped.malformed? ? value : wrapped.to_h
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
- update!(revoked_at: Time.current)
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
- attributes = ApiKeys::Restrictions.wrap(restrictions).to_h
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
- ApiKeys::Restrictions.wrap(attributes).to_h
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.filter_map do |entry|
141
- next entry unless entry.is_a?(String)
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 stored list into an array of entries, preserving anything
194
- # that is not a string so validations can report it instead of the value
195
- # disappearing silently.
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
- entries = case value
199
- when nil then []
200
- when String then value.split(ENTRY_SEPARATOR)
201
- when Array then value
202
- else [value]
203
- end
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
- entries.filter_map do |entry|
206
- next entry unless entry.is_a?(String)
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
- trimmed = entry.strip.downcase
209
- trimmed unless trimmed.empty?
210
- rescue ArgumentError
211
- entry
212
- end
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.select { |kind| public_send(kind).any? }
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) if origins.any?
290
- hash["ips"] = deep_copy(ips) if ips.any?
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
- if restrictions.origins.any?
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
- if restrictions.ips.any?
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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -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. Behind a CDN or proxy, make sure the client IP is truthful:"
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
- # The default trusts Rails' own resolution, which honors
305
- # config.action_dispatch.trusted_proxies. Behind a CDN that terminates the
306
- # connection, configure trusted_proxies whenever possible. Trust a vendor
307
- # header directly only when your network ingress rejects requests that
308
- # bypass that vendor; otherwise callers can spoof the address being checked.
309
- # Default: ->(request) { request.remote_ip }
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
  # ============================================================================