Ruby on Rails Security Testing: ActiveRecord Injection, Mass Assignment, and Rails-Specific Vulnerabilities

Rails has a deserved reputation for being a "secure by default" framework. CSRF protection is on by default. ActiveRecord parameterizes queries automatically. Strong parameters block mass assignment. The Content Security Policy DSL is built in. For a developer coming from PHP or raw SQL, it feels bulletproof.

It isn't. Rails apps have their own vulnerability class: misuse of the safety features themselves. A developer who knows that where("name = '#{params[:name]}'")} is vulnerable would be horrified — but the same developer might not recognize that User.find_by(params[:query]) is exploitable, or that skipping protect_from_forgery on a single controller action opens their entire session to cross-site attack.

This guide covers the Rails-specific vulnerability landscape: where the defaults break down, how to test for it, and what evidence to look for in code review and dynamic analysis.

The Rails Security Model: What It Actually Protects

Understanding what Rails does for you is the first step to understanding where it doesn't. Rails provides:

  • ActiveRecord query parameterization — when you use the standard query API, ActiveRecord binds parameters correctly. User.where(email: params[:email]) is safe.
  • Strong parametersActionController::Parameters requires explicit permit lists before ActiveRecord will accept them, preventing mass assignment of arbitrary attributes.
  • CSRF protectionprotect_from_forgery is included in ActionController::Base by default, requiring a valid authenticity token on state-changing requests.
  • XSS output encoding — ERB templates HTML-encode output by default. <%= user.name %> is safe; <%= raw user.name %> is not.
  • Encrypted cookies — the session cookie is signed and optionally encrypted with the secret_key_base.

Each of these has an escape hatch that developers reach for — sometimes legitimately, sometimes recklessly. Security testing of Rails apps is largely an exercise in finding where those escape hatches were misused.

SQL Injection via ActiveRecord

ActiveRecord prevents injection when you use its query DSL correctly. The failure modes are string interpolation and certain methods that accept raw SQL fragments.

String interpolation in where clauses

# Vulnerable — direct string interpolation
User.where("name = '#{params[:name]}'")
User.where("role = '#{role}' AND active = true")

# Safe — parameterized binding
User.where("name = ?", params[:name])
User.where("role = ? AND active = true", role)
User.where(name: params[:name])  # hash syntax — always safe

The vulnerable form passes the user's input directly into the SQL string. A payload like ' OR '1'='1 becomes WHERE name = '' OR '1'='1', returning all rows.

Order and group injection

Rails does not parameterize order() and group() clauses, because SQL doesn't support binding column names as parameters. If user input controls the sort order, you have SQL injection:

# Vulnerable — user controls the ORDER BY column
User.order(params[:sort])
# Payload: "name; DROP TABLE users--"

# Safe — whitelist allowed columns
ALLOWED_SORT_COLUMNS = %w[name email created_at].freeze
sort = ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : 'created_at'
User.order(sort)

find_by with hash input from params

This is the Rails-specific injection that trips up developers who know to avoid string interpolation:

# Vulnerable — user controls the entire query hash
User.find_by(params[:query])
# params[:query] = { "admin" => true } — bypasses authentication

# Also vulnerable
User.where(params.permit!)  # permit! whitelists everything

# Safe
User.find_by(email: params[:email])

When params[:query] is a hash (which it is if the request sends query[email]=x&query[admin]=true), find_by will query on all those keys. This allows attribute injection even without touching SQL syntax.

Testing approach

For dynamic testing: send standard SQL injection payloads in all parameters. For code review: search the codebase for the dangerous patterns:

# Grep patterns for code review
grep -rn 'where("' app/
grep -rn 'order(params' app/
grep -rn 'group(params' app/
grep -rn 'find_by(params' app/
grep -rn 'permit!' app/

Mass Assignment and Strong Parameters

Rails 4+ introduced strong parameters, requiring .permit(:field1, :field2) before passing params to ActiveRecord. The mass assignment vulnerability surfaces when permit lists are too permissive or bypassed.

Overly permissive permit lists

# Vulnerable — permits all attributes including role, admin flag
def user_params
  params.require(:user).permit!
end

# Also vulnerable — accidentally permits sensitive fields
def user_params
  params.require(:user).permit(:name, :email, :password, :role, :admin)
end

# Safe — only permits fields users should control
def user_params
  params.require(:user).permit(:name, :email, :password, :password_confirmation)
end

Nested attributes injection

# Vulnerable — _destroy permits record deletion
accepts_nested_attributes_for :addresses, allow_destroy: true
# An attacker can delete any address by sending: addresses_attributes[0][_destroy]=1&addresses_attributes[0][id]=

# Safe — validate the association belongs to the current user before accepting destruction

Testing mass assignment

Send additional fields in every POST/PUT/PATCH request:

# Test by adding sensitive fields to form submissions
curl -X POST https://app.example.com/users \
  -d "user[name]=test&user[email]=test@example.com&user[role]=admin&user[admin]=true"

# Or with nested attributes
curl -X PATCH https://app.example.com/users/123 \
  -d "user[name]=test&user[is_superuser]=1&user[subscription_plan]=enterprise"

Check the response and the actual database state. If a field was updated that shouldn't be user-controllable, you have mass assignment.

CSRF Protection Gaps

Rails enables CSRF protection by default with protect_from_forgery with: :exception in ApplicationController. The vulnerability appears when it's disabled — or when it's disabled selectively for "convenience."

Common disablement patterns

# In ApplicationController or a specific controller
skip_before_action :verify_authenticity_token  # Disables CSRF on all actions
skip_before_action :verify_authenticity_token, only: [:create, :update]  # Partial

# Also a problem — API controllers that inherit from ActionController::API
# ActionController::API does NOT include CSRF protection
class ApiController < ActionController::API
  # No CSRF protection here
end

API controllers backed by session cookies are vulnerable. If your Rails API uses cookie-based authentication and skips CSRF verification, any website can make authenticated requests on behalf of logged-in users.

Testing CSRF

<!-- CSRF test page — host on a different origin and visit while logged in -->
<html>
<body>
  <form id="csrf-test" action="https://target-app.com/users/profile" method="POST">
    <input name="user[email]" value="attacker@evil.com">
  </form>
  <script>document.getElementById('csrf-test').submit();</script>
</body>
</html>

If the request succeeds without the authenticity_token parameter, CSRF protection is disabled on that endpoint.

XSS in Rails Templates

ERB auto-escapes by default. XSS occurs when developers explicitly bypass escaping:

# Vulnerable — raw() and html_safe bypass encoding
<%= raw @user.bio %>
<%= @user.bio.html_safe %>
<%= link_to "Profile", @user.profile_url %>  # javascript: URLs are not filtered

# Safe — default ERB encoding
<%= @user.bio %>  # HTML-encoded automatically

# Safe link_to with protocol validation
<%= link_to "Profile", @user.profile_url if @user.profile_url.start_with?("https://") %>

JavaScript context injection

# Vulnerable — direct interpolation into JS
<script>
  var userName = "<%= @user.name %>";  # HTML encoding doesn't protect in JS context
</script>

# Safe — use json_escape or json_encode
<script>
  var userName = <%= json_escape(@user.name.to_json) %>;
  var data = <%= raw json_escape(@data.to_json) %>;
</script>

Searching for XSS patterns

grep -rn 'html_safe\|raw(' app/views/
grep -rn 'javascript:' app/views/  # Check for javascript: URL patterns
grep -rn 'link_to.*params\[' app/views/  # User-controlled URL in link_to

File Upload Vulnerabilities

Rails file uploads commonly use CarrierWave, Shrine, or Active Storage. Each has security considerations.

CarrierWave path traversal and content-type bypass

# Vulnerable — no extension or content-type validation
class AvatarUploader < CarrierWave::Uploader::Base
  storage :file
  # No extension whitelist — accepts any file type
end

# Vulnerable — extension check can be bypassed with double extension
# File: shell.php.jpg — extension check passes, PHP might execute it depending on server config

# Safe
class AvatarUploader < CarrierWave::Uploader::Base
  storage :fog  # Cloud storage, not local

  def extension_allowlist
    %w[jpg jpeg gif png webp]
  end

  def content_type_allowlist
    /image\//
  end
end

Active Storage content-type verification

# Active Storage does not validate content type by default
# An attacker can upload a PHP/HTML file with image/jpeg content-type header

# Validate in the model
validates :avatar, content_type: [:png, :jpg, :jpeg, :gif],
                   size: { less_than: 5.megabytes }

Testing file upload

# Test double extension bypass
curl -X POST https://app.example.com/avatars \
  -F "avatar=@shell.php.jpg;type=image/jpeg"

# Test content-type mismatch
curl -X POST https://app.example.com/avatars \
  -F "avatar=@malicious.html;type=image/png"

# Test path traversal in filename (less common with modern gems)
# filename: ../../../../etc/passwd

Secret Key Base Exposure

Rails uses secret_key_base to sign (and optionally encrypt) session cookies. Exposing this key allows an attacker to forge valid session cookies and authenticate as any user — including administrators.

Common exposure vectors

  • Committed to version control in config/secrets.yml or config/credentials.yml.enc with the master key also committed
  • Set in environment variables that are logged or accessible via debug endpoints
  • Exposed via Spring Boot Actuator equivalent — /rails/info/properties in development mode accessible in production
  • Disclosed in error pages when config.consider_all_requests_local = true in production
  • Leaked through the ENV object in Rails console output committed to logs

Testing for secret_key_base exposure

# Check if Rails info endpoint is accessible
curl https://app.example.com/rails/info/properties

# Check for environment variable disclosure in error pages
# Trigger an error and look for RAILS_ or SECRET_ in the response

# If you have the secret_key_base, forge a session cookie:
# Using the rails-session-forgery tool or manual ActiveSupport::MessageVerifier
require 'active_support'
secret = "your_leaked_secret_key_base"
verifier = ActiveSupport::MessageVerifier.new(secret)
payload = { "session_id" => "...", "user_id" => 1 }
forged_cookie = verifier.generate(payload)

Secure configuration

# config/environments/production.rb
config.consider_all_requests_local = false  # Never show full error pages in production
config.action_dispatch.show_exceptions = true

# Ensure secret_key_base comes from environment only
# config/credentials.yml.enc should be encrypted and the key NOT in the repo
# Or use ENV["SECRET_KEY_BASE"] in config/secrets.yml

Insecure Direct Object References in Rails

Rails routing and controllers make IDOR easy to introduce. The standard pattern — User.find(params[:id]) without authorization — is endemic in Rails codebases.

# Vulnerable — no authorization, any user can access any record
def show
  @document = Document.find(params[:id])
end

# Safe — scope to current user
def show
  @document = current_user.documents.find(params[:id])
  # Raises ActiveRecord::RecordNotFound (returns 404) if not found or not owned
end

# Also safe — explicit authorization check
def show
  @document = Document.find(params[:id])
  authorize! :read, @document  # Using CanCanCan or Pundit
end

Testing IDOR in Rails

# Create an account and note your resource IDs
# Then attempt to access IDs +1 and -1 from another account

# Authenticated as user A (document 101 is yours)
curl -b "session_cookie=user_a_session" https://app.example.com/documents/102
curl -b "session_cookie=user_a_session" https://app.example.com/documents/100

# Test with 0, negative numbers, and UUIDs from public pages
# Test IDOR on edit, update, delete, download — not just show

Regex-Based Route Constraints and ReDoS

Rails allows regex constraints on routes. Poorly anchored regexes can cause ReDoS (Regular Expression Denial of Service):

# Vulnerable — catastrophic backtracking possible
constraints(id: /[0-9]+([0-9]+)+/)  # Nested quantifiers

# Also a common mistake — using format: /.*/
constraints(format: /json|xml|.*/)  # The .* allows anything, including long inputs

# Safe — use simple, anchored patterns
constraints(id: /\d{1,10}/)  # Max 10 digits

Static Analysis: Brakeman

Brakeman is the de facto static analysis tool for Rails. It understands Rails-specific patterns — routes, ActiveRecord, template rendering — in ways that generic SAST tools don't.

# Install and run
gem install brakeman
brakeman /path/to/rails/app

# Focused output
brakeman --format json -o brakeman-report.json /path/to/app
brakeman --format html -o brakeman-report.html /path/to/app

# Only show high-confidence findings
brakeman --confidence-level 2 /path/to/app

# Check specific warning types
brakeman -t SQL,XSS,MassAssignment /path/to/app

Brakeman categorizes findings by confidence (High/Medium/Weak) and warning type. High-confidence findings should be treated as confirmed vulnerabilities. Weak findings need manual review — Brakeman may flag sanitized values it can't trace through the call graph.

Critical Brakeman warning types to prioritize

  • SQL Injection — string interpolation in ActiveRecord queries
  • Cross-Site Scriptingraw(), html_safe, unescaped parameters in templates
  • Mass Assignmentpermit!, overly permissive permit lists
  • Remote Code Executioneval, send, constantize with user input
  • Command Injection — backtick operators, system(), exec(), Open3.popen3 with user input
  • Redirectredirect_to params[:url] — open redirect
  • File AccessFile.read(params[:filename]) — path traversal

Dependency Scanning: bundler-audit

Rails applications have many gem dependencies. bundler-audit checks your Gemfile.lock against the Ruby Advisory Database:

# Install
gem install bundler-audit

# Update the advisory database first
bundle-audit update

# Scan the current project
bundle-audit check

# Scan a specific Gemfile.lock
bundle-audit check --gemfile-lock path/to/Gemfile.lock

# Output formats
bundle-audit check --format json
bundle-audit check --format text --output report.txt

Critical gem vulnerabilities to look for in Rails apps:

  • Rails framework CVEs — authentication bypass, CSRF bypass, ReDoS
  • Nokogiri (XML/HTML parser) — XXE, memory corruption
  • Puma/Unicorn/Thin (web server) — HTTP request smuggling, DoS
  • Devise (authentication) — account enumeration, timing attacks
  • Carrierwave/Paperclip (file upload) — path traversal, SSRF

Authorization Gem Misconfigurations

Most Rails apps use Pundit or CanCanCan for authorization. Both have common misconfiguration patterns.

Pundit: missing policy files and verify_authorized

# Pundit policies are opt-in — actions without a policy check are unrestricted
class DocumentsController < ApplicationController
  include Pundit::Authorization
  after_action :verify_authorized  # Enforces that authorize() was called
  after_action :verify_policy_scoped, only: :index

  def show
    @document = Document.find(params[:id])
    authorize @document  # If you forget this, verify_authorized raises an error
  end
end

# Without verify_authorized, a developer can forget authorize() entirely
# and the action will be accessible to any authenticated user

CanCanCan: load_and_authorize_resource gaps

# Using skip_authorization_check removes the safety net
class AdminController < ApplicationController
  skip_authorization_check  # Everything in this controller bypasses authorization

  def show
    # No authorization check — any authenticated user reaches here
    @report = Report.find(params[:id])
  end
end

Rails Console and Debug Endpoint Exposure

Several Rails features that are invaluable in development are catastrophic in production:

  • rails console web interfaces — web-console gem exposes an interactive Ruby console in error pages. In production with consider_all_requests_local = true, this is RCE for anyone who can trigger an error.
  • /rails/info/routes — exposes all application routes. Should not be accessible in production.
  • /rails/mailers — email preview. Exposes email templates and potentially sensitive data.
  • Rack::MiniProfiler — SQL query profiler that leaks query details if enabled in production.
# Check if debug endpoints are accessible
curl https://app.example.com/rails/info/routes
curl https://app.example.com/rails/info/properties
curl https://app.example.com/rails/mailers

# Check web-console gem in Gemfile.lock
grep "web-console" Gemfile.lock

# Correct Gemfile grouping
gem 'web-console', group: :development  # Not in production group

Testing Checklist for Rails Applications

  • Run Brakeman with --confidence-level 1 and triage all findings
  • Run bundle-audit check against the production Gemfile.lock
  • Test all numeric ID parameters for IDOR — try adjacent IDs from a different account
  • Search code for permit!, html_safe, raw(, skip_before_action :verify_authenticity_token
  • Test all sort/order parameters for SQL injection
  • Check if /rails/info/ routes are accessible in production
  • Verify secret_key_base is not in version control or logs
  • Test file upload endpoints with content-type mismatch and double extension
  • Verify all state-changing API endpoints require the authenticity token or use token-based auth with proper origin validation
  • Check that Pundit's verify_authorized is used in all controllers, or that CanCanCan's load_and_authorize_resource is not skipped
  • Test all redirect destinations — redirect_to params[:return_to] is a classic open redirect
  • Confirm config.force_ssl = true in production and HSTS headers are set

Ironimo scans Rails applications using the same dynamic testing methodology professional pentesters apply — SQL injection probes across all parameters, IDOR testing across ID ranges, CSRF verification on state-changing endpoints, and file upload validation.

Pair it with Brakeman and bundler-audit in your CI pipeline for full-spectrum Rails security coverage.

Start free scan
← Back to blog