Security Best Practices
SQL Injection Protection
QueryKit uses parameterized queries by default, which protects against SQL injection when used correctly. However, like any query builder, it’s possible to introduce vulnerabilities through misuse.
Safe Practices (Parameterized)
All standard query builder methods use parameterized queries automatically:
# SAFE - Values are parameterized
db.query('users').where('email', params[:email])
db.query('users').where('age', '>', params[:age])
db.query('users').where_in('id', params[:ids])
# SAFE - INSERT/UPDATE values are parameterized
db.insert('users').values(name: params[:name], email: params[:email])
db.update('users').set(name: params[:name]).where('id', params[:id])
# SAFE - Even with hash syntax
db.query('users').where(email: params[:email], status: params[:status])
Generated SQL: All values become ? placeholders with separate bindings array.
query = db.query('users').where('email', user_input)
query.to_sql # => "SELECT * FROM users WHERE email = ?"
query.bindings # => [user_input] # Safely escaped by database driver
Unsafe Practices (String Interpolation)
where_raw() with Interpolation
# DANGEROUS - Direct string interpolation
db.query('users').where_raw("email = '#{params[:email]}'")
# SAFE - Use placeholders with bindings
db.query('users').where_raw('email = ?', params[:email])
raw() with Interpolation
# DANGEROUS - SQL injection vulnerability
db.raw("SELECT * FROM users WHERE id = #{params[:id]}")
# SAFE - Use placeholders
db.raw('SELECT * FROM users WHERE id = ?', params[:id])
Column/Table Names from User Input
# DANGEROUS - Column names aren't parameterized
column = params[:sort_by] # User could pass "id; DROP TABLE users--"
db.query('users').order_by(column)
# SAFE - Whitelist allowed columns
allowed_columns = ['name', 'email', 'created_at']
column = allowed_columns.include?(params[:sort_by]) ? params[:sort_by] : 'id'
db.query('users').order_by(column)
Dynamic Table Names
# DANGEROUS
table = params[:table]
db.query(table).get
# SAFE - Use a whitelist
allowed_tables = ['users', 'posts', 'comments']
table = allowed_tables.include?(params[:table]) ? params[:table] : 'users'
db.query(table).get
Security Checklist
DO
- Always use parameterized queries for user input values
- Whitelist column/table names when accepting user input
- Validate input types before passing to queries
- Use bindings array with
raw()andwhere_raw() - Apply business logic validation before database queries
# Example: Safe dynamic filtering
def search_users(filters)
query = db.query('users')
# Whitelist allowed filters
if filters[:email]
query.where('email', filters[:email])
end
if filters[:status] && ['active', 'inactive'].include?(filters[:status])
query.where('status', filters[:status])
end
# Whitelist sort columns
sort_by = ['name', 'created_at'].include?(filters[:sort]) ? filters[:sort] : 'id'
query.order_by(sort_by)
db.get(query)
end
DON’T
- Never interpolate user input into SQL strings
- Don’t trust user input for column/table names
- Don’t skip validation because “it’s internal”
- Don’t use
eval()or similar with query strings - Don’t expose raw database errors to users (information disclosure)
# BAD - Multiple vulnerabilities
def bad_search(params)
# String interpolation vulnerability
condition = "email = '#{params[:email]}'"
# Unvalidated column name
sort = params[:sort_by]
# Vulnerable query
db.raw("SELECT * FROM users WHERE #{condition} ORDER BY #{sort}")
end
# GOOD - Safe implementation
def good_search(params)
query = db.query('users')
if params[:email]
query.where('email', params[:email]) # Parameterized
end
sort = ['name', 'email', 'id'].include?(params[:sort_by]) ? params[:sort_by] : 'id'
query.order_by(sort) # Whitelisted
db.get(query)
end
Common Attack Vectors
1. WHERE Clause Injection
# Attacker input: "' OR '1'='1"
# VULNERABLE
db.raw("SELECT * FROM users WHERE email = '#{params[:email]}'")
# Becomes: SELECT * FROM users WHERE email = '' OR '1'='1'
# SAFE
db.query('users').where('email', params[:email])
# Becomes: SELECT * FROM users WHERE email = ? with binding ["' OR '1'='1"]
2. UNION-based Injection
# Attacker input: "' UNION SELECT password FROM admin_users--"
# VULNERABLE
db.raw("SELECT * FROM users WHERE name = '#{params[:name]}'")
# SAFE
db.query('users').where('name', params[:name])
3. Second-Order Injection
# Data stored in database contains SQL
malicious_data = "'; DROP TABLE users--"
# VULNERABLE - Even though not direct user input
db.raw("SELECT * FROM logs WHERE message = '#{malicious_data}'")
# SAFE - Always parameterize, even for stored data
db.query('logs').where('message', malicious_data)
Repository Pattern Security
Repositories should validate input at the boundary:
class UserRepository < QueryKit::Repository
table 'users'
model User
# SAFE - Validates and whitelists
def search(filters)
query = where('status', 'active')
if filters[:email]&.match?(/\A[^@\s]+@[^@\s]+\z/) # Basic email validation
query = query.where('email', filters[:email])
end
if filters[:min_age]&.is_a?(Integer) && filters[:min_age] > 0
query = query.where('age', '>=', filters[:min_age])
end
query
end
# SAFE - Whitelists sort columns
def list_sorted(sort_by: 'id', direction: 'ASC')
allowed_sorts = ['id', 'name', 'email', 'created_at']
allowed_directions = ['ASC', 'DESC']
sort = allowed_sorts.include?(sort_by) ? sort_by : 'id'
dir = allowed_directions.include?(direction.upcase) ? direction.upcase : 'ASC'
all.order_by(sort, dir)
end
end
Framework Integration
Rails
# Controller
class UsersController < ApplicationController
def index
# Validate params with strong parameters
filters = params.permit(:email, :status, :sort_by)
@users = UserRepository.new.search(filters)
end
end
Sinatra
# Route
get '/users' do
# Validate and sanitize
email = params[:email]&.strip
status = ['active', 'inactive'].include?(params[:status]) ? params[:status] : nil
query = db.query('users')
query = query.where('email', email) if email
query = query.where('status', status) if status
json db.get(query)
end
Tools and Testing
Testing for SQL Injection
# In your tests
def test_sql_injection_protection
malicious_input = "'; DROP TABLE users--"
# Should not execute malicious SQL
results = db.query('users').where('email', malicious_input)
# Query should have placeholder
assert_includes results.to_sql, '?'
assert_equal [malicious_input], results.bindings
end
Static Analysis
Consider using:
- Brakeman - Rails security scanner
- bundler-audit - Check for vulnerable gems
- RuboCop - Lint for security issues
Summary
QueryKit is secure by default when you use the query builder methods. The main risks are:
- Using
raw()orwhere_raw()with string interpolation - Accepting user input for column/table names without whitelisting
- Not validating input types before queries
Golden Rule: If user input touches your query, use the query builder methods (which parameterize) or explicit placeholders with bindings. Never use string interpolation.