Agent skill
sinatra-security
Security best practices for Sinatra applications including input validation, CSRF protection, and authentication patterns. Use when hardening applications or conducting security reviews.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/security/sinatra-security-geoffjay-claude-plugins-7bc3662b
SKILL.md
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
Didn't find tool you were looking for?