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 parameters —
ActionController::Parametersrequires explicit permit lists before ActiveRecord will accept them, preventing mass assignment of arbitrary attributes. - CSRF protection —
protect_from_forgeryis included inActionController::Baseby 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.ymlorconfig/credentials.yml.encwith 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/propertiesin development mode accessible in production - Disclosed in error pages when
config.consider_all_requests_local = truein production - Leaked through the
ENVobject 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 Scripting —
raw(),html_safe, unescaped parameters in templates - Mass Assignment —
permit!, overly permissive permit lists - Remote Code Execution —
eval,send,constantizewith user input - Command Injection — backtick operators,
system(),exec(),Open3.popen3with user input - Redirect —
redirect_to params[:url]— open redirect - File Access —
File.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 consoleweb interfaces — web-console gem exposes an interactive Ruby console in error pages. In production withconsider_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 1and triage all findings - Run
bundle-audit checkagainst 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_baseis 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_authorizedis used in all controllers, or that CanCanCan'sload_and_authorize_resourceis not skipped - Test all redirect destinations —
redirect_to params[:return_to]is a classic open redirect - Confirm
config.force_ssl = truein 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