Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion app/assets/javascripts/solid_queue_monitor/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/solid_queue_monitor/assets_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(?<base>[A-Za-z0-9_]+)-(?<hash>[a-f0-9]+)(?<ext>\.css|\.js)\z/

Expand Down
17 changes: 17 additions & 0 deletions app/helpers/solid_queue_monitor/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<title>Solid Queue Monitor - <%= content_for?(:title) ? yield(:title) : 'Dashboard' %></title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags_if_enabled %>
<%= stylesheet_link_tag asset_url_for('application.css'), nonce: content_security_policy_nonce %>
</head>
<body class="solid_queue_monitor"
Expand Down
2 changes: 2 additions & 0 deletions app/views/solid_queue_monitor/failed_jobs/_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
<td class="actions-cell">
<div class="job-actions">
<form method="post" action="<%= retry_failed_job_path(id: job.id) %>" class="inline-form">
<%= csrf_token_field_if_enabled %>
<button type="submit" class="action-button retry-button">Retry</button>
</form>
<form method="post"
action="<%= discard_failed_job_path(id: job.id) %>"
class="inline-form"
data-confirm="Are you sure you want to discard this job?">
<%= csrf_token_field_if_enabled %>
<button type="submit" class="action-button discard-button">Discard</button>
</form>
</div>
Expand Down
1 change: 1 addition & 0 deletions app/views/solid_queue_monitor/failed_jobs/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</div>

<form method="post" id="failed-jobs-form">
<%= 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' },
Expand Down
3 changes: 3 additions & 0 deletions app/views/solid_queue_monitor/jobs/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,22 @@
<div class="job-actions">
<% if @failed_execution %>
<form action="<%= retry_failed_job_path(id: @failed_execution.id) %>" method="post" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="redirect_to" value="<%= job_path(@job) %>">
<button type="submit" class="action-button retry-button">Retry</button>
</form>
<form action="<%= discard_failed_job_path(id: @failed_execution.id) %>"
method="post"
class="inline-form"
data-confirm="Are you sure you want to discard this job?">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="redirect_to" value="<%= failed_jobs_path %>">
<button type="submit" class="action-button discard-button">Discard</button>
</form>
<% end %>
<% if @scheduled_execution %>
<form action="<%= execute_scheduled_job_path(id: @scheduled_execution.id) %>" method="post" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="redirect_to" value="<%= scheduled_jobs_path %>">
<button type="submit" class="action-button retry-button">Execute Now</button>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
<% if failed_execution %>
<div class="job-actions">
<form method="post" action="<%= retry_failed_job_path(id: failed_execution.id) %>" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="redirect_to" value="<%= root_path %>">
<button type="submit" class="action-button retry-button">Retry</button>
</form>
<form method="post"
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 %>
<input type="hidden" name="redirect_to" value="<%= root_path %>">
<button type="submit" class="action-button discard-button">Discard</button>
</form>
Expand Down
2 changes: 2 additions & 0 deletions app/views/solid_queue_monitor/queues/_job_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
<% if failed_execution %>
<div class="job-actions">
<form method="post" action="<%= retry_failed_job_path(id: failed_execution.id) %>" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="redirect_to" value="<%= queue_details_path(queue_name: @queue_name) %>">
<button type="submit" class="action-button retry-button">Retry</button>
</form>
<form method="post"
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 %>
<input type="hidden" name="redirect_to" value="<%= queue_details_path(queue_name: @queue_name) %>">
<button type="submit" class="action-button discard-button">Discard</button>
</form>
Expand Down
2 changes: 2 additions & 0 deletions app/views/solid_queue_monitor/queues/_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<td class="actions-cell">
<% if paused %>
<form action="<%= resume_queue_path %>" method="post" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="queue_name" value="<%= queue_name %>">
<button type="submit" class="action-button resume-button" title="Resume queue processing">Resume</button>
</form>
Expand All @@ -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 %>
<input type="hidden" name="queue_name" value="<%= queue_name %>">
<button type="submit" class="action-button pause-button" title="Pause queue processing">Pause</button>
</form>
Expand Down
2 changes: 2 additions & 0 deletions app/views/solid_queue_monitor/queues/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
<div class="section-header-right">
<% if @paused %>
<form action="<%= resume_queue_path %>" method="post" class="inline-form">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="queue_name" value="<%= @queue_name %>">
<input type="hidden" name="redirect_to" value="<%= queue_details_path(queue_name: @queue_name) %>">
<button type="submit" class="action-button resume-button">Resume Queue</button>
</form>
<% else %>
<form action="<%= pause_queue_path %>" method="post" class="inline-form" data-confirm="Are you sure you want to pause this queue?">
<%= csrf_token_field_if_enabled %>
<input type="hidden" name="queue_name" value="<%= @queue_name %>">
<input type="hidden" name="redirect_to" value="<%= queue_details_path(queue_name: @queue_name) %>">
<button type="submit" class="action-button pause-button">Pause Queue</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</div>

<form id="scheduled-jobs-form" method="post">
<%= 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' },
Expand Down
1 change: 1 addition & 0 deletions app/views/solid_queue_monitor/workers/_row.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
method="post"
class="inline-form"
data-confirm="Remove this dead process from the registry?">
<%= csrf_token_field_if_enabled %>
<button type="submit" class="action-button discard-button" title="Remove dead process">Remove</button>
</form>
<% else %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/solid_queue_monitor/workers/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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
</a>
<form id="prune-all-form" action="<%= prune_workers_path %>" method="post" class="is-hidden"></form>
<form id="prune-all-form" action="<%= prune_workers_path %>" method="post" class="is-hidden"><%= csrf_token_field_if_enabled %></form>
<% end %>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions lib/generators/solid_queue_monitor/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion lib/solid_queue_monitor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions spec/helpers/solid_queue_monitor/application_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('<meta name="csrf-token" content="x">'.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')
Expand Down
Loading
Loading