Overview
Security testing examines software systems for vulnerabilities, misconfigurations, and weaknesses that could compromise confidentiality, integrity, or availability. Unlike functional testing that validates features work correctly, security testing assumes adversarial conditions where attackers actively seek to break the system.
Security testing operates across multiple dimensions. Static analysis examines source code without execution, identifying patterns like SQL concatenation or weak cryptographic algorithms. Dynamic testing exercises the running application, probing for injection vulnerabilities, authentication bypasses, and authorization failures. Penetration testing simulates real attacks using the same techniques adversaries employ.
The discipline emerged from recognizing that functional correctness does not guarantee security. An application can pass all functional tests while containing critical vulnerabilities. A login form might correctly authenticate valid credentials but fail to prevent brute force attacks. An API might return proper data for authorized requests but leak information through timing differences or verbose error messages.
Security testing reveals several vulnerability classes. Input validation failures allow attackers to inject malicious content, from SQL commands that exfiltrate databases to JavaScript that hijacks user sessions. Authentication weaknesses enable unauthorized access through default credentials, session fixation, or cryptographic flaws. Authorization bugs permit privilege escalation where users access resources beyond their permissions. Configuration errors expose sensitive data, enable unnecessary services, or grant excessive permissions.
Ruby applications face specific security considerations. Dynamic typing obscures type-related vulnerabilities until runtime. Metaprogramming features create attack surfaces through unsafe evaluation or method manipulation. Rails conventions provide security by default but require understanding to avoid bypassing protections. The extensive gem ecosystem introduces supply chain risks where dependencies contain vulnerabilities.
# Vulnerable to SQL injection
User.where("email = '#{params[:email]}'")
# Safe parameterized query
User.where(email: params[:email])
The example shows how identical functionality differs in security characteristics. The first version concatenates user input directly into SQL, allowing injection attacks. The second version uses parameterized queries where the database driver handles escaping, preventing injection regardless of input content.
Key Principles
Security testing follows defense in depth, applying multiple protective layers rather than relying on single controls. A web application combines input validation, parameterized queries, and least privilege database accounts. If attackers bypass input validation, parameterized queries prevent SQL injection. If they find an injection vulnerability, limited database permissions reduce damage.
Fail securely ensures that failures default to safe states. Authentication errors deny access rather than granting it. Decryption failures reject data rather than returning partial results. Session validation failures destroy sessions rather than continuing with uncertain state. Ruby applications implement this through explicit guards:
def authorized_user
user = User.find_by(token: request.headers['Authorization'])
return nil unless user
return nil unless user.active?
return nil unless user.has_permission?(required_permission)
user
end
def show
user = authorized_user
return head :unauthorized unless user
render json: resource
end
Each validation point returns nil on failure. The controller checks for nil and denies access. Missing any check results in denial, not access.
Least privilege limits each component to minimum required permissions. Database users receive only needed operations on specific tables. File system access restricts to necessary directories with minimal permissions. API tokens grant scoped permissions for intended operations. Service accounts run with constrained capabilities. This principle reduces blast radius when components are compromised.
Complete mediation validates every access to protected resources. Caching authorization decisions creates windows where permissions change but cached results grant outdated access. Security testing verifies that permission checks occur on every request:
# Vulnerable - caches permission check
class DocumentsController
before_action :set_document
def show
# Permission checked once in set_document
render json: @document
end
def update
# Assumes set_document permission still valid
@document.update(document_params)
end
private
def set_document
@document = Document.find(params[:id])
return head :forbidden unless current_user.can_access?(@document)
end
end
This code checks permissions in set_document but the document is then used in multiple actions. If user permissions change between actions or if document access rules are complex, this creates vulnerabilities.
Open design assumes attackers know the system. Security depends on secrets like keys and passwords, not on obscurity of algorithms or architectures. Security testing examines whether systems remain secure when design details are public. This drives using proven cryptographic algorithms rather than custom schemes, publishing security documentation, and allowing code review.
Separation of duties prevents single points of trust. Code review separates author from approver. Deployment requires multiple authorized individuals. Sensitive operations need two-factor authentication. Security testing validates these controls by attempting to bypass them with single-actor scenarios.
Psychological acceptability ensures security controls remain usable. Overly complex security mechanisms encourage workarounds. Sixteen-character randomly generated passwords prompt users to write them down. Certificate validation that fails frequently trains users to click through warnings. Security testing includes usability assessment to identify controls that users will circumvent.
Security testing validates trust boundaries where data crosses security contexts. Network boundaries between public internet and private systems. Process boundaries between user code and kernel. User boundaries between different accounts. Component boundaries between microservices. Each boundary requires validation, authentication, and authorization. Ruby applications often cross boundaries:
# User boundary - different user accounts
def transfer_funds(from_account_id, to_account_id, amount)
from = Account.find(from_account_id)
raise Forbidden unless current_user.owns?(from)
# Transfer logic
end
# Component boundary - calling external service
def fetch_user_data(user_id)
response = HTTP.auth("Bearer #{service_token}")
.get("#{api_url}/users/#{user_id}")
# Process response
end
Security Implications
SQL injection remains prevalent despite being well understood. Attackers inject SQL commands through unvalidated input, exfiltrating data, modifying records, or executing stored procedures. Ruby applications are vulnerable when constructing queries through string interpolation or concatenation:
# Vulnerable to SQL injection
email = params[:email]
User.where("email = '#{email}'")
# Input: ' OR '1'='1
# Query: SELECT * FROM users WHERE email = '' OR '1'='1'
# Safe using placeholders
User.where("email = ?", params[:email])
# Safe using hash conditions
User.where(email: params[:email])
ActiveRecord provides protection through parameterized queries, but applications using raw SQL or string interpolation remain vulnerable. Security testing probes all database queries with injection payloads including quotes, comment markers, union statements, and time-based blind injection markers.
Cross-site scripting (XSS) injects malicious JavaScript into pages viewed by other users. Stored XSS persists in databases, affecting all users who view the content. Reflected XSS appears in immediate responses, often through URL parameters. DOM-based XSS manipulates client-side JavaScript. Rails escapes output by default, but manual HTML generation or use of html_safe creates vulnerabilities:
# Vulnerable - marks user content as safe
def show
@comment = Comment.find(params[:id])
end
# In view
<div><%= @comment.content.html_safe %></div>
# Safe - automatic escaping
<div><%= @comment.content %></div>
# Safe when HTML needed - sanitize first
<div><%= sanitize @comment.content %></div>
Security testing injects XSS payloads including script tags, event handlers, and data URIs. Testing verifies escaping occurs and that whitelisting approaches block malicious constructs.
Cross-site request forgery (CSRF) tricks users into executing unwanted actions. An attacker's site includes requests to the target application, which the browser sends with the user's cookies. Rails includes CSRF protection through tokens, but JSON APIs often disable it:
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
end
This creates vulnerabilities when APIs accept cookie authentication. Security testing verifies CSRF protection for state-changing operations and confirms APIs use token-based rather than cookie-based authentication.
Authentication vulnerabilities enable unauthorized access. Weak password policies allow brute force attacks. Missing account lockout permits unlimited attempts. Insecure password storage exposes credentials when databases leak. Session fixation allows attackers to hijack sessions by setting session IDs. Timing attacks infer valid usernames through response time differences:
# Vulnerable to timing attack
def authenticate(username, password)
user = User.find_by(username: username)
return false unless user
return false unless user.password_matches?(password)
true
end
The early return when the user does not exist creates a timing difference. Finding a valid username produces longer response times because password checking occurs. Security testing measures response times across many requests to identify timing channels.
Authorization failures allow privilege escalation. Insecure direct object references permit accessing resources by guessing IDs. Missing function-level access control allows calling administrative functions. Forced browsing accesses URLs directly without following intended navigation. Horizontal privilege escalation accesses other users' data. Vertical privilege escalation gains administrative privileges:
# Vulnerable to insecure direct object reference
def show
@document = Document.find(params[:id])
render json: @document
end
# Safe - scopes to current user
def show
@document = current_user.documents.find(params[:id])
render json: @document
end
Security testing attempts accessing resources with different user accounts, without authentication, and with tampered parameters.
Sensitive data exposure occurs through inadequate encryption, insecure transmission, or information leakage. Applications transmit sensitive data over HTTP rather than HTTPS. Error messages reveal stack traces, database schemas, or internal paths. API responses include unnecessary sensitive fields. Logs record passwords or credit card numbers:
# Vulnerable - logs sensitive data
logger.info "User login: #{params[:username]}, #{params[:password]}"
# Safe - logs only necessary data
logger.info "User login attempt: #{params[:username]}"
# Vulnerable - exposes sensitive fields
def show
render json: @user
end
# Safe - serializes specific fields
def show
render json: @user.as_json(only: [:id, :username, :created_at])
end
Security testing examines network traffic for unencrypted transmission, reviews logs for sensitive data, and analyzes API responses for excessive information disclosure.
XML External Entity (XXE) attacks exploit XML parsers that process external entity references. Attackers reference local files, internal network resources, or cause denial of service. Ruby XML parsers require configuration to prevent XXE:
# Vulnerable - default parsing
require 'nokogiri'
doc = Nokogiri::XML(params[:xml])
# Safe - disable external entities
doc = Nokogiri::XML(params[:xml]) do |config|
config.nonet.noent
end
Security testing submits XML with external entity references pointing to sensitive files and internal URLs.
Deserialization vulnerabilities occur when applications deserialize untrusted data. Ruby's Marshal and YAML can execute code during deserialization. Attackers craft malicious serialized objects that execute commands:
# Vulnerable - deserializes untrusted data
data = Marshal.load(params[:data])
# Safer - use JSON
data = JSON.parse(params[:data])
# Safe when YAML needed - use safe_load
data = YAML.safe_load(params[:data], permitted_classes: [Symbol])
Security testing attempts deserializing malicious payloads and verifies applications use safe deserialization methods.
Ruby Implementation
Security testing in Ruby combines multiple approaches. Unit tests verify individual security controls. Integration tests examine security across components. Security-focused tests probe for specific vulnerability classes. Static analysis identifies code patterns indicating vulnerabilities.
RSpec provides the foundation for security tests. Tests verify that controls prevent attacks rather than just testing happy paths:
RSpec.describe UsersController, type: :request do
describe 'SQL injection prevention' do
it 'prevents SQL injection in email parameter' do
payload = "' OR '1'='1"
get '/users', params: { email: payload }
expect(response).to have_http_status(:success)
users = JSON.parse(response.body)
expect(users.length).to eq(0)
end
it 'handles single quotes safely' do
user = create(:user, email: "test@example.com")
get '/users', params: { email: "test@example.com'" }
expect(response).to have_http_status(:success)
users = JSON.parse(response.body)
expect(users.length).to eq(0)
end
end
describe 'XSS prevention' do
it 'escapes HTML in user input' do
user = create(:user, name: '<script>alert("xss")</script>')
get "/users/#{user.id}"
expect(response.body).not_to include('<script>')
expect(response.body).to include('<script>')
end
it 'sanitizes HTML in markdown fields' do
post = create(:post, content: '<img src=x onerror=alert(1)>')
get "/posts/#{post.id}"
expect(response.body).not_to include('onerror=')
end
end
describe 'authorization' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:document) { create(:document, user: user1) }
it 'prevents accessing other users documents' do
sign_in user2
get "/documents/#{document.id}"
expect(response).to have_http_status(:forbidden)
end
it 'prevents unauthorized document modification' do
sign_in user2
patch "/documents/#{document.id}", params: { title: 'hacked' }
expect(response).to have_http_status(:forbidden)
expect(document.reload.title).not_to eq('hacked')
end
it 'prevents mass assignment attacks' do
sign_in user1
patch "/users/#{user1.id}", params: { admin: true }
expect(user1.reload.admin).to eq(false)
end
end
end
These tests verify that security controls work under attack conditions. SQL injection tests use actual injection payloads. XSS tests check that dangerous content gets escaped. Authorization tests attempt accessing resources without permissions.
Testing authentication requires verifying both positive and negative cases:
RSpec.describe 'Authentication' do
describe 'password requirements' do
it 'rejects weak passwords' do
post '/users', params: {
user: { email: 'test@example.com', password: '12345' }
}
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('too short')
end
it 'requires passwords with mixed case and numbers' do
post '/users', params: {
user: { email: 'test@example.com', password: 'password' }
}
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'timing attack prevention' do
it 'takes similar time for valid and invalid usernames' do
valid_user = create(:user, username: 'valid')
valid_times = 10.times.map do
start = Time.now
post '/login', params: { username: 'valid', password: 'wrong' }
Time.now - start
end
invalid_times = 10.times.map do
start = Time.now
post '/login', params: { username: 'invalid', password: 'wrong' }
Time.now - start
end
valid_avg = valid_times.sum / valid_times.size
invalid_avg = invalid_times.sum / invalid_times.size
difference = (valid_avg - invalid_avg).abs
expect(difference).to be < 0.01
end
end
describe 'session security' do
it 'regenerates session ID on login' do
user = create(:user)
get '/login'
old_session_id = session.id
post '/login', params: {
username: user.username,
password: user.password
}
expect(session.id).not_to eq(old_session_id)
end
it 'sets secure and httponly cookie flags' do
user = create(:user)
post '/login', params: {
username: user.username,
password: user.password
}
cookie = response.headers['Set-Cookie']
expect(cookie).to include('secure')
expect(cookie).to include('httponly')
end
end
end
Custom security test helpers encapsulate common testing patterns:
module SecurityTestHelpers
def inject_sql(field:, payload:)
get request_path, params: { field => payload }
expect(response).to be_successful
expect(extract_sql_error).to be_nil
end
def inject_xss(field:, payload:)
post request_path, params: { field => payload }
get response_path
expect(response.body).not_to include_unescaped(payload)
end
def attempt_csrf_attack(method:, path:, params:)
# Send request without CSRF token
public_send(method, path, params: params)
expect(response).to have_http_status(:forbidden)
end
def verify_rate_limiting(path:, limit:)
(limit + 5).times { get path }
expect(response).to have_http_status(:too_many_requests)
end
def test_authorization_matrix(resource:, user_types:, actions:)
user_types.each do |user_type, should_allow|
actions.each do |action|
user = create(:user, role: user_type)
sign_in user
send(action, resource)
if should_allow.include?(action)
expect(response).not_to have_http_status(:forbidden)
else
expect(response).to have_http_status(:forbidden)
end
end
end
end
end
These helpers make security tests more readable and maintainable. The authorization matrix helper tests multiple user types against multiple actions, ensuring complete coverage.
Testing encryption and cryptography requires verifying correct algorithm usage:
RSpec.describe 'Cryptographic security' do
describe 'password storage' do
it 'uses bcrypt for password hashing' do
user = create(:user, password: 'secure_password')
expect(user.password_digest).to start_with('$2a$')
end
it 'uses sufficient work factor' do
user = create(:user, password: 'secure_password')
cost = user.password_digest.split('$')[2].to_i
expect(cost).to be >= 12
end
end
describe 'data encryption' do
it 'encrypts sensitive fields at rest' do
user = create(:user, ssn: '123-45-6789')
raw_value = User.connection.select_value(
"SELECT ssn FROM users WHERE id = #{user.id}"
)
expect(raw_value).not_to eq('123-45-6789')
expect(user.ssn).to eq('123-45-6789')
end
it 'uses AES-256-GCM for encryption' do
encrypted_value = User.encrypt('sensitive')
algorithm = extract_algorithm_from_encrypted(encrypted_value)
expect(algorithm).to eq('aes-256-gcm')
end
end
describe 'token generation' do
it 'generates cryptographically secure tokens' do
tokens = 100.times.map { User.generate_token }
# Check randomness
expect(tokens.uniq.length).to eq(100)
# Check entropy
token = tokens.first
expect(token.length).to be >= 32
expect(token).to match(/\A[a-zA-Z0-9_-]+\z/)
end
end
end
Testing Approaches
Static Application Security Testing (SAST) analyzes source code without execution. Tools scan for vulnerability patterns like SQL injection, XSS, insecure cryptography, and hardcoded secrets. SAST integrates into development workflows through pre-commit hooks, CI/CD pipelines, or IDE plugins.
Brakeman provides SAST for Ruby on Rails applications:
# Gemfile
group :development, :test do
gem 'brakeman'
end
# Rakefile
require 'brakeman'
task :security_scan do
result = Brakeman.run(
app_path: '.',
output_formats: [:json],
output_files: ['tmp/brakeman.json'],
quiet: true
)
if result.errors.any? || result.warnings.any?
puts "Security issues found!"
exit 1
end
end
Configure Brakeman to match project needs:
# config/brakeman.yml
---
:skip_checks:
- DetailedExceptions # Allow detailed errors in development
:check_arguments: true
:safe_methods:
- safe_yaml_load
:output_formats:
- json
- html
:output_files:
- brakeman-report.json
- brakeman-report.html
Dynamic Application Security Testing (DAST) tests running applications. DAST tools send malicious payloads, fuzz inputs, and attempt common attacks. Unlike SAST which examines code, DAST validates runtime behavior including configuration, deployment, and infrastructure.
OWASP ZAP provides automated DAST scanning:
require 'zap'
RSpec.describe 'DAST security scan', type: :system do
before(:all) do
@zap = Zap::Client.new(
host: 'localhost',
port: 8080
)
# Start application for scanning
@server = start_test_server
end
after(:all) do
@server.stop
end
it 'passes automated security scan' do
target_url = 'http://localhost:3000'
# Spider the application
scan_id = @zap.spider.scan(target_url)
wait_for_scan_completion(@zap, scan_id)
# Active scan for vulnerabilities
scan_id = @zap.ascan.scan(target_url)
wait_for_scan_completion(@zap, scan_id)
# Get results
alerts = @zap.core.alerts(baseurl: target_url)
# Filter out acceptable risks
critical_alerts = alerts.select { |a| a['risk'] == 'High' }
expect(critical_alerts).to be_empty,
"Found critical vulnerabilities: #{critical_alerts.map { |a| a['alert'] }}"
end
end
Dependency scanning identifies vulnerable libraries. Ruby applications depend on gems that may contain security vulnerabilities. Bundler-audit checks gem dependencies against known vulnerability databases:
# Gemfile
group :development, :test do
gem 'bundler-audit'
end
# Rakefile
require 'bundler/audit/task'
Bundler::Audit::Task.new
# Run as: rake bundle:audit
# In CI/CD pipeline
task :security_check do
require 'bundler/audit/cli'
Bundler::Audit::CLI.start(['check', '--update'])
end
Automate dependency scanning in CI:
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2
- run: gem install bundler-audit
- run: bundle-audit check --update
Fuzz testing sends random or malformed inputs to find crashes and unexpected behavior. Fuzzing reveals input validation issues, crashes, and edge cases:
require 'fuzzbert'
RSpec.describe 'Input fuzzing' do
it 'handles fuzzed email inputs safely' do
fuzzer = FuzzBert::Fuzzer.new
100.times do
fuzzed_input = fuzzer.fuzz_string
expect {
post '/users', params: { user: { email: fuzzed_input } }
}.not_to raise_error
expect(response.status).to be_between(200, 499)
end
end
it 'handles fuzzed JSON safely' do
100.times do
json = generate_fuzzed_json
expect {
post '/api/data',
params: json,
headers: { 'Content-Type': 'application/json' }
}.not_to raise_error
end
end
end
def generate_fuzzed_json
mutations = [
'{"key": ' + 'x' * 10000 + '}', # Large string
'{"key": ' + '[' * 1000, # Unbalanced brackets
'{"key": null}' + "\x00" * 100, # Null bytes
'{"' + 'x' * 1000 + '": "val"}', # Large key
'[' + '1,' * 1000 + ']' # Large array
]
mutations.sample
end
Penetration testing simulates real attacks using the same techniques malicious actors employ. Penetration tests combine automated scanning with manual testing. Testers attempt privilege escalation, lateral movement, and data exfiltration.
Security regression testing verifies that security fixes remain effective. When vulnerabilities are discovered and fixed, regression tests prevent reintroduction:
RSpec.describe 'Security regressions' do
# CVE-2023-XXXX - SQL injection in search
it 'prevents SQL injection regression (CVE-2023-XXXX)' do
payload = "'; DROP TABLE users; --"
get '/search', params: { q: payload }
expect(response).to be_successful
expect { User.count }.not_to raise_error
end
# SEC-2023-001 - Authorization bypass in admin panel
it 'prevents admin authorization bypass (SEC-2023-001)' do
regular_user = create(:user, admin: false)
sign_in regular_user
get '/admin/users'
expect(response).to have_http_status(:forbidden)
# Attempted bypass through parameter manipulation
get '/admin/users', params: { admin: true }
expect(response).to have_http_status(:forbidden)
end
end
Tools & Ecosystem
Brakeman scans Rails applications for security vulnerabilities through static analysis. It identifies SQL injection, XSS, command injection, unsafe redirects, and mass assignment vulnerabilities:
# Install
gem install brakeman
# Basic scan
brakeman
# Detailed scan with all checks
brakeman -A
# Output to file
brakeman -o report.html
# Scan specific paths
brakeman -p app/controllers,app/models
# Compare against previous scan
brakeman -o current.json
brakeman --compare previous.json
Integrate Brakeman into development workflow:
# config/brakeman.ignore
{
"ignored_warnings": [
{
"warning_type": "SQL Injection",
"fingerprint": "abc123...",
"note": "False positive - input sanitized upstream"
}
]
}
Bundler-audit checks dependencies for known vulnerabilities by comparing Gemfile.lock against the Ruby Advisory Database:
# Install
gem install bundler-audit
# Update vulnerability database
bundle-audit update
# Check current dependencies
bundle-audit check
# Include development dependencies
bundle-audit check --verbose
Automate regular scanning:
# lib/tasks/security.rake
namespace :security do
desc 'Run security checks'
task audit: :environment do
require 'bundler/audit/cli'
puts "Updating vulnerability database..."
Bundler::Audit::CLI.start(['update'])
puts "Scanning dependencies..."
Bundler::Audit::CLI.start(['check'])
end
end
RuboCop Security provides security-focused linting rules. It detects insecure code patterns that might not constitute immediate vulnerabilities but indicate security concerns:
# Gemfile
gem 'rubocop-security', require: false
# .rubocop.yml
require:
- rubocop-security
Security/Eval:
Enabled: true
Security/JSONLoad:
Enabled: true
Security/MarshalLoad:
Enabled: true
Security/YAMLLoad:
Enabled: true
Rack::Attack provides rate limiting and request throttling to prevent brute force attacks, denial of service, and API abuse:
# Gemfile
gem 'rack-attack'
# config/initializers/rack_attack.rb
class Rack::Attack
# Throttle login attempts
throttle('limit login attempts', limit: 5, period: 60.seconds) do |req|
if req.path == '/login' && req.post?
req.ip
end
end
# Throttle API requests
throttle('limit api requests', limit: 100, period: 1.hour) do |req|
req.ip if req.path.start_with?('/api/')
end
# Block suspicious requests
blocklist('block suspicious patterns') do |req|
Rack::Attack::Fail2Ban.filter("suspicious-#{req.ip}",
maxretry: 3,
findtime: 10.minutes,
bantime: 1.hour) do
req.path.include?('../') ||
req.query_string.include?('<script') ||
req.user_agent =~ /suspicious-pattern/
end
end
end
Test rate limiting:
RSpec.describe 'Rate limiting' do
it 'blocks excessive login attempts' do
6.times do |i|
post '/login', params: { username: 'test', password: 'wrong' }
if i < 5
expect(response).to have_http_status(:unauthorized)
else
expect(response).to have_http_status(:too_many_requests)
end
end
end
end
Secure Headers gem sets security-related HTTP headers:
# Gemfile
gem 'secure_headers'
# config/initializers/secure_headers.rb
SecureHeaders::Configuration.default do |config|
config.x_frame_options = 'DENY'
config.x_content_type_options = 'nosniff'
config.x_xss_protection = '1; mode=block'
config.x_download_options = 'noopen'
config.x_permitted_cross_domain_policies = 'none'
config.referrer_policy = 'strict-origin-when-cross-origin'
config.csp = {
default_src: ["'self'"],
script_src: ["'self'", "'unsafe-inline'"],
style_src: ["'self'", "'unsafe-inline'"],
img_src: ["'self'", 'data:', 'https:'],
font_src: ["'self'", 'data:'],
connect_src: ["'self'"],
frame_ancestors: ["'none'"]
}
end
Devise provides authentication with security features built in:
# Gemfile
gem 'devise'
# Configure security settings
Devise.setup do |config|
# Lockout after failed attempts
config.lock_strategy = :failed_attempts
config.unlock_strategy = :time
config.maximum_attempts = 5
config.unlock_in = 1.hour
# Password requirements
config.password_length = 12..128
config.pepper = ENV['DEVISE_PEPPER']
# Session security
config.expire_auth_token_on_timeout = true
config.timeout_in = 30.minutes
config.remember_for = 2.weeks
end
Common Pitfalls
Testing only happy paths creates false security confidence. Tests verify features work correctly but miss attack scenarios. A login test confirms valid credentials grant access but fails to test rate limiting, timing attacks, or session fixation:
# Insufficient - only tests valid case
it 'logs in successfully' do
user = create(:user)
post '/login', params: { username: user.username, password: user.password }
expect(response).to redirect_to(dashboard_path)
end
# Comprehensive - tests security controls
describe 'login security' do
it 'logs in with valid credentials' do
user = create(:user)
post '/login', params: { username: user.username, password: user.password }
expect(response).to redirect_to(dashboard_path)
end
it 'rejects invalid credentials' do
user = create(:user)
post '/login', params: { username: user.username, password: 'wrong' }
expect(response).to have_http_status(:unauthorized)
end
it 'prevents brute force attacks' do
user = create(:user)
10.times { post '/login', params: { username: user.username, password: 'wrong' } }
expect(response).to have_http_status(:too_many_requests)
end
it 'prevents timing attacks' do
# Test timing consistency
end
it 'regenerates session on login' do
# Test session fixation prevention
end
end
Relying on client-side validation creates vulnerabilities. JavaScript validation improves user experience but attackers bypass it through browser tools, intercepting proxies, or direct API calls. Server-side validation must duplicate all security checks:
# Vulnerable - assumes client sent valid data
def create
@user = User.create(params[:user])
render json: @user
end
# Secure - validates on server
def create
@user = User.new(user_params)
unless @user.valid?
return render json: { errors: @user.errors }, status: :unprocessable_entity
end
@user.save
render json: @user
end
private
def user_params
params.require(:user).permit(:email, :username, :password)
end
Insufficient authorization testing misses horizontal and vertical privilege escalation. Tests verify authorized users can access resources but fail to confirm unauthorized users cannot:
# Insufficient
it 'shows user profile' do
sign_in @user
get "/users/#{@user.id}"
expect(response).to be_successful
end
# Comprehensive
describe 'profile access' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
it 'shows own profile' do
sign_in user1
get "/users/#{user1.id}"
expect(response).to be_successful
end
it 'prevents viewing other profiles' do
sign_in user1
get "/users/#{user2.id}"
expect(response).to have_http_status(:forbidden)
end
it 'prevents unauthenticated access' do
get "/users/#{user1.id}"
expect(response).to have_http_status(:unauthorized)
end
end
Trusting decoded JWTs without validation enables token manipulation. JWTs contain encoded data that clients can read and modify. Applications must verify signatures before trusting claims:
# Vulnerable - trusts JWT without verification
def current_user
token = request.headers['Authorization']&.split(' ')&.last
payload = JWT.decode(token, nil, false).first
User.find(payload['user_id'])
end
# Secure - verifies signature
def current_user
token = request.headers['Authorization']&.split(' ')&.last
payload = JWT.decode(token, ENV['JWT_SECRET'], true, algorithm: 'HS256').first
User.find(payload['user_id'])
rescue JWT::DecodeError, JWT::ExpiredSignature
nil
end
Using eval or instance_eval with user input enables code injection. Ruby's dynamic features allow executing arbitrary code. Applications must never evaluate user-controlled strings:
# Vulnerable
def calculate
result = eval(params[:expression])
render json: { result: result }
end
# Safer - use safe evaluation library
def calculate
parser = MathParser.new
result = parser.evaluate(params[:expression])
render json: { result: result }
end
Ignoring CSRF protection for JSON APIs creates vulnerabilities when APIs accept cookie authentication. CSRF tokens protect against cross-site requests but JSON APIs often disable them without implementing alternative protections:
# Vulnerable
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
# Uses cookie-based authentication
def data
render json: current_user.data
end
end
# Secure - uses token authentication
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_token
def data
render json: current_user.data
end
private
def authenticate_token
token = request.headers['Authorization']
@current_user = User.find_by(api_token: token)
head :unauthorized unless @current_user
end
end
Mass assignment vulnerabilities occur when models accept attributes without whitelisting. Strong parameters protect against this but developers sometimes bypass them:
# Vulnerable
def update
@user.update(params[:user])
end
# Secure
def update
@user.update(user_params)
end
private
def user_params
params.require(:user).permit(:name, :email)
end
# Also vulnerable - bypasses strong parameters
def update
@user.attributes = params[:user].slice(:name, :email, :admin)
end
Logging sensitive data creates compliance violations and security risks. Logs persist in files, centralized logging systems, and backups. Logged passwords or tokens enable compromise:
# Vulnerable
logger.info "Processing payment: #{params.inspect}"
logger.debug "User authentication: #{username}, #{password}"
# Secure
logger.info "Processing payment for order #{order_id}"
logger.debug "User authentication attempt for: #{username}"
# Filter sensitive parameters
Rails.application.config.filter_parameters += [
:password, :password_confirmation, :secret, :token,
:api_key, :credit_card, :ssn
]
Reference
Security Testing Checklist
| Category | Check | Test Method |
|---|---|---|
| Input Validation | SQL injection prevention | Submit SQL metacharacters in all inputs |
| Input Validation | XSS prevention | Submit script tags and event handlers |
| Input Validation | Command injection prevention | Submit shell metacharacters |
| Input Validation | Path traversal prevention | Submit ../ sequences |
| Authentication | Password strength enforcement | Attempt weak passwords |
| Authentication | Account lockout | Exceed failed login threshold |
| Authentication | Session fixation prevention | Verify session ID changes on login |
| Authentication | Logout functionality | Confirm session invalidation |
| Authorization | Horizontal privilege escalation | Access other users' resources |
| Authorization | Vertical privilege escalation | Access admin functions as regular user |
| Authorization | Direct object reference | Access resources by ID manipulation |
| Session Management | Secure cookie flags | Verify secure and httponly flags |
| Session Management | Session timeout | Verify idle timeout enforced |
| Session Management | Concurrent session limits | Test multiple simultaneous logins |
| Cryptography | Strong algorithms | Verify AES-256, bcrypt usage |
| Cryptography | Secure random generation | Test token randomness |
| Cryptography | Key management | Verify keys not hardcoded |
| Data Protection | HTTPS enforcement | Attempt HTTP access |
| Data Protection | Sensitive data encryption | Verify encryption at rest |
| Data Protection | Information disclosure | Review error messages and responses |
| API Security | Rate limiting | Exceed request thresholds |
| API Security | CSRF protection | Submit requests without tokens |
| API Security | Authentication token security | Test token expiration and revocation |
| Configuration | Security headers | Verify CSP, HSTS, X-Frame-Options |
| Configuration | Default credentials | Test for default admin accounts |
| Configuration | Debug mode disabled | Verify debug off in production |
| Dependencies | Vulnerable libraries | Run bundler-audit |
| Dependencies | Outdated dependencies | Check for available updates |
Common Vulnerability Patterns
| Vulnerability | Ruby Pattern | Secure Alternative |
|---|---|---|
| SQL Injection | User.where("name = '#{params[:name]}')") | User.where(name: params[:name]) |
| XSS | raw(user_input) | sanitize(user_input) |
| XSS | user_input.html_safe | Automatic escaping via <%= %> |
| Command Injection | system("ls #{params[:dir]}") | Open3.capture3("ls", params[:dir]) |
| Path Traversal | File.read(params[:file]) | File.read(Rails.root.join('safe', basename(params[:file]))) |
| Unsafe Deserialization | Marshal.load(params[:data]) | JSON.parse(params[:data]) |
| Unsafe YAML | YAML.load(user_input) | YAML.safe_load(user_input, permitted_classes: [Symbol]) |
| Mass Assignment | User.new(params[:user]) | User.new(user_params) |
| Weak Random | rand(1000000) | SecureRandom.random_number(1000000) |
| Timing Attack | user && user.password == password | ActiveSupport::SecurityUtils.secure_compare |
Security Testing Tools
| Tool | Purpose | Usage |
|---|---|---|
| Brakeman | Static analysis for Rails | brakeman -A |
| bundler-audit | Dependency vulnerability scanning | bundle-audit check |
| RuboCop Security | Security linting | rubocop --only Security |
| OWASP ZAP | Dynamic application testing | zap-cli quick-scan |
| Rack::Attack | Rate limiting and throttling | Middleware configuration |
| SecureHeaders | HTTP security headers | Rails initializer |
| Devise | Secure authentication | Authentication gem |
| bcrypt | Password hashing | User.has_secure_password |
HTTP Security Headers
| Header | Purpose | Recommended Value |
|---|---|---|
| Strict-Transport-Security | Force HTTPS | max-age=31536000; includeSubDomains |
| X-Frame-Options | Prevent clickjacking | DENY |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff |
| X-XSS-Protection | Enable XSS filter | 1; mode=block |
| Content-Security-Policy | Restrict resource loading | default-src 'self' |
| Referrer-Policy | Control referrer information | strict-origin-when-cross-origin |
| Permissions-Policy | Control browser features | geolocation=(), microphone=() |
Authentication Security Controls
| Control | Implementation | Test Verification |
|---|---|---|
| Password Complexity | Minimum length, character requirements | Submit weak passwords |
| Account Lockout | Lock after N failed attempts | Exceed failure threshold |
| Session Timeout | Idle and absolute timeouts | Wait for timeout period |
| Multi-factor Authentication | TOTP or SMS codes | Test login flow |
| Password Reset Security | Time-limited tokens | Test token expiration |
| Session Regeneration | New ID on privilege change | Verify ID changes |
| Secure Password Storage | bcrypt with cost >= 12 | Inspect password digest |
Common Security Test Assertions
| Test | RSpec Assertion |
|---|---|
| Successful response | expect(response).to be_successful |
| Unauthorized access | expect(response).to have_http_status(:unauthorized) |
| Forbidden access | expect(response).to have_http_status(:forbidden) |
| Rate limited | expect(response).to have_http_status(:too_many_requests) |
| Content escaped | expect(response.body).to include('<script>') |
| Content not present | expect(response.body).not_to include('') |
| Session regenerated | expect(session.id).not_to eq(old_session_id) |
| Cookie flags set | expect(response.headers['Set-Cookie']).to include('secure') |
| No error raised | expect { action }.not_to raise_error |
| Value unchanged | expect(model.reload.attribute).to eq(original_value) |