pgbus 0.2.3 → 0.2.4

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: 85f2e8748cf70bb74cb736b23e3e0288be5ca0b946b0eb80c08e1d28650e98c0
4
- data.tar.gz: d1fd675bb9d9a5beb4607f8e0da684c606d096a835948abf60034bd601cdd7f2
3
+ metadata.gz: daac3606ba66624f54b5b6cf8db0830827c2ce0492b7d6d38ce347d1e0f3aeb2
4
+ data.tar.gz: ad7eb6ea7beae1e07a813cac5cac37980671f8106f2ac7444a127a81d6f7a2e3
5
5
  SHA512:
6
- metadata.gz: 99b168d4276ef5149effdbf7d18955c542b9f8507a67d022381a61c7c36cacdf58ab414e166e528495e244313ef12d10e054119361bbf77ca2e6cc260f6d1982
7
- data.tar.gz: 606324c33ddd072b24135c91314258fc2c162e54b6610ec813457cae2b4df3ba5c0391fc5dbd8a1ff6e13f14032968320b41b9a1d9450d0b5fbe2d66686f2c9e
6
+ metadata.gz: f7a513fd3e2f7aac5a3c0db040a3e7b6b11b6c4c0f619f6f0d37ebbf3e2cd6336737af20e4a7a00da19a1937d5be2b8e24d666483bf661005422c04bbac1805c
7
+ data.tar.gz: 255c0382fbc4f4b797577757ebe5bfa9611f350456292d78e41678d6ebe834c52a5588e2f902159b0c5467becb076f0b724b8dc782b21dfb0d423404dc31d178
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Breaking Changes
4
+
5
+ - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
6
+
7
+ ### Security
8
+
9
+ - Add `bundler-audit` to CI for dependency vulnerability scanning
10
+ - Add `QueueNameValidator` to enforce strict queue name validation (alphanumeric + underscores, 61 char max)
11
+ - Add `config.allowed_global_id_models` to restrict which models can be deserialized from event payloads
12
+ - Add security headers to dashboard (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
13
+ - Warn when dashboard `web_auth` is unconfigured
14
+ - Add `globalid` as an explicit runtime dependency (was used but only transitively available via activejob)
15
+
3
16
  ## [0.1.0] - 2026-03-30
4
17
 
5
18
  - Initial release
@@ -6,6 +6,7 @@ module Pgbus
6
6
 
7
7
  protect_from_forgery with: :exception
8
8
  before_action :set_locale
9
+ after_action :set_security_headers
9
10
 
10
11
  layout "pgbus/application"
11
12
 
@@ -21,6 +22,14 @@ module Pgbus
21
22
 
22
23
  private
23
24
 
25
+ def set_security_headers
26
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
27
+ response.headers["X-Content-Type-Options"] = "nosniff"
28
+ response.headers["X-XSS-Protection"] = "0"
29
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
30
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
31
+ end
32
+
24
33
  def set_locale
25
34
  I18n.locale = extract_locale || I18n.default_locale
26
35
  end
@@ -19,6 +19,13 @@ module ActiveJob
19
19
  true
20
20
  end
21
21
 
22
+ # Called by ActiveJob::Continuation (Rails 8.1+) at each checkpoint.
23
+ # When true, continuable jobs save their cursor and re-enqueue
24
+ # themselves so the worker can shut down gracefully.
25
+ def stopping?
26
+ Pgbus.stopping
27
+ end
28
+
22
29
  private
23
30
 
24
31
  def adapter
@@ -80,7 +80,7 @@ module Pgbus
80
80
  private
81
81
 
82
82
  def execute_job(job)
83
- if defined?(Rails) && Rails.application
83
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
84
84
  Rails.application.executor.wrap { job.perform_now }
85
85
  else
86
86
  job.perform_now
data/lib/pgbus/client.rb CHANGED
@@ -216,7 +216,7 @@ module Pgbus
216
216
 
217
217
  def purge_archive(queue_name, older_than:, batch_size: 1000)
218
218
  full_name = config.queue_name(queue_name)
219
- sanitized = full_name.gsub(/[^a-zA-Z0-9_]/, "")
219
+ sanitized = QueueNameValidator.sanitize!(full_name)
220
220
  total = 0
221
221
 
222
222
  sql = "DELETE FROM pgmq.a_#{sanitized} " \
@@ -8,7 +8,7 @@ module Pgbus
8
8
  module_function
9
9
 
10
10
  def load(path, env: nil)
11
- env ||= defined?(Rails) ? Rails.env : ENV.fetch("PGBUS_ENV", "development")
11
+ env ||= (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV.fetch("PGBUS_ENV", "development")
12
12
  raw = File.read(path)
13
13
  parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
14
14
  config_hash = parsed.fetch(env, parsed)
@@ -36,7 +36,7 @@ module Pgbus
36
36
  attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size, :outbox_retention
37
37
 
38
38
  # Event bus
39
- attr_accessor :idempotency_ttl
39
+ attr_accessor :idempotency_ttl, :allowed_global_id_models
40
40
 
41
41
  # Logging
42
42
  attr_accessor :logger
@@ -108,8 +108,9 @@ module Pgbus
108
108
  @outbox_retention = 24 * 3600 # 1 day
109
109
 
110
110
  @idempotency_ttl = 7 * 24 * 3600 # 7 days
111
+ @allowed_global_id_models = nil # nil = allow all (for backwards compat)
111
112
 
112
- @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
113
+ @logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stdout)
113
114
 
114
115
  @listen_notify = true
115
116
  @notify_throttle_ms = 250
@@ -138,7 +139,9 @@ module Pgbus
138
139
  end
139
140
 
140
141
  def queue_name(name)
141
- "#{queue_prefix}_#{name}"
142
+ full = "#{queue_prefix}_#{name}"
143
+ QueueNameValidator.validate!(full)
144
+ full
142
145
  end
143
146
 
144
147
  def dead_letter_queue_name(name)
@@ -36,7 +36,7 @@ module Pgbus
36
36
 
37
37
  def build_event(raw)
38
38
  payload = raw["payload"]
39
- payload = GlobalID::Locator.locate(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
39
+ payload = Serializer.locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
40
40
 
41
41
  Event.new(
42
42
  event_id: raw["event_id"],
@@ -143,7 +143,7 @@ module Pgbus
143
143
  return true if config.recurring_tasks_file && File.exist?(config.recurring_tasks_file.to_s)
144
144
 
145
145
  # Check default location
146
- if defined?(Rails)
146
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
147
147
  default_path = Rails.root.join("config", "recurring.yml")
148
148
  return File.exist?(default_path.to_s)
149
149
  end
@@ -155,7 +155,7 @@ module Pgbus
155
155
  return if config.recurring_tasks&.any?
156
156
 
157
157
  path = config.recurring_tasks_file
158
- path ||= defined?(Rails) ? Rails.root.join("config", "recurring.yml") : nil
158
+ path ||= defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join("config", "recurring.yml") : nil
159
159
  return unless path && File.exist?(path.to_s)
160
160
 
161
161
  config.recurring_tasks = Recurring::ConfigLoader.load(path)
@@ -289,7 +289,7 @@ module Pgbus
289
289
  end
290
290
 
291
291
  def load_rails_app
292
- return unless defined?(Rails)
292
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
293
293
 
294
294
  Rails.application.eager_load! if Rails.application.respond_to?(:eager_load!)
295
295
  end
@@ -66,12 +66,14 @@ module Pgbus
66
66
 
67
67
  def graceful_shutdown
68
68
  Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
69
+ Pgbus.stopping = true
69
70
  @lifecycle.transition_to(:draining)
70
71
  @wake_signal.notify!
71
72
  end
72
73
 
73
74
  def immediate_shutdown
74
75
  Pgbus.logger.warn { "[Pgbus] Worker shutting down immediately!" }
76
+ Pgbus.stopping = true
75
77
  @lifecycle.transition_to!(:stopped)
76
78
  @wake_signal.notify!
77
79
  @pool.kill
@@ -210,6 +212,7 @@ module Pgbus
210
212
  def check_recycle
211
213
  return unless @lifecycle.running? && recycle_needed?
212
214
 
215
+ Pgbus.stopping = true
213
216
  @lifecycle.transition_to(:draining)
214
217
  @wake_signal.notify!
215
218
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Validates and sanitizes PGMQ queue names for safe use in SQL identifiers.
5
+ #
6
+ # PGMQ queue names are interpolated into SQL as table/sequence names
7
+ # (e.g., pgmq.q_<name>, pgmq.a_<name>). This module enforces strict
8
+ # validation to prevent SQL injection via crafted queue names.
9
+ module QueueNameValidator
10
+ # PostgreSQL identifier limit is 63 bytes (NAMEDATALEN - 1).
11
+ # PGMQ prefixes with "q_" or "a_" (2 chars), so limit the name itself.
12
+ MAX_QUEUE_NAME_LENGTH = 61
13
+
14
+ # Only alphanumeric characters and underscores are allowed.
15
+ VALID_QUEUE_NAME_PATTERN = /\A[a-zA-Z0-9_]+\z/
16
+
17
+ module_function
18
+
19
+ # Validates a queue name for safe SQL identifier use.
20
+ # Returns the name if valid, raises ArgumentError if not.
21
+ def validate!(name)
22
+ name = name.to_s
23
+ raise ArgumentError, "Queue name cannot be blank" if name.empty?
24
+ if name.length > MAX_QUEUE_NAME_LENGTH
25
+ raise ArgumentError,
26
+ "Queue name too long (#{name.length} chars, max #{MAX_QUEUE_NAME_LENGTH}): #{name.inspect}"
27
+ end
28
+
29
+ unless VALID_QUEUE_NAME_PATTERN.match?(name)
30
+ raise ArgumentError,
31
+ "Invalid queue name: #{name.inspect}. Only alphanumeric characters and underscores are allowed."
32
+ end
33
+
34
+ name
35
+ end
36
+
37
+ # Sanitizes a queue name by removing invalid characters, then validates.
38
+ # Use this for names from untrusted sources (e.g., URL params).
39
+ def sanitize!(name)
40
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, "")
41
+ validate!(sanitized)
42
+ sanitized
43
+ end
44
+ end
45
+ end
@@ -24,7 +24,7 @@ module Pgbus
24
24
  end
25
25
 
26
26
  def detect_env
27
- if defined?(Rails)
27
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
28
28
  Rails.env.to_s
29
29
  else
30
30
  ENV.fetch("PGBUS_ENV", "development")
@@ -41,7 +41,7 @@ module Pgbus
41
41
  data = JSON.parse(json_string)
42
42
  payload = data["payload"]
43
43
 
44
- data["payload"] = GlobalID::Locator.locate(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
44
+ data["payload"] = locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
45
45
 
46
46
  Event.new(
47
47
  event_id: data["event_id"],
@@ -49,5 +49,32 @@ module Pgbus
49
49
  published_at: Time.parse(data["published_at"])
50
50
  )
51
51
  end
52
+
53
+ # Locate a GlobalID with optional type restriction.
54
+ # When allowed_global_id_models is configured, only those model classes
55
+ # can be resolved — prevents loading arbitrary objects from crafted payloads.
56
+ def locate_global_id(gid_string)
57
+ gid = GlobalID.parse(gid_string)
58
+ raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
+
60
+ allowed = Pgbus.configuration.allowed_global_id_models
61
+ if allowed&.empty?
62
+ raise ArgumentError,
63
+ "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
64
+ "Set to nil to allow all models, or add permitted classes."
65
+ end
66
+ if allowed&.any? { |entry| !entry.is_a?(Class) && !entry.is_a?(Module) }
67
+ raise ArgumentError,
68
+ "allowed_global_id_models must contain Class/Module objects, " \
69
+ "got: #{allowed.map(&:class).uniq.join(", ")}"
70
+ end
71
+ if allowed&.none? { |klass| gid.model_class <= klass }
72
+ raise ArgumentError,
73
+ "GlobalID model #{gid.model_class} is not in allowed_global_id_models. " \
74
+ "Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
75
+ end
76
+
77
+ GlobalID::Locator.locate(gid)
78
+ end
52
79
  end
53
80
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.4"
5
5
  end
@@ -9,16 +9,35 @@ module Pgbus
9
9
  before_action :authenticate_pgbus!
10
10
  end
11
11
 
12
+ class << self
13
+ attr_accessor :auth_warned
14
+ end
15
+
12
16
  private
13
17
 
14
18
  def authenticate_pgbus!
15
19
  auth_block = Pgbus.configuration.web_auth
16
- return if auth_block.nil?
20
+
21
+ if auth_block.nil?
22
+ warn_unauthenticated_dashboard
23
+ return
24
+ end
17
25
 
18
26
  return if auth_block.respond_to?(:call) && auth_block.call(request)
19
27
 
20
28
  head :unauthorized
21
29
  end
30
+
31
+ def warn_unauthenticated_dashboard
32
+ return if Pgbus::Web::Authentication.auth_warned
33
+
34
+ Pgbus.logger.warn do
35
+ "[Pgbus] Dashboard is accessible without authentication. " \
36
+ "Configure Pgbus.configuration.web_auth to restrict access. " \
37
+ "See: https://github.com/mhenrixon/pgbus#dashboard-authentication"
38
+ end
39
+ Pgbus::Web::Authentication.auth_warned = true
40
+ end
22
41
  end
23
42
  end
24
43
  end
@@ -662,10 +662,7 @@ module Pgbus
662
662
  end
663
663
 
664
664
  def sanitize_name(name)
665
- sanitized = name.gsub(/[^a-zA-Z0-9_]/, "")
666
- raise ArgumentError, "Invalid queue name: #{name.inspect}" if sanitized.empty?
667
-
668
- sanitized
665
+ QueueNameValidator.sanitize!(name)
669
666
  end
670
667
 
671
668
  def compute_throughput(queues)
data/lib/pgbus.rb CHANGED
@@ -13,6 +13,14 @@ module Pgbus
13
13
  class SchemaNotReady < Error; end
14
14
 
15
15
  class << self
16
+ # Process-global flag set by Worker#graceful_shutdown so the adapter
17
+ # can report stopping? to ActiveJob::Continuation (Rails 8.1+).
18
+ attr_writer :stopping
19
+
20
+ def stopping
21
+ @stopping || false
22
+ end
23
+
16
24
  def loader
17
25
  @loader ||= begin
18
26
  loader = Zeitwerk::Loader.for_gem
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: 1.11.1
46
+ - !ruby/object:Gem::Dependency
47
+ name: globalid
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.0'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.0'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: pgmq-ruby
48
62
  requirement: !ruby/object:Gem::Requirement
@@ -228,6 +242,7 @@ files:
228
242
  - lib/pgbus/process/wake_signal.rb
229
243
  - lib/pgbus/process/worker.rb
230
244
  - lib/pgbus/queue_factory.rb
245
+ - lib/pgbus/queue_name_validator.rb
231
246
  - lib/pgbus/rate_counter.rb
232
247
  - lib/pgbus/recurring/already_recorded.rb
233
248
  - lib/pgbus/recurring/command_job.rb