rails
Safe redirects in Rails 7
Enforcing canonical URLs by redirecting to params
is not safe and may raise an exception. Use strong params with allow_other_host: false
for security.
URLs with slugs
Sometimes you’ll want to perform a redirect in a Rails controller to enforce canonical URLs. This comes into play when dealing with slugs. Take this URL, for example:
https://app.example.com/projects/27-mvp-launch
By Rails convention, 27
is the primary key of Project
model, and mvp-launch
is a slug (generated by customizing to_param or via a gem like friendly_id). The ActiveRecord.find
implementation will discard the non-integer part when performing a database query.
Project.find("27")
Project.find("27-mvp-launch")
Enforcing canonical URLs with redirects
A consequence of this magic is that the slug portion of the ID doesn’t really matter, since it is discarded. If the name of the project changed and URLs no longer match, or if the end-user mistyped the URL, Rails will still dutifully load it.
/projects/27-mvp-launch
/projects/27-mvp-lunch
/projects/27-someone-elses-project
To solve this, your controller can check for the accuracy of the slug and redirect if it doesn’t match the expected value.
class ProjectsController < ApplicationController
before_action :redirect_to_canonical, only: [:edit, :show]
private
def redirect_to_canonical
canonical_slug = @project.to_param
if params[:id] != canonical_slug
redirect_to({id: canonical_slug}, status: :moved_permanently)
end
end
end
@project
has been omitted for brevity.)
Retaining query params
The trouble with such a redirect is that any extra params in the original request are lost. Depending on the app, that might be undesirable. For example, consider a search
route within a project that has a URL like this:
https://app.example.com/projects/27-mvp-lunch/search?q=forecast&sort=relevance
Performing a redirect_to
to apply a canonical project ID strips out the search params:
redirect_to({project_id: canonical_slug}, status: :moved_permanently)
# => https://app.example.com/projects/27-mvp-launch/search
?
in the URL is lost.
The obvious solution to retaining the search params is to reuse the params
object, but this is not allowed.
redirect_to params.merge(project_id: canonical_slug), status: :moved_permanently
#=> ActionController::UnfilteredParameters - unable to convert unpermitted parameters to hash
params
like this.
A secure solution
Performing a redirect by constructing a URL based on user input is inherently risky, and is a well-documented security vulnerability. This is essentially what you are doing when you call redirect_to params.merge(...)
, because params
can contain arbitrary data the user has appended to the URL.
Mitigate redirect attacks using allow_other_host: false
Starting with Rails 7, new Rails apps are configured to automatically prevent a certain type of redirect attack. However, if you have updated your project from an earlier Rails version, this setting might not be enabled.
# Protect from open redirect attacks in `redirect_back_or_to` and `redirect_to`.
Rails.application.config.action_controller.raise_on_open_redirects = true
To be safe, pass allow_other_host: false
to redirect_to
whenever you are redirecting to a URL that may have been built using user-provided data, as shown in the examples below.
Use strong params
The ActionController::UnfilteredParameters
error means that Rails wants you to use strong parameters, which is the safest solution.
search_params = params.permit(:q, :sort, :project_id)
redirect_to(
search_params.merge(project_id: canonical_slug),
allow_other_host: false,
status: :moved_permanently
)
permit
to enforce which query params will survive the redirect.
Bypass strong params using request.params
If you are okay with the user appending arbitrary query params without enforcing an allow-list, you can bypass the strong params requirement by using request.params
directly:
redirect_to(
request.params.merge(project_id: canonical_slug),
allow_other_host: false,
status: :moved_permanently
)