aws-sdk-rails 5.0.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: b7354b35727ee37128ccb8b13f94a4d0564518e02e1acaf87086a3664fbea719
4
- data.tar.gz: 8c9aae1eb8fed5ee9c4dca9d74fadd95619a0d05c455c9164dca9310002199db
3
+ metadata.gz: 877222ef0a2075585720421300bd93c84591249678bafb02edf1e01962e63051
4
+ data.tar.gz: caed28f5f0bb7f604b5b2aeee08e59d5b8e6ad3612b4f9e8189b0a22e448ceeb
5
5
  SHA512:
6
- metadata.gz: e3c645120b3787a041900edd7365dec40d766807bc6c11fe81cd6788909d3f180c549fbd0e9071acfeefcd8608cc2e7d3d99f8f53d4ebf611742a33683eeddf0
7
- data.tar.gz: 49743ff5d71ed4dfe915e6aa80cb6d1f1fdb6ba18d783330faae0e320435af92bb32ed78a042b3cb3be486e4a5cdf3826c723d36272a89a599c7f00c85ec0eca
6
+ metadata.gz: fc56e477c15a69e1773f305806118ba2aa7357840f68edbe6b491c569cdcb98ff417ad45abe38f620e5bc6b3c0010e00a1b0bedf987006f8ead442ab2dea4597
7
+ data.tar.gz: 33ede8ffc209b921330121a0f7642d1af4a97ce299dcc7613899f0b0322ec0e4e99b1ed098e601c08402496666a890d030502f9b41fe540c199d10f7bf6fa325
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
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
+
8
+ 5.1.0 (2024-12-05)
9
+ ------------------
10
+
11
+ * Feature - Support async job processing in Elastic Beanstalk middleware. (#167)
12
+
1
13
  5.0.0 (2024-11-21)
2
14
  ------------------
3
15
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 5.0.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,10 +4,49 @@ 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
46
+
47
+ return unless ENV['AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC']
48
+
49
+ @executor = init_executor
11
50
  end
12
51
 
13
52
  def call(env)
@@ -18,55 +57,129 @@ module Aws
18
57
 
19
58
  @logger.debug('aws-sdk-rails middleware detected call from Elastic Beanstalk SQS Daemon.')
20
59
 
21
- # 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.
22
63
  unless request.local? || sent_from_docker_host?(request)
23
64
  @logger.warn('SQSD request detected from untrusted address; returning 403 forbidden.')
24
65
  return forbidden_response
25
66
  end
26
67
 
27
68
  # Execute job or periodic task based on HTTP request context
28
- periodic_task?(request) ? execute_periodic_task(request) : execute_job(request)
69
+ execute(request)
70
+ end
71
+
72
+ def shutdown(timeout = nil)
73
+ return unless @executor
74
+
75
+ @logger.info("Shutting down SQS EBS background job executor. Timeout: #{timeout}")
76
+ @executor.shutdown
77
+ clean_shutdown = @executor.wait_for_termination(timeout)
78
+ @logger.info("SQS EBS background executor shutdown complete. Clean: #{clean_shutdown}")
29
79
  end
30
80
 
31
81
  private
32
82
 
83
+ def init_executor
84
+ threads = Integer(ENV.fetch('AWS_PROCESS_BEANSTALK_WORKER_THREADS',
85
+ Concurrent.available_processor_count || Concurrent.processor_count))
86
+ options = {
87
+ max_threads: threads,
88
+ max_queue: 1,
89
+ auto_terminate: false, # register our own at_exit to gracefully shutdown
90
+ fallback_policy: :abort # Concurrent::RejectedExecutionError must be handled
91
+ }
92
+ at_exit { shutdown }
93
+
94
+ Concurrent::ThreadPoolExecutor.new(options)
95
+ end
96
+
97
+ def execute(request)
98
+ if periodic_task?(request)
99
+ execute_periodic_task(request)
100
+ else
101
+ execute_job(request)
102
+ end
103
+ end
104
+
33
105
  def execute_job(request)
106
+ if @executor
107
+ _execute_job_background(request)
108
+ else
109
+ _execute_job_now(request)
110
+ end
111
+ end
112
+
113
+ # Execute a job in the current thread
114
+ def _execute_job_now(request)
34
115
  # Jobs queued from the SQS adapter contain the JSON message in the request body.
35
116
  job = ::ActiveSupport::JSON.decode(request.body.string)
36
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
37
126
  @logger.debug("Executing job: #{job_name}")
38
- _execute_job(job, job_name)
127
+ ::ActiveJob::Base.execute(job)
39
128
  [200, { 'Content-Type' => 'text/plain' }, ["Successfully ran job #{job_name}."]]
40
- rescue NameError
41
- internal_error_response
42
129
  end
43
130
 
44
- def _execute_job(job, job_name)
45
- ::ActiveJob::Base.execute(job)
46
- rescue NameError => e
47
- @logger.error("Job #{job_name} could not resolve to a class that inherits from Active Job.")
48
- @logger.error("Error: #{e}")
49
- raise e
131
+ # Execute a job using the thread pool executor
132
+ def _execute_job_background(request)
133
+ job_data = ::ActiveSupport::JSON.decode(request.body.string)
134
+ job_name = job_data['job_class']
135
+ resolve_job_class(job_name)
136
+ @logger.debug("Queuing background job: #{job_name}")
137
+ @executor.post(job_data) do |job|
138
+ ::ActiveJob::Base.execute(job)
139
+ end
140
+ [200, { 'Content-Type' => 'text/plain' }, ["Successfully queued job #{job_name}"]]
141
+ rescue Concurrent::RejectedExecutionError
142
+ msg = 'No capacity to execute job.'
143
+ @logger.info(msg)
144
+ [429, { 'Content-Type' => 'text/plain' }, [msg]]
145
+ rescue NameError, InvalidJobClassError => e
146
+ unresolved_job_class_response(job_name, e)
50
147
  end
51
148
 
52
149
  def execute_periodic_task(request)
53
150
  # The beanstalk worker SQS Daemon will add the 'X-Aws-Sqsd-Taskname' for periodic tasks set in cron.yaml.
54
151
  job_name = request.headers['X-Aws-Sqsd-Taskname']
55
- @logger.debug("Creating and executing periodic task: #{job_name}")
56
- _execute_periodic_task(job_name)
57
- [200, { 'Content-Type' => 'text/plain' }, ["Successfully ran periodic task #{job_name}."]]
58
- rescue NameError
59
- internal_error_response
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
162
+ if @executor
163
+ _execute_periodic_task_background(job)
164
+ else
165
+ _execute_periodic_task_now(job)
166
+ end
60
167
  end
61
168
 
62
- def _execute_periodic_task(job_name)
63
- job = job_name.constantize.new
169
+ def _execute_periodic_task_now(job)
170
+ @logger.debug("Executing periodic task: #{job.class}")
64
171
  job.perform_now
65
- rescue NameError => e
66
- @logger.error("Periodic task #{job_name} could not resolve to an Active Job class " \
67
- '- check the cron name spelling and set the path as / in cron.yaml.')
68
- @logger.error("Error: #{e}.")
69
- raise e
172
+ [200, { 'Content-Type' => 'text/plain' }, ["Successfully ran periodic task #{job.class}."]]
173
+ end
174
+
175
+ def _execute_periodic_task_background(job)
176
+ @logger.debug("Queuing bakground periodic task: #{job.class}")
177
+ @executor.post(job, &:perform_now)
178
+ [200, { 'Content-Type' => 'text/plain' }, ["Successfully queued periodic task #{job.class}"]]
179
+ rescue Concurrent::RejectedExecutionError
180
+ msg = 'No capacity to execute periodic task.'
181
+ @logger.info(msg)
182
+ [429, { 'Content-Type' => 'text/plain' }, [msg]]
70
183
  end
71
184
 
72
185
  def internal_error_response
@@ -74,6 +187,19 @@ module Aws
74
187
  [500, { 'Content-Type' => 'text/plain' }, [message]]
75
188
  end
76
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
+
77
203
  def forbidden_response
78
204
  message = 'Request with aws-sqsd user agent was made from untrusted address.'
79
205
  [403, { 'Content-Type' => 'text/plain' }, [message]]
@@ -92,6 +218,48 @@ module Aws
92
218
  request.headers['X-Aws-Sqsd-Taskname'].present? && request.fullpath == '/'
93
219
  end
94
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
+
95
263
  def sent_from_docker_host?(request)
96
264
  app_runs_in_docker_container? && ip_originates_from_docker_host?(request)
97
265
  end
@@ -108,16 +276,24 @@ module Aws
108
276
  File.exist?('/proc/self/mountinfo') && File.read('/proc/self/mountinfo') =~ %r{/docker/containers/}
109
277
  end
110
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.
111
286
  def ip_originates_from_docker_host?(request)
112
- default_docker_ips.include?(request.remote_ip) ||
113
- 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)
114
290
  end
115
291
 
116
292
  def default_docker_ips
117
293
  @default_docker_ips ||= build_default_docker_ips
118
294
  end
119
295
 
120
- # rubocop:disable Metrics/AbcSize
296
+ # rubocop:disable Metrics/AbcSize, Style/FileOpen
121
297
  def build_default_docker_ips
122
298
  default_gw_ips = ['172.17.0.1']
123
299
 
@@ -134,7 +310,7 @@ module Aws
134
310
 
135
311
  default_gw_ips
136
312
  end
137
- # rubocop:enable Metrics/AbcSize
313
+ # rubocop:enable Metrics/AbcSize, Style/FileOpen
138
314
  end
139
315
  end
140
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.0.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-11-21 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.11
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: []