Files
2025-11-29 18:28:07 +08:00

16 KiB

name, description
name description
sinatra-security 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:

  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:

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