aws-sdk-rails 5.1.0 → 5.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de037ab8aa7c27156e7074ab7aaf7999d32ebb468c26c938c35031618912af2c
4
- data.tar.gz: 61bc484c0d053a05fb1ad108c636a67be5fb76a2c0d9152d126657b9b3099baf
3
+ metadata.gz: 877222ef0a2075585720421300bd93c84591249678bafb02edf1e01962e63051
4
+ data.tar.gz: caed28f5f0bb7f604b5b2aeee08e59d5b8e6ad3612b4f9e8189b0a22e448ceeb
5
5
  SHA512:
6
- metadata.gz: 89e5f84d363bb63b0d3c7520405acb03f46a283a6ea03d711547e5ecfc686e9435d232312d228c9cf61809f58fb6dc247a29793b56274cb94c5f9adee5910e72
7
- data.tar.gz: aedc388ff0285d30d741b6ec6d2114af167b119f5f02b5da2be1a9858028e81bbbbfd152b3802c1482c9b50b9d5fdb57c96449c80a31d4c9d304e2aad306eb52
6
+ metadata.gz: fc56e477c15a69e1773f305806118ba2aa7357840f68edbe6b491c569cdcb98ff417ad45abe38f620e5bc6b3c0010e00a1b0bedf987006f8ead442ab2dea4597
7
+ data.tar.gz: 33ede8ffc209b921330121a0f7642d1af4a97ce299dcc7613899f0b0322ec0e4e99b1ed098e601c08402496666a890d030502f9b41fe540c199d10f7bf6fa325
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ 5.2.0 (2026-08-11)
2
+ ------------------
3
+
4
+ * Issue - The `ElasticBeanstalkSQSD` middleware's Docker host check now only consults `remote_addr`, the raw TCP peer address, instead of also accepting `remote_ip`. `remote_ip` is derived from the client-supplied `X-Forwarded-For` header whenever the peer address is itself private, so a request from any private address could name the Docker gateway and be treated as local. Loopback peer addresses are accepted by the check, so requests proxied over loopback are unaffected.
5
+ * Issue - Only classes inheriting from `ActiveJob::Base` are executed by the `ElasticBeanstalkSQSD` middleware, preventing arbitrary classes named in an SQS message from being instantiated and run. Job class and periodic task names are validated as constant paths and resolved without searching the namespace's ancestors, so a request cannot name a constant outside the intended namespace.
6
+ * Feature - Adds a new configuration object for elastic beanstalk sqsd middleware, with an optional `job_class_allowlist` configuration to `ElasticBeanstalkSQSD` middleware to restrict which job classes can be dispatched. When set, the allowlist is checked before the class name is resolved, so an excluded class is never loaded.
7
+
1
8
  5.1.0 (2024-12-05)
2
9
  ------------------
3
10
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 5.1.0
1
+ 5.2.0
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module Rails
5
+ module Middleware
6
+ class ElasticBeanstalkSQSD
7
+ # Configuration for the {ElasticBeanstalkSQSD} middleware.
8
+ #
9
+ # Use {ElasticBeanstalkSQSD.config} to access the singleton config
10
+ # instance and {ElasticBeanstalkSQSD.configure} to configure in code:
11
+ #
12
+ # Aws::Rails::Middleware::ElasticBeanstalkSQSD.configure do |config|
13
+ # config.job_class_allowlist = [SendReceiptJob, ProcessOrderJob]
14
+ # end
15
+ #
16
+ class Configuration
17
+ # @return [Array<Class, String>, nil] Optional list of job classes
18
+ # permitted to be executed. When set, only classes in this list
19
+ # will be dispatched. When nil, any class inheriting from
20
+ # ActiveJob::Base is allowed. Entries may be given as Class objects
21
+ # or Strings; matching is by class name, so an allowlisted class is
22
+ # still recognized after a Zeitwerk reload replaces the class object.
23
+ attr_accessor :job_class_allowlist
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,7 +4,42 @@ module Aws
4
4
  module Rails
5
5
  module Middleware
6
6
  # Middleware to handle requests from the SQS Daemon present on Elastic Beanstalk worker environments.
7
- class ElasticBeanstalkSQSD
7
+ #
8
+ # See {Configuration} for the available options and {configure} for
9
+ # setting them in code.
10
+ class ElasticBeanstalkSQSD # rubocop:disable Metrics/ClassLength
11
+ # Raised when a job message names a class that cannot be executed -
12
+ # the name is not a well-formed constant path, it is not in the
13
+ # configured job_class_allowlist, or it does not inherit from
14
+ # ActiveJob::Base.
15
+ class InvalidJobClassError < StandardError; end
16
+
17
+ # A job class name is an (optionally namespaced) constant path and
18
+ # nothing else. Names that cannot match this can never name a job
19
+ # class, so rejecting them keeps them out of the constant lookup.
20
+ JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(::[A-Z]\w*)*\z/.freeze
21
+
22
+ class << self
23
+ # Yields the middleware configuration for customization.
24
+ #
25
+ # @example Restrict job dispatch to specific classes
26
+ # Aws::Rails::Middleware::ElasticBeanstalkSQSD.configure do |config|
27
+ # config.job_class_allowlist = [SendReceiptJob, ProcessOrderJob]
28
+ # end
29
+ #
30
+ # @yieldparam [Configuration] config
31
+ # @return [Configuration]
32
+ def configure
33
+ yield(config) if block_given?
34
+ config
35
+ end
36
+
37
+ # @return [Configuration] the current middleware configuration.
38
+ def config
39
+ @config ||= Configuration.new
40
+ end
41
+ end
42
+
8
43
  def initialize(app)
9
44
  @app = app
10
45
  @logger = ::Rails.logger
@@ -22,7 +57,9 @@ module Aws
22
57
 
23
58
  @logger.debug('aws-sdk-rails middleware detected call from Elastic Beanstalk SQS Daemon.')
24
59
 
25
- # Only accept requests from this user agent if it is from localhost or a docker host in case of forgery.
60
+ # Best-effort source check. On Docker-based EB platforms this is unreliable
61
+ # because all traffic arrives via the docker bridge. Use job_class_allowlist
62
+ # and network-level controls (security groups) as the primary safeguards.
26
63
  unless request.local? || sent_from_docker_host?(request)
27
64
  @logger.warn('SQSD request detected from untrusted address; returning 403 forbidden.')
28
65
  return forbidden_response
@@ -78,43 +115,55 @@ module Aws
78
115
  # Jobs queued from the SQS adapter contain the JSON message in the request body.
79
116
  job = ::ActiveSupport::JSON.decode(request.body.string)
80
117
  job_name = job['job_class']
118
+ # Scope the rescue to resolution only. A NameError raised from inside
119
+ # the job's own #perform must not be mislabeled as a class-resolution
120
+ # failure, so ::ActiveJob::Base.execute runs outside the begin/rescue.
121
+ begin
122
+ resolve_job_class(job_name)
123
+ rescue NameError, InvalidJobClassError => e
124
+ return unresolved_job_class_response(job_name, e)
125
+ end
81
126
  @logger.debug("Executing job: #{job_name}")
82
127
  ::ActiveJob::Base.execute(job)
83
128
  [200, { 'Content-Type' => 'text/plain' }, ["Successfully ran job #{job_name}."]]
84
- rescue NameError => e
85
- @logger.error("Job #{job_name} could not resolve to a class that inherits from Active Job.")
86
- @logger.error("Error: #{e}")
87
- internal_error_response
88
129
  end
89
130
 
90
131
  # Execute a job using the thread pool executor
91
132
  def _execute_job_background(request)
92
133
  job_data = ::ActiveSupport::JSON.decode(request.body.string)
93
- @logger.debug("Queuing background job: #{job_data['job_class']}")
134
+ job_name = job_data['job_class']
135
+ resolve_job_class(job_name)
136
+ @logger.debug("Queuing background job: #{job_name}")
94
137
  @executor.post(job_data) do |job|
95
138
  ::ActiveJob::Base.execute(job)
96
139
  end
97
- [200, { 'Content-Type' => 'text/plain' }, ["Successfully queued job #{job_data['job_class']}"]]
140
+ [200, { 'Content-Type' => 'text/plain' }, ["Successfully queued job #{job_name}"]]
98
141
  rescue Concurrent::RejectedExecutionError
99
142
  msg = 'No capacity to execute job.'
100
143
  @logger.info(msg)
101
144
  [429, { 'Content-Type' => 'text/plain' }, [msg]]
145
+ rescue NameError, InvalidJobClassError => e
146
+ unresolved_job_class_response(job_name, e)
102
147
  end
103
148
 
104
149
  def execute_periodic_task(request)
105
150
  # The beanstalk worker SQS Daemon will add the 'X-Aws-Sqsd-Taskname' for periodic tasks set in cron.yaml.
106
151
  job_name = request.headers['X-Aws-Sqsd-Taskname']
107
- job = job_name.constantize.new
152
+ # Resolve once and reuse the class, rather than resolving the name a
153
+ # second time with another constantize (which also left a check-then-
154
+ # act gap between what was validated and what ran). Scope the rescue
155
+ # to resolution only, so a NameError from inside the task's own
156
+ # #perform is not mislabeled as a cron-name resolution failure.
157
+ begin
158
+ job = resolve_job_class(job_name).new
159
+ rescue NameError, InvalidJobClassError => e
160
+ return unresolved_periodic_task_response(job_name, e)
161
+ end
108
162
  if @executor
109
163
  _execute_periodic_task_background(job)
110
164
  else
111
165
  _execute_periodic_task_now(job)
112
166
  end
113
- rescue NameError => e
114
- @logger.error("Periodic task #{job_name} could not resolve to an Active Job class " \
115
- '- check the cron name spelling and set the path as / in cron.yaml.')
116
- @logger.error("Error: #{e}.")
117
- internal_error_response
118
167
  end
119
168
 
120
169
  def _execute_periodic_task_now(job)
@@ -138,6 +187,19 @@ module Aws
138
187
  [500, { 'Content-Type' => 'text/plain' }, [message]]
139
188
  end
140
189
 
190
+ def unresolved_job_class_response(job_name, error)
191
+ @logger.error("Job #{job_name} could not resolve to a class that inherits from Active Job.")
192
+ @logger.error("Error: #{error}")
193
+ internal_error_response
194
+ end
195
+
196
+ def unresolved_periodic_task_response(job_name, error)
197
+ @logger.error("Periodic task #{job_name} could not resolve to an Active Job class " \
198
+ '- check the cron name spelling and set the path as / in cron.yaml.')
199
+ @logger.error("Error: #{error}.")
200
+ internal_error_response
201
+ end
202
+
141
203
  def forbidden_response
142
204
  message = 'Request with aws-sqsd user agent was made from untrusted address.'
143
205
  [403, { 'Content-Type' => 'text/plain' }, [message]]
@@ -156,6 +218,48 @@ module Aws
156
218
  request.headers['X-Aws-Sqsd-Taskname'].present? && request.fullpath == '/'
157
219
  end
158
220
 
221
+ # Resolves +name+ to a job class, raising InvalidJobClassError if it is
222
+ # not a well-formed constant path, is not in the configured allowlist,
223
+ # or does not name a class inheriting from ActiveJob::Base. Returns the
224
+ # resolved class so callers can reuse it without resolving the name a
225
+ # second time.
226
+ #
227
+ # Checks are ordered so that the least trusting one runs first. Merely
228
+ # resolving a constant is not inert: under Zeitwerk it triggers
229
+ # autoloading, which runs the body of whichever file defines it. So the
230
+ # name is matched against the allowlist as a String, before any lookup,
231
+ # and an excluded name never reaches the constant resolver at all.
232
+ def resolve_job_class(name)
233
+ name = name.to_s
234
+ raise InvalidJobClassError, "#{name.inspect} is not a valid job class name" unless
235
+ JOB_CLASS_NAME_PATTERN.match?(name)
236
+
237
+ allowlist = self.class.config.job_class_allowlist
238
+ # Match by class name, not object identity: in development Zeitwerk
239
+ # reloads produce a new class object with the same name, so an
240
+ # allowlist of Class objects would reject every job after a reload.
241
+ if allowlist && !allowlist.map(&:to_s).include?(name)
242
+ raise InvalidJobClassError, "#{name} is not in the configured job_class_allowlist"
243
+ end
244
+
245
+ klass = constantize_job_class(name)
246
+ unless klass.is_a?(Class) && klass < ::ActiveJob::Base
247
+ raise InvalidJobClassError, "#{name} is not a valid job class (must inherit from ActiveJob::Base)"
248
+ end
249
+
250
+ klass
251
+ end
252
+
253
+ # Resolves an already-validated name. +inherit+ is false so that each
254
+ # segment must be defined directly on the preceding one: a name such as
255
+ # 'SomeJob::Foo' cannot resolve Foo from SomeJob's ancestors when
256
+ # SomeJob itself does not define it.
257
+ def constantize_job_class(name)
258
+ # CodeQL [rb/code-injection] Name is validated against JOB_CLASS_NAME_PATTERN
259
+ # and the allowlist above, and the result is checked for ActiveJob::Base ancestry below.
260
+ Object.const_get(name, false)
261
+ end
262
+
159
263
  def sent_from_docker_host?(request)
160
264
  app_runs_in_docker_container? && ip_originates_from_docker_host?(request)
161
265
  end
@@ -172,16 +276,24 @@ module Aws
172
276
  File.exist?('/proc/self/mountinfo') && File.read('/proc/self/mountinfo') =~ %r{/docker/containers/}
173
277
  end
174
278
 
279
+ # Only consults remote_addr, the raw TCP peer address. remote_ip is
280
+ # derived from X-Forwarded-For whenever the peer is itself private, and
281
+ # the docker gateway range (172.16.0.0/12) is one Rack treats as a
282
+ # trusted proxy to see through - so a request from any private peer can
283
+ # set remote_ip to the gateway with a header alone. Loopback is accepted
284
+ # here because such a request is genuinely local; request.local? rejects
285
+ # it only on the forgeable half of its check.
175
286
  def ip_originates_from_docker_host?(request)
176
- default_docker_ips.include?(request.remote_ip) ||
177
- default_docker_ips.include?(request.remote_addr)
287
+ remote_addr = request.remote_addr
288
+ default_docker_ips.include?(remote_addr) ||
289
+ ::ActionDispatch::Request::LOCALHOST.match?(remote_addr)
178
290
  end
179
291
 
180
292
  def default_docker_ips
181
293
  @default_docker_ips ||= build_default_docker_ips
182
294
  end
183
295
 
184
- # rubocop:disable Metrics/AbcSize
296
+ # rubocop:disable Metrics/AbcSize, Style/FileOpen
185
297
  def build_default_docker_ips
186
298
  default_gw_ips = ['172.17.0.1']
187
299
 
@@ -198,7 +310,7 @@ module Aws
198
310
 
199
311
  default_gw_ips
200
312
  end
201
- # rubocop:enable Metrics/AbcSize
313
+ # rubocop:enable Metrics/AbcSize, Style/FileOpen
202
314
  end
203
315
  end
204
316
  end
data/lib/aws-sdk-rails.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'aws/rails/middleware/elastic_beanstalk_sqsd/configuration'
3
4
  require_relative 'aws/rails/middleware/elastic_beanstalk_sqsd'
4
5
  require_relative 'aws/rails/railtie'
5
6
  require_relative 'aws/rails/notifications'
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aws-sdk-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.0
4
+ version: 5.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amazon Web Services
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-12-05 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: aws-sdk-core
@@ -50,13 +49,13 @@ files:
50
49
  - VERSION
51
50
  - lib/aws-sdk-rails.rb
52
51
  - lib/aws/rails/middleware/elastic_beanstalk_sqsd.rb
52
+ - lib/aws/rails/middleware/elastic_beanstalk_sqsd/configuration.rb
53
53
  - lib/aws/rails/notifications.rb
54
54
  - lib/aws/rails/railtie.rb
55
55
  homepage: https://github.com/aws/aws-sdk-rails
56
56
  licenses:
57
57
  - Apache-2.0
58
58
  metadata: {}
59
- post_install_message:
60
59
  rdoc_options: []
61
60
  require_paths:
62
61
  - lib
@@ -71,8 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
70
  - !ruby/object:Gem::Version
72
71
  version: '0'
73
72
  requirements: []
74
- rubygems_version: 3.5.9
75
- signing_key:
73
+ rubygems_version: 4.0.16
76
74
  specification_version: 4
77
75
  summary: AWS SDK for Ruby on Rails Railtie
78
76
  test_files: []