CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - Ruby
Overview
Mass assignment vulnerabilities in Ruby on Rails occur when Rails automatically assigns request parameters to model attributes, allowing attackers to modify security-critical fields like is_admin, role, or balance. This guidance targets modern Rails; legacy Rails patterns are called out explicitly below. Always use Strong Parameters to allowlist permitted attributes, never use update with unfiltered params, and validate all input.
Primary Defence: Use Strong Parameters (params.require().permit()) to allowlist only user-modifiable attributes, never use permit!, and validate with ActiveModel validations.
Defense-in-depth: CWE-915 (mass assignment) controls which attributes can be set (e.g., using
permit(:email, :username)to exclude:is_admin), while ActiveModel validations validate the values of allowed attributes (e.g., usingvalidates :email, format: { with: URI::MailTo::EMAIL_REGEXP }). Both protections are essential.
Common Vulnerable Patterns
Using permit! (Permits All Attributes)
# VULNERABLE - permit! allows all parameters
class UsersController < ApplicationController
def create
# Permits ALL parameters - extremely dangerous!
@user = User.create(params.require(:user).permit!)
if @user.persisted?
render json: @user, status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
end
# Attack: POST /users
# { "user": { "username": "attacker", "email": "attacker@evil.com", "is_admin": true, "balance": 999999 } }
Why this is vulnerable:
permit!disables Strong Parameters protection entirely- All request parameters are allowed to be mass-assigned
- Attackers can set
is_admin,balance, or any other model attribute - Enables privilege escalation and data manipulation
- Equivalent to having no protection at all
Using update with Unfiltered Parameters
# VULNERABLE - Direct parameter passing to update
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
# Passes raw params - no filtering!
if @user.update(params[:user].to_unsafe_h)
render json: @user
else
render json: @user.errors, status: :unprocessable_entity
end
end
end
# Attack: PATCH /users/123
# { "user": { "email": "new@example.com", "is_admin": true, "balance": 500000 } }
Why this is vulnerable:
- Rails 4+ requires Strong Parameters, but
to_unsafe_hbypasses the allowlist - Unfiltered params allow any attribute in the payload to be assigned
- No allowlist of permitted attributes
- All attributes in params hash get assigned to model
attr_accessible (Legacy, Rails 3.x Only)
# VULNERABLE - legacy attr_accessible is deprecated and may have gaps
class User < ApplicationRecord
attr_accessible :username, :email # Old Rails 3.x style
# is_admin is protected, but this mechanism is outdated
end
# Note: This pattern is legacy-only and should not be used in modern Rails (4.0+)
Why this is vulnerable:
attr_accessiblewas deprecated in Rails 4.0 (2013) and removed from modern Rails- Protection is at model level, not controller level (less flexible and easy to misconfigure)
- Easy to forget to update when adding new fields
- Modern Rails applications should use Strong Parameters exclusively
Direct Hash Assignment
# VULNERABLE - Directly assigning params to attributes
class UsersController < ApplicationController
def create
@user = User.new
# Assigns all params[:user] attributes directly!
params[:user].each do |key, value|
@user.send("#{key}=", value)
end
@user.save
render json: @user
end
end
# Attack: Sets any attribute via send()
Why this is vulnerable:
send()calls setter methods dynamically based on user input- No validation of which attributes should be settable
- Bypasses all mass assignment protection
- Can set any attribute including security-critical ones
Serialization Without Attribute Filtering
# VULNERABLE - ActiveModel::Serializers without attribute control
class UserSerializer < ActiveModel::Serializer
attributes :id, :username, :email, :is_admin, :balance # Exposes sensitive fields!
end
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
# If using assigns_attributes with a deserializer, can be vulnerable
@user.assign_attributes(deserialize_user(params[:user]))
@user.save
render json: @user, serializer: UserSerializer
end
end
Why this is vulnerable:
- Serializer exposes fields that shouldn't be visible
- If deserialization maps back to these fields, mass assignment possible
- Information disclosure (exposing
is_admin,balance) - Can lead to exploitation if client-side data is re-submitted
Secure Patterns
Use Strong Parameters (Required)
# SECURE - Strong Parameters with explicit allowlist
class UsersController < ApplicationController
def create
# Only permit specific, safe attributes
@user = User.new(user_params)
# Set secure defaults for protected attributes
@user.is_admin = false
@user.balance = 0
@user.created_at = Time.current
if @user.save
render json: user_response(@user), status: :created
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def user_params
# Explicit allowlist - ONLY these attributes can be mass-assigned
params.require(:user).permit(:username, :email, :password, :password_confirmation)
end
def user_response(user)
# Only return safe attributes
user.slice(:id, :username, :email, :created_at)
end
end
Why this works:
- Explicit allowlist:
permit(:username, :email, :password)only allows these specific attributes - Strong Parameters enforcement: Rails filters unpermitted params (or raises when configured to do so)
- Secure defaults: Code explicitly sets
is_admin,balanceto safe values - No permit!: Never uses
permit!which would disable protection - Filtered response: Only returns safe attributes to client
- Clear intent: Allowlist documents exactly which fields are user-controllable
Separate Permitted Parameters for Different Actions
# SECURE - Different parameter allowlists for different actions
class UsersController < ApplicationController
before_action :authenticate_user!
before_action :set_user, only: [:update_profile, :update_email, :update_role]
before_action :authorize_admin!, only: [:update_role]
def update_profile
if @user.update(profile_params)
render json: @user.slice(:id, :display_name, :bio, :website)
else
render json: @user.errors, status: :unprocessable_entity
end
end
def update_email
# Require password verification for email changes
unless @user.authenticate(params[:user][:current_password])
render json: { error: 'Invalid password' }, status: :unauthorized
return
end
if @user.update(email_params)
@user.update(email_verified: false) # Require re-verification
# Send verification email...
render json: { message: 'Email updated, verification required' }
else
render json: @user.errors, status: :unprocessable_entity
end
end
def update_role
# Audit role changes
AuditLog.create(
user_id: @user.id,
action: 'role_change',
new_role: params[:user][:role],
reason: params[:user][:reason],
changed_by: current_user.id
)
if @user.update(role_params)
render json: @user.slice(:id, :username, :role)
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def set_user
@user = User.find(params[:id])
# Users can only update their own profile (except admins for roles)
unless @user == current_user || action_name == 'update_role'
render json: { error: 'Forbidden' }, status: :forbidden
end
end
def profile_params
params.require(:user).permit(:display_name, :bio, :website)
end
def email_params
params.require(:user).permit(:email)
end
def role_params
params.require(:user).permit(:role)
end
def authorize_admin!
render json: { error: 'Admin access required' }, status: :forbidden unless current_user.admin?
end
end
Why this works:
- Operation-specific allowlists: Each action has its own
permit()method with minimal fields - Minimal exposure: Profile updates can't change email, email updates can't change role
- Authorization layers: Regular users update profiles, email changes require password, role changes require admin
- Re-authentication: Email updates require password verification
- Audit trail: Role changes are logged with reason and actor
- Access control:
before_actionfilters enforce authorization
Nested Attributes with accept_nested_attributes_for
# SECURE - Nested attributes with Strong Parameters
class User < ApplicationRecord
has_many :addresses
accepts_nested_attributes_for :addresses, allow_destroy: true
end
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
if @user.update(user_params_with_addresses)
render json: @user, include: :addresses
else
render json: @user.errors, status: :unprocessable_entity
end
end
private
def user_params_with_addresses
params.require(:user).permit(
:username,
:email,
addresses_attributes: [:id, :street, :city, :state, :zip, :_destroy]
)
# Note: :id is needed for updating existing addresses
# :_destroy is for delete operations
end
end
# Request: PATCH /users/123
# {
# "user": {
# "email": "newemail@example.com",
# "addresses_attributes": [
# { "id": 1, "street": "123 Main St", "city": "Anytown" },
# { "street": "456 Oak Ave", "city": "Other" } # New address
# ]
# }
# }
Why this works:
- Nested permit:
addresses_attributes: [...]explicitly permits nested parameters - Controlled nesting: Only specified nested attributes are allowed
- Update safety: Existing addresses require
:id, new addresses don't have:id - Destroy protection:
_destroymust be explicitly permitted - Type safety: Rails validates nested structure
Using slice for Dynamic Parameter Filtering
# SECURE - slice for additional parameter filtering
class UsersController < ApplicationController
def bulk_update
users_to_update = params[:users].map do |user_params|
user = User.find(user_params[:id])
# Use slice to extract only allowed keys
allowed_updates = user_params.slice(:email, :display_name, :bio)
user.update(allowed_updates)
user
end
render json: users_to_update
end
end
Why this works:
- Hash filtering:
slice()extracts only specified keys - Defense-in-depth: Works even if Strong Parameters bypassed
- Explicit allowlist: Clear which fields can be updated
- Bulk operation safety: Applies filtering to each item in batch
Custom Attribute Assignment with Validation
# SECURE - Custom setter methods with validation
class User < ApplicationRecord
attr_readonly :is_admin, :balance # Prevent mass assignment
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, presence: true, length: { minimum: 3, maximum: 50 }
# Custom method for admin promotion (requires admin authorization)
def promote_to_admin!(promoted_by:, reason:)
raise SecurityError, 'Already admin' if is_admin?
AuditLog.create(
user_id: id,
action: 'promote_to_admin',
promoted_by: promoted_by.id,
reason: reason
)
update_column(:is_admin, true) # Bypass mass assignment protection
end
# Custom method for balance updates (requires transaction authorization)
def adjust_balance(amount:, transaction_type:, authorized_by:)
raise ArgumentError, 'Invalid amount' unless amount.is_a?(Numeric)
Transaction.create!(
user_id: id,
amount: amount,
transaction_type: transaction_type,
authorized_by: authorized_by
)
increment!(:balance, amount)
end
end
class AdminController < ApplicationController
before_action :require_admin!
def promote_user
user = User.find(params[:id])
user.promote_to_admin!(
promoted_by: current_user,
reason: params[:reason]
)
render json: { message: 'User promoted to admin' }
end
end
Why this works:
- attr_readonly: Prevents updates to critical fields after creation (not a substitute for Strong Parameters)
- Custom methods: Sensitive operations require explicit method calls
- Authorization: Methods require appropriate permissions
- Audit trail: Changes are logged with actor and reason
- Validation: Business rules enforced at model level
- No accidental assignment: Can't accidentally mass-assign sensitive fields
Form Objects for Complex Operations
# SECURE - Form objects encapsulate complex parameter handling
class UserRegistrationForm
include ActiveModel::Model
attr_accessor :username, :email, :password, :password_confirmation, :terms_accepted
validates :username, presence: true, length: { minimum: 3, maximum: 50 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, presence: true, length: { minimum: 8 }
validates :password_confirmation, presence: true
validates :terms_accepted, acceptance: true
validate :password_match
def save
return false unless valid?
user = User.create!(
username: username,
email: email,
password: password,
is_admin: false, # Explicit secure default
balance: 0,
email_verified: false
)
# Send verification email, etc.
user
end
private
def password_match
errors.add(:password_confirmation, 'must match password') if password != password_confirmation
end
end
class RegistrationsController < ApplicationController
def create
form = UserRegistrationForm.new(registration_params)
if form.save
render json: { message: 'Registration successful' }, status: :created
else
render json: form.errors, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:registration).permit(
:username, :email, :password, :password_confirmation, :terms_accepted
)
end
end
Why this works:
- Encapsulation: Form object handles all registration logic
- Explicit fields: Only form object attributes can be set
- Validation centralized: All validation in one place
- Secure defaults: Explicit control over entity creation
- Separation of concerns: Form logic separate from model
- Business logic: Complex workflows handled safely