--- 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 ```ruby # Enable Rack::Protection use Rack::Protection # Or specifically CSRF use Rack::Protection::AuthenticityToken ``` ### XSS Prevention ```ruby # 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 ```ruby # 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 ```ruby use Rack::Session::Cookie, secret: ENV['SESSION_SECRET'], # Long random string same_site: :strict, httponly: true, secure: production? ``` ### Input Validation ```ruby 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 ```ruby 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:** ```ruby 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 "" 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:** ```erb
<%= csrf_tag %>
``` **For AJAX:** ```javascript // 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:** ```ruby # ERB - escape by default
<%= user_input %>
# Explicitly raw (only for trusted content)
<%== trusted_html %>
# Sanitize user HTML require 'sanitize' helpers do def sanitize_html(html) Sanitize.fragment(html, Sanitize::Config::RELAXED) end end # In template
<%= sanitize_html(user_bio) %>
``` **JSON Responses:** ```ruby # 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:** ```ruby 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:** ```ruby # 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:** ```ruby 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:** ```ruby 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:** ```ruby 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:** ```ruby 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:** ```ruby 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:** ```ruby 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:** ```ruby 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 ```ruby 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:** ```ruby 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: 1. **Injection Prevention** - SQL Injection - Command Injection - LDAP Injection - XML Injection 2. **Broken Authentication** - Password policies - Session management - Multi-factor authentication - Account lockout 3. **Sensitive Data Exposure** - Encryption at rest - Encryption in transit (HTTPS) - Secure key storage - Data minimization 4. **XML External Entities (XXE)** - XML parser configuration - Disable external entity processing 5. **Broken Access Control** - Authentication on all protected routes - Authorization checks - IDOR prevention - CORS configuration 6. **Security Misconfiguration** - Remove default credentials - Disable directory listing - Error message handling - Keep dependencies updated 7. **Cross-Site Scripting (XSS)** - Output encoding - Input validation - Content Security Policy - HTTPOnly cookies 8. **Insecure Deserialization** - Validate serialized data - Use safe serialization formats - Sign serialized data 9. **Using Components with Known Vulnerabilities** - Regular dependency updates - Security audits (bundle audit) - Monitor CVE databases 10. **Insufficient Logging & Monitoring** - Log security events - Monitor for attacks - Alerting systems - Log rotation and retention ### Security Testing Examples **Testing Authentication:** ```ruby 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:** ```ruby 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`