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")
These two queries will both find project with id=27. Magic!

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
These all load Project #27. The user-friendliness of the slug now has the opposite effect: the URL no longer accurately describes the 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
Before loading the project, you can check whether the slug matches what is expected (e.g. “27-mvp-lunch” vs “27-mvp-launch”) and redirect if they are different. (The code that loads the @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
Everything after the ? 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
Rails won’t let you use 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
This setting is enabled in new Rails 7 apps. Apps upgraded from older versions must explicitly opt in.

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
)
Use 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
)

References

Share this? Copy link

Feedback? Email me!

Hi! 👋 I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts →

Open source projects

mattbrictson/nextgen

Generate your next Rails app interactively! This template includes production-ready recommendations for testing, security, developer productivity, and modern frontends. Plus optional Vite support! ⚡️

97
Updated 18 days ago

mattbrictson/tomo

A friendly CLI for deploying Rails apps ✨

376
Updated 18 days ago

More on GitHub →