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; 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., using validates :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_h bypasses 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_accessible was 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, balance to 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_action filters 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: _destroy must 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

Additional Resources