Skip to content

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_protected instead of controller-level Strong Parameters. attr_accessible may indicate Rails 3 or earlier, or a compatibility gem: protected_attributes for Rails 4.x, or protected_attributes_continued for Rails 5.0-6.1. Check the Rails version and both gem names in Gemfile.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_params allowlist to the create/update actions but leaving a separate admin controller, Rake task, or API action that calls User.create(params[:user]) or to_unsafe_h directly - Strong Parameters is applied per action, not per model, so every entry point that touches the model needs its own permit() 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 includes role for convenience means the public-facing action also accepts role, even though only the privileged action was supposed to.
  • Replacing a call to an audited method such as promote_to_admin! with a direct user.update_column(:is_admin, true) to get past a validation or callback that was in the way - update_column writes 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])) for accepts_nested_attributes_for without 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.

Additional Resources