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 (4.0+), where Strong Parameters is the framework-enforced default.
Primary Defence: Use Strong Parameters (params.require().permit()) to allowlist only user-modifiable attributes, never permit! or to_unsafe_h, 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.,validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }). Both protections are essential.
Common Vulnerable Patterns
Using permit! (Permits All Attributes)
# VULNERABLE - permit! disables Strong Parameters entirely
class UsersController < ApplicationController
def create
@user = User.create(params.require(:user).permit!)
render json: @user, status: :created
end
end
# Attack: POST /users
# { "user": { "username": "attacker", "is_admin": true, "balance": 999999 } }
Why this is vulnerable: permit! disables Strong Parameters protection for that hash entirely - every request parameter is allowed to be mass-assigned.
Using update with Unfiltered Parameters
# VULNERABLE - to_unsafe_h bypasses the Strong Parameters allowlist
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
@user.update(params[:user].to_unsafe_h)
render json: @user
end
end
# Attack: PATCH /users/123
# { "user": { "email": "new@example.com", "is_admin": true, "balance": 500000 } }
Why this is vulnerable: to_unsafe_h converts ActionController::Parameters back into a plain, unfiltered hash, so every attribute in the payload is assigned to the model.
Direct Attribute Assignment with send()
# VULNERABLE - send() calls any setter method by user-supplied name
class UsersController < ApplicationController
def create
@user = User.new
params[:user].each do |key, value|
@user.send("#{key}=", value) # No allowlist of which attributes are settable
end
@user.save
render json: @user
end
end
Why this is vulnerable: send() dynamically calls a setter method named by user input, bypassing Strong Parameters entirely - this is the canonical CWE-915 pattern and can set any attribute, including is_admin or role.
Legacy note: Pre-4.0 Rails used model-level
attr_accessible/attr_protectedinstead of controller-level Strong Parameters.attr_accessiblemay indicate Rails 3 or earlier, or a compatibility gem:protected_attributesfor Rails 4.x, orprotected_attributes_continuedfor Rails 5.0-6.1. Check the Rails version and both gem names inGemfile.lock. Migrate the protections to Strong Parameters before removing the compatibility gem.
Secure Patterns
Strong Parameters (Required)
# SECURE - Strong Parameters with an explicit allowlist
class UsersController < ApplicationController
def create
@user = User.new(user_params)
@user.is_admin = false # Explicit secure defaults
@user.balance = 0
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)
user.slice(:id, :username, :email, :created_at)
end
end
Why this works: permit(:username, :email, :password, :password_confirmation) is an explicit allowlist - Rails filters out any parameter not on the list before it reaches User.new. An attacker sending is_admin=1 has that field silently dropped rather than assigned.
Separate Permitted Parameters per Action
# SECURE - each action has its own, minimal allowlist
class UsersController < ApplicationController
before_action :authenticate_user!
before_action :authorize_admin!, only: [:update_role]
def update_profile
current_user.update(profile_params)
render json: current_user.slice(:id, :display_name, :bio)
end
def update_role
target = User.find(params[:id])
target.update(role_params)
render json: target.slice(:id, :username, :role)
end
private
def profile_params
params.require(:user).permit(:display_name, :bio, :website)
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: Each action defines its own minimal permit() list, so a profile-update endpoint has no path to change role and vice versa. Combined with an authorization check (authorize_admin!) on the sensitive action, this limits both which attributes an endpoint can touch and who can call it.
Explicit Methods for Privileged Changes
# SECURE - the only route to is_admin is a named, audited, authorized method
class User < ApplicationRecord
# created_by_id is written once at insert and ignored by every later UPDATE.
# That is what attr_readonly does - see the note below on why it is not the
# control for is_admin.
attr_readonly :created_by_id
def promote_to_admin!(promoted_by:, reason:)
return false if is_admin?
transaction do
AuditLog.create!(user_id: id, action: 'promote_to_admin',
promoted_by: promoted_by.id, reason: reason)
update!(is_admin: true)
end
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: is_admin appears in no permit() list anywhere in the application, so no controller action can mass-assign it. The only code that writes it is promote_to_admin!, which is gated by require_admin! and records who did it and why in the same transaction as the change - so an audit row exists for every promotion, and a failed audit write rolls the promotion back.
attr_readonly is worth knowing about but is not the control here, and it is easy to reach for on the strength of its name. Rails' documented behaviour is that a readonly attribute is written when the record is created and is left out of every subsequent UPDATE; since Rails 7.1, assigning one on a persisted record raises. So it protects an immutable stamp - created_by_id, account_number - and does nothing about the over-posting case this CWE is mostly about, which is a create call. Adding attr_readonly :is_admin would also break the promotion path outright rather than securing it: update raises ActiveRecord::ReadonlyAttributeError on the assignment, and update_column/update_columns raise ActiveRecord::ActiveRecordError before issuing the statement, so there would be no supported way left to promote anyone.
Testing
- Normal input: create and update records using only allowed attributes and confirm expected persistence.
- Boundary input: submit unknown attributes, nested hashes, and empty values, and confirm behavior is consistent.
- Malicious input: include
is_admin,role,balance, or an ownership attribute; confirm Strong Parameters rejects or ignores them. - Re-scan with the security scanner to confirm the finding is resolved.
Common Pitfalls
- Adding a scoped
user_paramsallowlist to thecreate/updateactions but leaving a separate admin controller, Rake task, or API action that callsUser.create(params[:user])orto_unsafe_hdirectly - Strong Parameters is applied per action, not per model, so every entry point that touches the model needs its ownpermit()call. - Reusing one broad
permit(:username, :email, :role)method across both the public profile-update action and a privileged role-assignment action, instead of defining a separate, narrower allowlist per action - a single shared allowlist that includesrolefor convenience means the public-facing action also acceptsrole, even though only the privileged action was supposed to. - Replacing a call to an audited method such as
promote_to_admin!with a directuser.update_column(:is_admin, true)to get past a validation or callback that was in the way -update_columnwrites the column with no validations, no callbacks, and no audit row, so the attribute changes with nothing recording who changed it. The absence of a model-level allowlist in modern Rails means the audited method is the whole control; a call that goes around it removes the control entirely. - Permitting a nested attribute hash (e.g.,
params.require(:user).permit(profile_attributes: [:bio, :role])) foraccepts_nested_attributes_forwithout checking that the nested allowlist itself excludes security-critical fields - Strong Parameters must be applied recursively to nested attributes, not just the top-level hash.