diff --git a/CHANGELOG.md b/CHANGELOG.md index ee947a8..aec03f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- `SolidQueueMonitor.csrf_protection_enabled` config option (default `false`). When enabled, the engine no longer skips `verify_authenticity_token`: all dashboard forms embed an `authenticity_token`, `csrf_meta_tags` are added to the layout, and unverified `POST` requests to the destructive actions (retry / discard / pause / resume / execute / reject / remove / prune) are rejected. Disabled by default for backward compatibility, since the gem does not assume the host app has a session store. See the new "CSRF Protection" section in the README for requirements. + ## [2.1.0] - 2026-05-13 ### Added diff --git a/README.md b/README.md index 3dba0ec..2999ca7 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ SolidQueueMonitor.setup do |config| # Disable the chart on the overview page to skip chart queries entirely # config.show_chart = true + + # Enable CSRF protection for the dashboard's destructive actions (opt-in) + # config.csrf_protection_enabled = false end # Optional: inherit from a host-app controller to plug into your existing auth. @@ -140,6 +143,27 @@ If you don't need the job activity chart, disable it to skip chart queries entir config.show_chart = false ``` +### CSRF Protection + +The dashboard's destructive actions (retry, discard, pause, resume, execute, reject, remove/prune workers) are all `POST` requests. By default CSRF protection is **disabled**, because the gem does not assume the host application has a session store (it works in API-only apps without one). + +If your host app has a session store and the dashboard is mounted on the same origin, you should enable CSRF protection: + +```ruby +config.csrf_protection_enabled = true +``` + +When enabled: + +- All dashboard forms embed an `authenticity_token`, and `csrf_meta_tags` are added to the layout for JS/`fetch`-driven requests. +- Unverified `POST` requests are rejected by Rails' standard `verify_authenticity_token` (returns `422 Unprocessable Entity`). Safe methods (`GET`/`HEAD`) pass through. + +Requirements: + +- The host app has a session store configured (e.g. `config.session_store :cookie_store`). +- `config.api_only` is not enabled (or session middleware is otherwise present). +- The dashboard is mounted on the same origin as the host app, so `form_authenticity_token` works. + ### Authentication By default, Solid Queue Monitor does not require authentication to access the dashboard. This makes it easy to get started in development environments. diff --git a/app/assets/javascripts/solid_queue_monitor/application.js b/app/assets/javascripts/solid_queue_monitor/application.js index 3f73653..421d4c9 100644 --- a/app/assets/javascripts/solid_queue_monitor/application.js +++ b/app/assets/javascripts/solid_queue_monitor/application.js @@ -322,7 +322,9 @@ function bulkSubmit(action, promptMsg) { var ids = checkedBoxes().map(function (checkbox) { return checkbox.value; }); if (ids.length === 0 || !window.confirm(promptMsg)) return; - Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"]')).forEach(function (input) { input.remove(); }); + // Only clear previously-appended job id inputs. Other hidden inputs + // (e.g. the CSRF authenticity_token) must be preserved. + Array.prototype.slice.call(form.querySelectorAll('input[type="hidden"][name="job_ids[]"]')).forEach(function (input) { input.remove(); }); form.action = action; ids.forEach(function (id) { appendHidden('job_ids[]', id); }); form.submit(); diff --git a/app/controllers/solid_queue_monitor/application_controller.rb b/app/controllers/solid_queue_monitor/application_controller.rb index 9a84c4a..6981c7f 100644 --- a/app/controllers/solid_queue_monitor/application_controller.rb +++ b/app/controllers/solid_queue_monitor/application_controller.rb @@ -13,7 +13,12 @@ class ApplicationController < SolidQueueMonitor.base_controller_class.safe_const before_action :authenticate, if: -> { SolidQueueMonitor::AuthenticationService.authentication_required? } layout 'solid_queue_monitor/application' - skip_before_action :verify_authenticity_token + + # CSRF protection is opt-in (config.csrf_protection_enabled). By default the + # token check is skipped so the dashboard works in hosts without a session + # store. When the host enables it, the standard verify_authenticity_token + # before_action runs and unverified POSTs are rejected. + skip_before_action :verify_authenticity_token, unless: -> { SolidQueueMonitor.csrf_protection_enabled } def set_flash_message(message, type) # Store in instance variable for access in views diff --git a/app/controllers/solid_queue_monitor/assets_controller.rb b/app/controllers/solid_queue_monitor/assets_controller.rb index 9d03c83..95f03d7 100644 --- a/app/controllers/solid_queue_monitor/assets_controller.rb +++ b/app/controllers/solid_queue_monitor/assets_controller.rb @@ -4,6 +4,10 @@ module SolidQueueMonitor class AssetsController < ApplicationController skip_before_action :authenticate, raise: false + # Public read-only assets: exempt from CSRF so the cross-origin JavaScript + # guard doesn't reject GETs for the JS asset when csrf_protection_enabled. + skip_forgery_protection + MIME_TYPES = { '.css' => 'text/css', '.js' => 'application/javascript' }.freeze FINGERPRINT_PATTERN = /\A(?[A-Za-z0-9_]+)-(?[a-f0-9]+)(?\.css|\.js)\z/ diff --git a/app/helpers/solid_queue_monitor/application_helper.rb b/app/helpers/solid_queue_monitor/application_helper.rb index 7efbea1..d4c15ed 100644 --- a/app/helpers/solid_queue_monitor/application_helper.rb +++ b/app/helpers/solid_queue_monitor/application_helper.rb @@ -25,6 +25,23 @@ def message_class(type) type.to_s == 'success' ? 'message-success' : 'message-error' end + # Hidden authenticity_token field for raw HTML POST forms. + # Renders nothing unless CSRF protection is enabled, so hosts without a + # session store are unaffected (form_authenticity_token needs a session). + def csrf_token_field_if_enabled + return ''.html_safe unless SolidQueueMonitor.csrf_protection_enabled + + hidden_field_tag(:authenticity_token, form_authenticity_token) + end + + # CSRF meta tags for JS/fetch-driven POSTs (defense in depth). + # Only emitted when CSRF protection is enabled, for the same reason. + def csrf_meta_tags_if_enabled + return ''.html_safe unless SolidQueueMonitor.csrf_protection_enabled + + csrf_meta_tags + end + def queue_link(queue_name, css_class: nil) return '-' if queue_name.blank? diff --git a/app/views/layouts/solid_queue_monitor/application.html.erb b/app/views/layouts/solid_queue_monitor/application.html.erb index cd827b7..d0a4c23 100644 --- a/app/views/layouts/solid_queue_monitor/application.html.erb +++ b/app/views/layouts/solid_queue_monitor/application.html.erb @@ -4,6 +4,7 @@ Solid Queue Monitor - <%= content_for?(:title) ? yield(:title) : 'Dashboard' %> + <%= csrf_meta_tags_if_enabled %> <%= stylesheet_link_tag asset_url_for('application.css'), nonce: content_security_policy_nonce %>
+ <%= csrf_token_field_if_enabled %>
+ <%= csrf_token_field_if_enabled %>
diff --git a/app/views/solid_queue_monitor/failed_jobs/index.html.erb b/app/views/solid_queue_monitor/failed_jobs/index.html.erb index 976befe..bd72428 100644 --- a/app/views/solid_queue_monitor/failed_jobs/index.html.erb +++ b/app/views/solid_queue_monitor/failed_jobs/index.html.erb @@ -15,6 +15,7 @@
+ <%= csrf_token_field_if_enabled %> <% columns = [ { sort_key: nil, label: tag.input(type: 'checkbox', id: 'select-all', class: 'select-all-checkbox') }, { sort_key: :class_name, label: 'Job' }, diff --git a/app/views/solid_queue_monitor/jobs/_header.html.erb b/app/views/solid_queue_monitor/jobs/_header.html.erb index 5a83cc0..7e31814 100644 --- a/app/views/solid_queue_monitor/jobs/_header.html.erb +++ b/app/views/solid_queue_monitor/jobs/_header.html.erb @@ -15,6 +15,7 @@
<% if @failed_execution %> + <%= csrf_token_field_if_enabled %> @@ -22,12 +23,14 @@ method="post" class="inline-form" data-confirm="Are you sure you want to discard this job?"> + <%= csrf_token_field_if_enabled %> <% end %> <% if @scheduled_execution %>
+ <%= csrf_token_field_if_enabled %>
diff --git a/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb b/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb index 4db408d..56fdac2 100644 --- a/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb +++ b/app/views/solid_queue_monitor/overview/_recent_job_row.html.erb @@ -10,6 +10,7 @@ <% if failed_execution %>
+ <%= csrf_token_field_if_enabled %>
@@ -17,6 +18,7 @@ action="<%= discard_failed_job_path(id: failed_execution.id) %>" class="inline-form" data-confirm="Are you sure you want to discard this job?"> + <%= csrf_token_field_if_enabled %> diff --git a/app/views/solid_queue_monitor/queues/_job_row.html.erb b/app/views/solid_queue_monitor/queues/_job_row.html.erb index 27bc4f1..7d2ba8b 100644 --- a/app/views/solid_queue_monitor/queues/_job_row.html.erb +++ b/app/views/solid_queue_monitor/queues/_job_row.html.erb @@ -10,6 +10,7 @@ <% if failed_execution %>
+ <%= csrf_token_field_if_enabled %>
@@ -17,6 +18,7 @@ action="<%= discard_failed_job_path(id: failed_execution.id) %>" class="inline-form" data-confirm="Are you sure you want to discard this job?"> + <%= csrf_token_field_if_enabled %> diff --git a/app/views/solid_queue_monitor/queues/_row.html.erb b/app/views/solid_queue_monitor/queues/_row.html.erb index d5a5213..eb18059 100644 --- a/app/views/solid_queue_monitor/queues/_row.html.erb +++ b/app/views/solid_queue_monitor/queues/_row.html.erb @@ -17,6 +17,7 @@ <% if paused %>
+ <%= csrf_token_field_if_enabled %>
@@ -25,6 +26,7 @@ method="post" class="inline-form" data-confirm="Are you sure you want to pause the <%= queue_name %> queue? Workers will stop processing jobs from this queue."> + <%= csrf_token_field_if_enabled %> diff --git a/app/views/solid_queue_monitor/queues/show.html.erb b/app/views/solid_queue_monitor/queues/show.html.erb index 5fab2b6..8cb6e77 100644 --- a/app/views/solid_queue_monitor/queues/show.html.erb +++ b/app/views/solid_queue_monitor/queues/show.html.erb @@ -11,12 +11,14 @@
<% if @paused %>
+ <%= csrf_token_field_if_enabled %>
<% else %>
+ <%= csrf_token_field_if_enabled %> diff --git a/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb b/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb index 5ddd374..d246429 100644 --- a/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb +++ b/app/views/solid_queue_monitor/scheduled_jobs/index.html.erb @@ -15,6 +15,7 @@
+ <%= csrf_token_field_if_enabled %> <% columns = [ { sort_key: nil, label: tag.input(type: 'checkbox', id: 'scheduled-jobs-select-all') }, { sort_key: :class_name, label: 'Job' }, diff --git a/app/views/solid_queue_monitor/workers/_row.html.erb b/app/views/solid_queue_monitor/workers/_row.html.erb index caf55d5..9691e9b 100644 --- a/app/views/solid_queue_monitor/workers/_row.html.erb +++ b/app/views/solid_queue_monitor/workers/_row.html.erb @@ -13,6 +13,7 @@ method="post" class="inline-form" data-confirm="Remove this dead process from the registry?"> + <%= csrf_token_field_if_enabled %> <% else %> diff --git a/app/views/solid_queue_monitor/workers/index.html.erb b/app/views/solid_queue_monitor/workers/index.html.erb index 1d0f924..931616b 100644 --- a/app/views/solid_queue_monitor/workers/index.html.erb +++ b/app/views/solid_queue_monitor/workers/index.html.erb @@ -24,7 +24,7 @@ data-confirm="Remove all <%= @summary[:dead] %> dead process<%= suffix %>? This will clean up processes that have stopped sending heartbeats."> Prune all - + <% end %>
diff --git a/lib/generators/solid_queue_monitor/templates/initializer.rb b/lib/generators/solid_queue_monitor/templates/initializer.rb index 14a0ae0..80203b0 100644 --- a/lib/generators/solid_queue_monitor/templates/initializer.rb +++ b/lib/generators/solid_queue_monitor/templates/initializer.rb @@ -27,4 +27,11 @@ # Disable the chart on the overview page to skip chart queries entirely. # config.show_chart = true + + # Enable CSRF protection for the dashboard's destructive POST actions. + # Disabled by default for backward compatibility. Requires the host app to + # have a session store (e.g. cookie_store) and the dashboard mounted on the + # same origin. When enabled, all dashboard forms embed an authenticity token + # and unverified POSTs are rejected. + # config.csrf_protection_enabled = false end diff --git a/lib/solid_queue_monitor.rb b/lib/solid_queue_monitor.rb index 9e32ffd..a078aae 100644 --- a/lib/solid_queue_monitor.rb +++ b/lib/solid_queue_monitor.rb @@ -11,7 +11,8 @@ class Error < StandardError; end class << self attr_writer :username, :password, :base_controller_class attr_accessor :jobs_per_page, :authentication_enabled, - :auto_refresh_enabled, :auto_refresh_interval, :show_chart + :auto_refresh_enabled, :auto_refresh_interval, :show_chart, + :csrf_protection_enabled def username resolve_value(@username) @@ -39,6 +40,9 @@ def resolve_value(value) @auto_refresh_enabled = true @auto_refresh_interval = 30 # seconds @show_chart = true + # Disabled by default for backward compatibility: enabling CSRF protection + # requires a session-backed host app, which the gem does not assume. + @csrf_protection_enabled = false def self.setup yield self diff --git a/spec/helpers/solid_queue_monitor/application_helper_spec.rb b/spec/helpers/solid_queue_monitor/application_helper_spec.rb index cb49269..2fe3001 100644 --- a/spec/helpers/solid_queue_monitor/application_helper_spec.rb +++ b/spec/helpers/solid_queue_monitor/application_helper_spec.rb @@ -39,6 +39,36 @@ end end + describe '#csrf_token_field_if_enabled' do + it 'returns an empty string when CSRF protection is disabled' do + allow(SolidQueueMonitor).to receive(:csrf_protection_enabled).and_return(false) + expect(helper.csrf_token_field_if_enabled).to eq('') + end + + it 'renders a hidden authenticity_token field when enabled' do + allow(SolidQueueMonitor).to receive(:csrf_protection_enabled).and_return(true) + allow(helper).to receive(:form_authenticity_token).and_return('abc123') + + result = helper.csrf_token_field_if_enabled + expect(result).to include('type="hidden"') + expect(result).to include('name="authenticity_token"') + expect(result).to include('value="abc123"') + end + end + + describe '#csrf_meta_tags_if_enabled' do + it 'returns an empty string when CSRF protection is disabled' do + allow(SolidQueueMonitor).to receive(:csrf_protection_enabled).and_return(false) + expect(helper.csrf_meta_tags_if_enabled).to eq('') + end + + it 'delegates to csrf_meta_tags when enabled' do + allow(SolidQueueMonitor).to receive(:csrf_protection_enabled).and_return(true) + allow(helper).to receive(:csrf_meta_tags).and_return(''.html_safe) + expect(helper.csrf_meta_tags_if_enabled).to include('name="csrf-token"') + end + end + describe '#queue_link' do it 'renders a link to the queue details page' do result = helper.queue_link('default') diff --git a/spec/requests/solid_queue_monitor/csrf_protection_spec.rb b/spec/requests/solid_queue_monitor/csrf_protection_spec.rb new file mode 100644 index 0000000..f25053e --- /dev/null +++ b/spec/requests/solid_queue_monitor/csrf_protection_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Real CSRF token generation needs a working session store with a key +# generator and cookie salts. The shared request app in spec_helper wires +# session middleware but none of that, so these specs build a small Rack stack +# around the engine and merge in the host application's env config (key +# generator, secret_key_base, cookie salts). Requests still hit the engine +# directly, so paths are engine-relative (e.g. "/queues"). +RSpec.describe 'CSRF protection' do + def build_app_with_session + stack = SolidQueueMonitor::Engine + stack = ActionDispatch::Session::CookieStore.new(stack, key: '_sqm_csrf_session') + stack = ActionDispatch::Cookies.new(stack) + + lambda do |env| + stack.call(Rails.application.env_config.merge(env)) + end + end + + before { @app = build_app_with_session } + + describe 'default configuration' do + it 'is disabled by default' do + expect(SolidQueueMonitor.csrf_protection_enabled).to be(false) + end + end + + context 'when disabled (default)' do + it 'allows a destructive POST without a token' do + post '/pause_queue', params: { queue_name: 'default' } + + expect(response).to have_http_status(:found) + expect(SolidQueue::Pause.exists?(queue_name: 'default')).to be(true) + end + + it 'does not render CSRF meta tags' do + get '/queues' + + expect(response.body).not_to include('name="csrf-token"') + end + + it 'does not embed an authenticity_token in forms' do + create(:solid_queue_job, queue_name: 'default') + + get '/queues' + + expect(response.body).not_to include('name="authenticity_token"') + end + end + + context 'when enabled' do + around do |example| + SolidQueueMonitor.csrf_protection_enabled = true + original_protection = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + example.run + ensure + ActionController::Base.allow_forgery_protection = original_protection + SolidQueueMonitor.csrf_protection_enabled = false + end + + it 'lets safe (GET) requests through' do + get '/queues' + + expect(response).to have_http_status(:ok) + end + + it 'renders CSRF meta tags in the layout' do + get '/queues' + + expect(response.body).to include('name="csrf-token"') + end + + it 'embeds an authenticity_token in destructive forms' do + create(:solid_queue_job, queue_name: 'default') + + get '/queues' + + expect(response.body).to include('name="authenticity_token"') + end + + it 'rejects a destructive POST without a token' do + expect do + post '/pause_queue', params: { queue_name: 'default' } + end.to raise_error(ActionController::InvalidAuthenticityToken) + + expect(SolidQueue::Pause.exists?(queue_name: 'default')).to be(false) + end + + it 'accepts a destructive POST carrying a valid token' do + get '/queues' + token = response.body[/