Resque 3.2.0 shipped on October 16 with a single change, listed under Security: resque-web now rejects state-changing requests that come from another site. Until this release, resque-web had no cross-site request forgery (CSRF) protection at all. A page on any other site could submit a form to your dashboard and clear your failed jobs or delete a queue, as long as the browser it ran in could reach resque-web and was logged in to it.
The fix is small and most teams can upgrade without touching anything. There is one breaking change, though: the Retry and Remove links on individual failed jobs are now POST forms, and the old GET URLs return 404. If anything in your stack calls those URLs directly, it will stop working.
At Scout Monitoring, we trace Resque jobs in a lot of Rails apps, and resque-web is usually mounted right next to them. Here’s what changed, who was exposed, and what to check before you upgrade.
What Was Exposed Before 3.2
resque-web is a Sinatra app. Most teams mount it inside Rails with mount Resque::Server.new, at: "/resque" and put authentication in front of it, either a route constraint that checks the Devise session or HTTP basic auth. Authentication decides who can reach the dashboard. It doesn’t stop another site from making the browser of someone who can reach it send a request.
Rails’ protect_from_forgery doesn’t help here either. It runs as a callback in your controllers, and a mounted Rack app like resque-web never goes through a controller. Before 3.2, these were the actions a cross-site form could trigger:
- Delete a queue, along with every job waiting in it (
POST /queues/:id/remove) - Delete a single queued job (
POST /queues/:id/remove-job, added in 3.1) - Clear all failed jobs, or only the ones already retried
- Retry all failed jobs at once
- Retry or remove one failed job, which were plain GET links
How much of that a real attacker could reach depended on your authentication. Rails sets session cookies to SameSite=Lax by default, which stops browsers from sending them with cross-site POSTs, so a Devise-protected dashboard was mostly covered for the POST actions. But Lax still sends the cookie on a top-level GET navigation, which is exactly what the single-job Retry and Remove links were. And SameSite is a cookie setting, so it does nothing for HTTP basic auth, which is what Resque’s own demo app uses.
We checked this against both versions. On Resque 3.1.0, a POST to /failed/clear carrying Origin: http://evil.test was accepted and the failed jobs were gone. On 3.2.0, the same request gets a 403 and nothing changes.
How the 3.2 Check Works
The new check is a before filter on Resque::Server that runs on every request that can change something, meaning anything other than read-only methods like GET and HEAD. It reads two headers that browsers attach automatically:
Sec-Fetch-Sitecomes first. Onlysame-origin(a form on resque-web itself) andnone(the user typed the URL) are allowed. Everything else, includingsame-site, gets403 Cross-site request rejected.Originis the fallback for older browsers that don’t sendSec-Fetch-Site. The request is allowed only if its host and port match resque-web’s own.- Neither header means the request didn’t come from a browser, so it’s allowed. curl, scripts, and server-side tools keep working.
Rejecting same-site matters for anyone hosting resque-web on a subdomain. A page on blog.example.com counts as the same site as jobs.example.com, so SameSite cookies are sent between them. Resque 3.2 only trusts requests from resque-web’s own origin.
Because the filter sits on Resque::Server, it also covers routes that plugins add. If you use resque-scheduler or another plugin that adds its own tabs, their POST actions are protected without any change on their side.
Here’s what we saw for each case, sending a POST /failed/clear to each version:
| Request | Resque 3.1.0 | Resque 3.2.0 |
|---|---|---|
| No browser headers (curl, a script) | Cleared | Cleared |
Sec-Fetch-Site: same-origin |
Cleared | Cleared |
Sec-Fetch-Site: same-site |
Cleared | 403 |
Sec-Fetch-Site: cross-site |
Cleared | 403 |
Origin from another host, no Sec-Fetch-Site |
Cleared | 403 |
The Breaking Change: Retry and Remove Are POST-Only
The second half of the release is the one that can break things. Retrying or removing a single failed job used to be a link:
GET /failed/requeue/42
GET /failed/remove/42
GET /failed/:queue/requeue/42
GET /failed/:queue/remove/42
In 3.2 those routes only accept POST, and the dashboard renders them as small forms. They work the same way from the dashboard, including the “Retrying…” state. Anything that sent a GET to them now gets a 404:
- A bookmarked or pasted link to retry a specific failed job
- A script, cron job, or ChatOps command that retries failures by URL
- An internal admin tool that links straight into resque-web
The fix for scripts is to switch to POST. Because those requests don’t carry browser headers, they pass the new check with no other changes:
curl -X POST -u "$RESQUE_WEB_USER:$RESQUE_WEB_PASSWORD" \
https://app.example.com/resque/failed/requeue/42
Better still, skip the dashboard and call Resque directly from a Rake task or console, for example Resque::Failure.requeue(42). That doesn’t depend on resque-web’s routes at all.
What to Check Before You Upgrade
A quick search finds most of what could break:
grep -rnE "failed/(.+/)?(requeue|remove)/" . --include="*.rb" --include="*.sh" --include="*.yml" --include="*.erb"
grep -rn "Resque::Server" config/ app/ lib/
The first finds anything that builds retry or remove URLs. The second shows where and how resque-web is mounted, so you can confirm what authentication sits in front of it. Also check any tools outside the repo that call resque-web, such as Slack bots or runbooks with saved links.
Two edge cases are worth knowing about. If resque-web sits behind a proxy that rewrites the Host header, the Origin fallback can reject legitimate requests, but only from old browsers, since current Chrome, Firefox, Safari, and Edge all send Sec-Fetch-Site and never reach the fallback. And if you built a custom page on another subdomain that posts to resque-web, that page now gets a 403 and needs to move into resque-web or call Resque directly.
Watching Failed Jobs After the Upgrade
The risk with the breaking change is silent. A retry script that starts getting 404s won’t raise an error inside your app. The failed jobs it used to retry just stay failed.
Scout traces every Resque job with its duration, throughput, and errors, and it marks deploys on your charts. After you upgrade, a job whose errors no longer recover, or whose throughput drops because retries stopped arriving, stands out against the deploy marker. That’s the point to look for anything still calling the old GET routes.
Running Resque in production? Start monitoring with Scout for free and see every job’s errors, queue time, and throughput, before and after the upgrade.