# SECURE: Parameterized queries with validation
require 'pg'
require 'bcrypt'
class TransactionService
VALID_SORT_FIELDS = %w[transaction_date amount description status].freeze
VALID_SORT_DIRECTIONS = %w[ASC DESC].freeze
def initialize
@conn = PG.connect(
host: ENV['DB_HOST'],
dbname: ENV['DB_NAME'],
user: ENV['DB_USER'],
password: ENV['DB_PASSWORD']
)
prepare_statements
end
def prepare_statements
# Prepare frequently used queries
@conn.prepare('find_user_by_username',
'SELECT id, username, email, role, password_hash ' +
'FROM users WHERE username = $1')
@conn.prepare('get_account_balance',
'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE')
@conn.prepare('update_account_balance',
'UPDATE accounts SET balance = balance + $1, ' +
'last_transaction = NOW() WHERE id = $2')
@conn.prepare('insert_transaction_log',
'INSERT INTO transaction_log (from_account, to_account, ' +
'amount, description, status, timestamp) ' +
'VALUES ($1, $2, $3, $4, $5, NOW())')
end
# Secure authentication
def authenticate(username, password)
# Validate username format
return nil unless username =~ /A[a-zA-Z0-9_]{3,30}z/
result = @conn.exec_prepared('find_user_by_username', [username])
if result.any?
user = result.first
# Verify password with bcrypt
if BCrypt::Password.new(user['password_hash']) == password
user.delete('password_hash')
return user
end
end
nil
end
# Secure transaction search with parameterized queries
def search_transactions(user_id, filters = {})
# Validate user_id
return [] unless user_id.to_i > 0
query = "SELECT t.id, t.amount, t.description, t.transaction_date, " +
"t.status, a.account_name, a.account_type " +
"FROM transactions t " +
"JOIN accounts a ON t.account_id = a.id " +
"WHERE a.user_id = $1"
params = [user_id.to_i]
param_count = 1
# Add filters with parameters
if filters[:date_from].present?
begin
date = Date.parse(filters[:date_from])
param_count += 1
query += " AND t.transaction_date >= $#{param_count}"
params << date
rescue Date::Error
# Invalid date, skip this filter
end
end
if filters[:date_to].present?
begin
date = Date.parse(filters[:date_to])
param_count += 1
query += " AND t.transaction_date <= $#{param_count}"
params << date
rescue Date::Error
# Invalid date, skip this filter
end
end
if filters[:amount_min].present? && filters[:amount_min].to_f > 0
param_count += 1
query += " AND t.amount >= $#{param_count}"
params << filters[:amount_min].to_f
end
if filters[:amount_max].present? && filters[:amount_max].to_f > 0
param_count += 1
query += " AND t.amount <= $#{param_count}"
params << filters[:amount_max].to_f
end
if filters[:description].present?
param_count += 1
query += " AND t.description ILIKE $#{param_count}"
params << "%#{filters[:description]}%"
end
# Validate and add sorting
sort_by = VALID_SORT_FIELDS.include?(filters[:sort_by]) ?
filters[:sort_by] : 'transaction_date'
sort_direction = VALID_SORT_DIRECTIONS.include?(filters[:direction]) ?
filters[:direction] : 'DESC'
query += " ORDER BY #{sort_by} #{sort_direction}"
query += " LIMIT 1000" # Prevent excessive results
@conn.exec_params(query, params).to_a
end
# Secure funds transfer with transaction
def transfer_funds(from_account, to_account, amount, description)
# Validate inputs
from_account_id = from_account.to_i
to_account_id = to_account.to_i
transfer_amount = amount.to_f
return false if from_account_id <= 0 || to_account_id <= 0
return false if transfer_amount <= 0 || transfer_amount > 1000000
return false if description.blank? || description.length > 500
begin
# Start transaction
@conn.exec('BEGIN')
# Check balance with row lock
balance_result = @conn.exec_prepared('get_account_balance',
[from_account_id])
if balance_result.none?
@conn.exec('ROLLBACK')
return false
end
current_balance = balance_result[0]['balance'].to_f
if current_balance < transfer_amount
@conn.exec('ROLLBACK')
return false
end
# Debit from account
@conn.exec_prepared('update_account_balance',
[-transfer_amount, from_account_id])
# Credit to account
@conn.exec_prepared('update_account_balance',
[transfer_amount, to_account_id])
# Log transaction
@conn.exec_prepared('insert_transaction_log',
[from_account_id, to_account_id,
transfer_amount, description, 'completed'])
# Commit transaction
@conn.exec('COMMIT')
true
rescue PG::Error => e
@conn.exec('ROLLBACK')
Rails.logger.error "Transfer failed: #{e.message}"
false
end
end
def close
@conn.close if @conn
end
end
# Secure Rails controller
class TransactionsController < ApplicationController
before_action :authenticate_user!
def search
service = TransactionService.new
# Sanitize and validate filters
filters = {
date_from: params[:date_from],
date_to: params[:date_to],
amount_min: params[:amount_min],
amount_max: params[:amount_max],
description: sanitize_search_term(params[:description]),
sort_by: params[:sort_by],
direction: params[:direction]
}.compact
@transactions = service.search_transactions(current_user.id, filters)
render json: @transactions
ensure
service&.close
end
def transfer
service = TransactionService.new
# Additional validation
from_account = params[:from_account]
to_account = params[:to_account]
amount = params[:amount].to_f
description = params[:description]
# Verify user owns the from_account
unless current_user.accounts.exists?(id: from_account)
render json: { error: 'Unauthorized' }, status: 403
return
end
success = service.transfer_funds(
from_account,
to_account,
amount,
description
)
if success
render json: { success: true }
else
render json: { error: 'Transfer failed' }, status: 400
end
ensure
service&.close
end
private
def sanitize_search_term(term)
return nil if term.blank?
# Remove potential SQL meta-characters
term.gsub(/[%_\]/, '')
end
end
# Alternative: Use ActiveRecord when possible
class Transaction < ApplicationRecord
belongs_to :account
scope :for_user, ->(user_id) {
joins(:account).where(accounts: { user_id: user_id })
}
scope :date_range, ->(from, to) {
where(transaction_date: from..to) if from && to
}
scope :amount_range, ->(min, max) {
scope = all
scope = scope.where('amount >= ?', min) if min
scope = scope.where('amount <= ?', max) if max
scope
}
def self.search(user_id, filters = {})
transactions = for_user(user_id)
transactions = transactions.date_range(filters[:date_from],
filters[:date_to])
transactions = transactions.amount_range(filters[:amount_min],
filters[:amount_max])
if filters[:description].present?
transactions = transactions.where('description ILIKE ?',
"%#{filters[:description]}%")
end
transactions.order(transaction_date: :desc).limit(1000)
end
end