| name | sinatra-security |
| description | Security best practices for Sinatra applications including input validation, CSRF protection, and authentication patterns. Use when hardening applications or conducting security reviews. |
Sinatra Security Skill
Tier 1: Quick Reference - Essential Security
CSRF Protection
# Enable Rack::Protection
use Rack::Protection
# Or specifically CSRF
use Rack::Protection::AuthenticityToken
XSS Prevention
# In ERB templates - always escape by default
<%= user.bio %> # Escaped (safe)
<%== user.bio %> # Raw (dangerous!)
# In JSON responses - use proper JSON encoding
require 'json'
json({ name: user.name }.to_json)
SQL Injection Prevention
# BAD: String interpolation
DB["SELECT * FROM users WHERE email = '#{email}'"]
# GOOD: Parameterized queries
DB["SELECT * FROM users WHERE email = ?", email]
# GOOD: Hash conditions
User.where(email: email)
Secure Sessions
use Rack::Session::Cookie,
secret: ENV['SESSION_SECRET'], # Long random string
same_site: :strict,
httponly: true,
secure: production?
Input Validation
helpers do
def validate_email(email)
email.to_s.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
end
def validate_integer(value)
Integer(value)
rescue ArgumentError, TypeError
nil
end
end
post '/users' do
halt 400, 'Invalid email' unless validate_email(params[:email])
# Process...
end
Authentication Check
helpers do
def authenticate!
halt 401, json({ error: 'Unauthorized' }) unless current_user
end
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
end
before '/admin/*' do
authenticate!
end
Tier 2: Detailed Instructions - Security Implementation
Comprehensive CSRF Protection
Configuration:
class Application < Sinatra::Base
# Enable CSRF protection
use Rack::Protection::AuthenticityToken,
except: [:json], # Skip for JSON APIs with token auth
allow_if: -> (env) {
# Skip for API endpoints with bearer token
env['HTTP_AUTHORIZATION']&.start_with?('Bearer ')
}
# Manual CSRF token generation
helpers do
def csrf_token
session[:csrf] ||= SecureRandom.hex(32)
end
def csrf_tag
"<input type='hidden' name='authenticity_token' value='#{csrf_token}'>"
end
def verify_csrf_token
token = params[:authenticity_token] || request.env['HTTP_X_CSRF_TOKEN']
halt 403, 'Invalid CSRF token' unless token == session[:csrf]
end
end
# Include in forms
post '/users' do
verify_csrf_token unless request.content_type == 'application/json'
# Process...
end
end
In Views:
<form method="POST" action="/users">
<%= csrf_tag %>
<!-- form fields -->
</form>
For AJAX:
// Include CSRF token in AJAX requests
fetch('/users', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name=csrf_token]').value,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
XSS Prevention Strategies
Template Escaping:
# ERB - escape by default
<div><%= user_input %></div>
# Explicitly raw (only for trusted content)
<div><%== trusted_html %></div>
# Sanitize user HTML
require 'sanitize'
helpers do
def sanitize_html(html)
Sanitize.fragment(html, Sanitize::Config::RELAXED)
end
end
# In template
<div><%= sanitize_html(user_bio) %></div>
JSON Responses:
# Always use proper JSON encoding
get '/api/users/:id' do
user = User.find(params[:id])
# BAD: Manual JSON construction
# "{ \"name\": \"#{user.name}\" }" # XSS if name contains quotes
# GOOD: Use JSON library
content_type :json
{ name: user.name, bio: user.bio }.to_json
end
Content Security Policy:
class Application < Sinatra::Base
before do
headers 'Content-Security-Policy' => [
"default-src 'self'",
"script-src 'self' https://cdn.example.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'"
].join('; ')
end
end
SQL Injection Prevention
Parameterized Queries:
# Sequel
# BAD
DB["SELECT * FROM users WHERE name = '#{name}'"]
# GOOD
DB["SELECT * FROM users WHERE name = ?", name]
DB["SELECT * FROM users WHERE name = :name", name: name]
# ActiveRecord
# BAD
User.where("email = '#{email}'")
# GOOD
User.where(email: email)
User.where("email = ?", email)
User.where("email = :email", email: email)
Input Validation:
helpers do
def validate_sql_param(param, type: :string)
case type
when :integer
Integer(param)
when :boolean
[true, 'true', '1', 1].include?(param)
when :string
param.to_s.gsub(/['";\\]/, '') # Remove dangerous chars
else
param
end
rescue ArgumentError
halt 400, 'Invalid parameter'
end
end
get '/users/:id' do
id = validate_sql_param(params[:id], type: :integer)
user = User.find(id)
json user.to_hash
end
Authentication Patterns
Password Authentication:
require 'bcrypt'
class User
include BCrypt
def password=(new_password)
@password_hash = Password.create(new_password)
end
def password_hash
@password_hash
end
def authenticate(password)
Password.new(password_hash) == password
end
end
# Registration
post '/register' do
user = User.new(
email: params[:email],
name: params[:name]
)
user.password = params[:password]
user.save
session[:user_id] = user.id
redirect '/dashboard'
end
# Login
post '/login' do
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
session[:logged_in_at] = Time.now.to_i
redirect '/dashboard'
else
halt 401, 'Invalid credentials'
end
end
Token-Based Authentication:
require 'jwt'
class TokenAuth
SECRET = ENV['JWT_SECRET']
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET, 'HS256')
end
def self.decode(token)
body = JWT.decode(token, SECRET, true, algorithm: 'HS256')[0]
HashWithIndifferentAccess.new(body)
rescue JWT::DecodeError, JWT::ExpiredSignature
nil
end
end
# Middleware
class JWTAuth
def initialize(app)
@app = app
end
def call(env)
auth_header = env['HTTP_AUTHORIZATION']
token = auth_header&.split(' ')&.last
if payload = TokenAuth.decode(token)
env['current_user_id'] = payload[:user_id]
@app.call(env)
else
[401, { 'Content-Type' => 'application/json' },
['{"error": "Unauthorized"}']]
end
end
end
# Login endpoint
post '/api/login' do
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = TokenAuth.encode(user_id: user.id)
json({ token: token, user: user.to_hash })
else
halt 401, json({ error: 'Invalid credentials' })
end
end
# Protected routes
class API < Sinatra::Base
use JWTAuth
helpers do
def current_user
@current_user ||= User.find(request.env['current_user_id'])
end
end
get '/profile' do
json current_user.to_hash
end
end
API Key Authentication:
class APIKeyAuth
def initialize(app)
@app = app
end
def call(env)
api_key = env['HTTP_X_API_KEY']
if valid_api_key?(api_key)
user = User.find_by(api_key: api_key)
env['current_user'] = user
@app.call(env)
else
[401, { 'Content-Type' => 'application/json' },
['{"error": "Invalid API key"}']]
end
end
private
def valid_api_key?(key)
key && User.exists?(api_key: key, active: true)
end
end
use APIKeyAuth
# Generate API keys
helpers do
def generate_api_key
SecureRandom.hex(32)
end
end
post '/api/keys' do
authenticate!
api_key = generate_api_key
current_user.update(api_key: api_key)
json({ api_key: api_key })
end
Authorization Patterns
Role-Based Access Control:
class User
ROLES = [:guest, :user, :admin, :superadmin]
def has_role?(role)
ROLES.index(self.role) >= ROLES.index(role)
end
def can?(action, resource)
case role
when :admin, :superadmin
true
when :user
action == :read || resource.user_id == id
else
action == :read
end
end
end
helpers do
def authorize!(action, resource)
unless current_user&.can?(action, resource)
halt 403, json({ error: 'Forbidden' })
end
end
end
# Usage
get '/posts/:id' do
post = Post.find(params[:id])
authorize!(:read, post)
json post.to_hash
end
delete '/posts/:id' do
post = Post.find(params[:id])
authorize!(:delete, post)
post.destroy
status 204
end
Permission-Based Authorization:
class Permission
ACTIONS = {
posts: [:create, :read, :update, :delete],
users: [:read, :update, :delete],
comments: [:create, :read, :delete]
}
def self.check(user, action, resource_type)
return false unless user
permissions = user.permissions
permissions.include?("#{resource_type}:#{action}") ||
permissions.include?("#{resource_type}:*") ||
permissions.include?("*:*")
end
end
helpers do
def can?(action, resource_type)
Permission.check(current_user, action, resource_type)
end
def authorize!(action, resource_type)
unless can?(action, resource_type)
halt 403, json({ error: 'Forbidden' })
end
end
end
post '/posts' do
authorize!(:create, :posts)
# Create post
end
Rate Limiting
Using Rack::Attack:
require 'rack/attack'
class Rack::Attack
# Throttle login attempts
throttle('login/ip', limit: 5, period: 60) do |req|
req.ip if req.path == '/login' && req.post?
end
# Throttle API requests by API key
throttle('api/key', limit: 100, period: 60) do |req|
req.env['HTTP_X_API_KEY'] if req.path.start_with?('/api')
end
# Throttle by IP
throttle('req/ip', limit: 300, period: 60) do |req|
req.ip
end
# Block known bad actors
blocklist('block bad IPs') do |req|
BadIP.blocked?(req.ip)
end
# Custom response
self.throttled_responder = lambda do |env|
[
429,
{ 'Content-Type' => 'application/json' },
[{ error: 'Rate limit exceeded' }.to_json]
]
end
end
use Rack::Attack
Secure File Uploads
require 'securerandom'
class FileUploadHandler
ALLOWED_TYPES = {
'image/jpeg' => '.jpg',
'image/png' => '.png',
'image/gif' => '.gif',
'application/pdf' => '.pdf'
}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def self.process(file)
# Validate file presence
return { error: 'No file provided' } unless file
# Validate file size
if file[:tempfile].size > MAX_SIZE
return { error: 'File too large' }
end
# Validate content type
content_type = file[:type]
unless ALLOWED_TYPES.key?(content_type)
return { error: 'Invalid file type' }
end
# Sanitize filename
original_name = File.basename(file[:filename])
sanitized_name = original_name.gsub(/[^a-zA-Z0-9\._-]/, '')
# Generate unique filename
extension = ALLOWED_TYPES[content_type]
unique_name = "#{SecureRandom.hex(16)}#{extension}"
# Save file
upload_dir = 'uploads'
FileUtils.mkdir_p(upload_dir)
path = File.join(upload_dir, unique_name)
File.open(path, 'wb') do |f|
f.write(file[:tempfile].read)
end
{ success: true, path: path, filename: unique_name }
end
end
post '/upload' do
result = FileUploadHandler.process(params[:file])
if result[:error]
halt 400, json({ error: result[:error] })
else
json({ url: "/uploads/#{result[:filename]}" })
end
end
Tier 3: Resources & Examples
Security Headers
Comprehensive Security Headers:
class SecurityHeaders
HEADERS = {
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains'
}
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
headers.merge!(HEADERS)
[status, headers, body]
end
end
use SecurityHeaders
OWASP Security Checklist
See assets/owasp-checklist.md for complete checklist covering:
Injection Prevention
- SQL Injection
- Command Injection
- LDAP Injection
- XML Injection
Broken Authentication
- Password policies
- Session management
- Multi-factor authentication
- Account lockout
Sensitive Data Exposure
- Encryption at rest
- Encryption in transit (HTTPS)
- Secure key storage
- Data minimization
XML External Entities (XXE)
- XML parser configuration
- Disable external entity processing
Broken Access Control
- Authentication on all protected routes
- Authorization checks
- IDOR prevention
- CORS configuration
Security Misconfiguration
- Remove default credentials
- Disable directory listing
- Error message handling
- Keep dependencies updated
Cross-Site Scripting (XSS)
- Output encoding
- Input validation
- Content Security Policy
- HTTPOnly cookies
Insecure Deserialization
- Validate serialized data
- Use safe serialization formats
- Sign serialized data
Using Components with Known Vulnerabilities
- Regular dependency updates
- Security audits (bundle audit)
- Monitor CVE databases
Insufficient Logging & Monitoring
- Log security events
- Monitor for attacks
- Alerting systems
- Log rotation and retention
Security Testing Examples
Testing Authentication:
RSpec.describe 'Authentication' do
describe 'POST /login' do
let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
it 'succeeds with valid credentials' do
post '/login', { email: 'test@example.com', password: 'password123' }.to_json,
'CONTENT_TYPE' => 'application/json'
expect(last_response).to be_ok
expect(json_response).to have_key('token')
end
it 'fails with invalid password' do
post '/login', { email: 'test@example.com', password: 'wrong' }.to_json,
'CONTENT_TYPE' => 'application/json'
expect(last_response.status).to eq(401)
end
it 'prevents brute force attacks' do
6.times do
post '/login', { email: 'test@example.com', password: 'wrong' }.to_json,
'CONTENT_TYPE' => 'application/json'
end
expect(last_response.status).to eq(429) # Rate limited
end
end
end
Testing Authorization:
RSpec.describe 'Authorization' do
let(:user) { create(:user) }
let(:admin) { create(:user, role: :admin) }
let(:post) { create(:post, user: user) }
describe 'DELETE /posts/:id' do
it 'allows owner to delete' do
delete "/posts/#{post.id}", {}, auth_header(user.token)
expect(last_response.status).to eq(204)
end
it 'allows admin to delete' do
delete "/posts/#{post.id}", {}, auth_header(admin.token)
expect(last_response.status).to eq(204)
end
it 'denies other users' do
other_user = create(:user)
delete "/posts/#{post.id}", {}, auth_header(other_user.token)
expect(last_response.status).to eq(403)
end
it 'requires authentication' do
delete "/posts/#{post.id}"
expect(last_response.status).to eq(401)
end
end
end
Additional Resources
- Security Middleware:
assets/security-middleware.rb - Authentication Patterns:
assets/auth-patterns.rb - OWASP Checklist:
assets/owasp-checklist.md - Security Audit Template:
references/security-audit-template.md - Penetration Testing Guide:
references/penetration-testing.md