Studio Zenkai


Bold and Clear, without excess • Kaizen 改善 • With Purpose


Studio Zenkai is dedicated to the craft of programming, photography and living sustainably. About me


Securing a Rails endpoint

Here is a typical Rails endpoint:


def update_user
  user = User.find_by(id: params[:id])
  
  if user.update(params[:user])
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.full_messages }, status: :unprocessable_entity
  end
end

Readable, simple, error handling. But insecure. What can we do?

1. Strong Parameters

The endpoint allows mass assignement a User record.

Safe attributes prevents this by explicity enumerating attributes that can be safely updated.

def update_user
  user = User.find_by(id: params[:id])
  
  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.full_messages }, status: :unprocessable_entity
  end
end

def user_params
  params.require(:user).permit(:name, :email, :password)
end

Imagine a field titled salary or super_secret_fees, that are computed from work hours and company algorithms. Without safe attributes, the current endpoint can set a much higher salary!

2. Authenticate the User

The endpoint lacks user authentication. Any endpoint that change or delete database records must require some form of secure authentication

#   Ensures the user is logged in and valid.
before_action :authenticate_user!

def update_user
  user = current_user
  
  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.full_messages }, status: :unprocessable_entity
  end
end

Without authentication, a script can iterate to change all user records.

3. Authorize the Action

The user might be authentified, but it could be a user without the required role or authorization.

def update_user
  user = current_user
  authorize user

  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.full_messages }, status: :unprocessable_entity
  end
end

In this case, the user data should be editable by its owner, the current user. In other cases, you can also allow admins or account manager to administrate data, while still making sure policies are followed

4. Add Input Validation

The user might bypass the form and send in incorrect input to crash your application, have access to the db, ie look for epxloits.

Inputs should be validated before passing them on

def user_params
  params.require(:user).permit(:name, :email, :password).tap do |whitelisted|
    raise ArgumentError, "Invalid name" unless whitelisted[:name].match?(/\A[a-zA-Z]+\z/)
  end
end

An alternative to above is to use ActiveRecord validation, if the team also expects to update records through jobs or services.

5. Implement Rate Limiting

Brute force attacks are performed so your server or your database stop providing its service or leak sensitive information.

For example, if your service provides an essential service to the country’s citizen, a state-actor can brute force endpoints so normal users cannot use the service, thereby disrupting essential services.

A gem like Rack::Attack prevents brute force or abuse.

# in config/initializers/rack_attack.rb
Rack::Attack.throttle("requests by IP", limit: 5, period: 1.minute) do |req|
  req.ip if req.path == "/users/update" && req.post?
end

It is also possible to throttle requests by user, or other parts of the request header. If this is an issue, you can also add dedicated rate limiters in front your system.

6. Sanitize Inputs

In addition to input validation above, you can take steps so queries do not make the application fail, regardless of user inputs

Converting inputs to string such as below make sure the database doesn’t throw an exception.

Also using find_by will let Rails fail gracefully

def update_user
  user = User.find_by(id: params[:id].to_i)

  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.full_messages }, status: :unprocessable_entity
  end
end

7. Restricting verbiose outputs

user.errors.full_messages will tell users about business and data rules.

For example, it migth show all password rules, address requirements, invalid hostnames and more.

Send filtered error messages to prevent hackers knowing the limits of your system.

def update_user
  user = User.find_by(id: params[:id].to_i)

  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.map(&:code).map(&:codebook_to_en) }, status: :unprocessable_entity
  end
end

Here, a codebook will translate Rails errors to human-readable messages, while making sure system details are not communicated.

8. Implement CSRF Protection

Ensure CSRF tokens are verified for API requests.

# in ApplicationController
before_action :verify_authenticity_token

# Alternatively, for APIs:
before_action :verify_authenticity_token, unless: -> { request.format.json? }

9. Enforce HTTPS

non-HTTPS requests are not secure and should be rejected

# in ApplicationController
before_action :ensure_secure_connection

def ensure_secure_connection
  redirect_to protocol: "https://", status: :moved_permanently unless request.ssl?
end

10. Remove Sensitive Data from Logs

Log files are typically not as well secured as db, yet they might contain sensitive data. You might have a team member who made sent logs to Kibana ES and made them viewable without requiring credentials (ie security by obsfucation)

# in config/application.rb:
Rack::Attack.throttle("requests by IP", limit: 5, period: 1.minute) do |req|
  req.ip if req.path == "/users/update" && req.post?
end

Rails.application.config.filter_parameters += [:password, :credit_card]

No more passwords in log files!

Bonust Monitoring and alerts are not only for devops! Tools like Sentry, DataDog or New Relic allows you to detect unusual patterns such as excessive failed updates. As soon as an application is live, make sure to set these up.

Final Secured Endpoint:


# in config/application.rb:
Rails.application.config.filter_parameters += [:password, :credit_card]


before_action :verify_authenticity_token
before_action :authenticate_user!

def update_user
  user = User.find_by(id: params[:id].to_i)
  authorize user

  if user.update(user_params)
    render json: { success: true, message: "User updated successfully" }
  else
    render json: { success: false, errors: user.errors.map(&:code).map(&:codebook_to_en) }, status: :unprocessable_entity
  end
end

def user_params
  params.require(:user).permit(:name, :email, :password) do |whitelisted|
    raise ArgumentError, "Invalid name" unless whitelisted[:name].match?(/\A[a-zA-Z]+\z/)
  end
end