Disclaimer: This security guide isn't intended to be exhaustive
Use this guide before each deployment, or, even better, use an automated process.
Definitions
- Never: Never means never
- Don't: Don't unless you have a really really good reason
- Avoid: Avoid unless you have a good reason
Gems
- Brakeman - A static analysis security vulnerability scanner for Ruby on Rails applications
- Rack::Attack!! - Rack middleware for blocking & throttling
- SecureHeaders - Security related headers all in one gem
- Sanitize - An allowlist-based HTML and CSS sanitizer
- zxcvbn - Devise plugin to reject weak passwords using zxcvbn
- StrongPassword - Entropy-based password strength checking for Ruby and Rails
- Pundit - Minimal authorization through OO design and pure Ruby classes
TOC
- Injections
- Cross-site Scripting (XSS)
- Authentication and Sessions
- Authorization
- Cross-Site Request Forgery
- Insecure Direct Object Reference or Forceful Browsing
- Redirects
- Files
- Cross-Origin Resource Sharing
- Data Leaking and Logging
- Misc
- Parameterize or serialize user input (including URL query params) before using it
- Don't pass strings as parameters to Active Records methods. Use arrays or hashes instead
- Never use user input directly when using the
delete_allmethod - Never use user input in system commands
- Avoid system commands
- Sanitize ALL hand-written SQL ActiveRecord Sanitization
# bad User.find_by("id = '#{params[:user_id]'") User.delete_all("id = #{params[:user_id]}") User.where(admin: false).group(params[:group]) User.where("name = '#{params[:name]'") # good User.find(id) User.find_by(id: params[:id]) User.find_by_id(params[:id].to_i) # better User.where({ name: params[:name] }) User.where(admin: false).group(:name) User.where("name LIKE ?", "#{params[:search]}%") User.where("name LIKE ?", User.sanitize_sql_like(params[:search]) + "%") By default, when string data is shown in views, it is escaped prior to being sent back to the browser.
- Never disable
ActiveSupport#escape_html_entities_in_json - Don't use
raw,html_safe,content_tag, or<%== - Prefer Markdown over HTML
- Validate and sanitize user input for Urls and Html (including classes or attributes)
- Never create templates in code (use ERB, Slim, Haml, etc)
- Never use
render inlineorrender text - Never use unquoted variables in HTML attribute
- Don't use template variables in script blocks
- Implement Content Security Policy or use SecureHeaders gem if below Rails v5.2
# bad config.action_view.escape_html_entities_in_json = false <%= raw @user.bio %> <%= @user.bio.html_safe %> <%= link_to "Personal Website", @user.personal_website %> <div class=<%= params[:css_class] %></div> <script>var name = <%= @user.name %>;</script> render inline: "<div>#{@user.name}</div>" # good sanitize(@user.bio, tags: %w(b br em i p strong), attributes: %w()) strip_tags("Strip <i>these</i> tags!") # => Strip these tags! strip_links('<a href="http://www.rubyonrails.org">Ruby on Rails</a>') # => Ruby on Rails validates :instagram, url: true, allow_blank: true # link_to("Instagram", @user.instagram) validates :color, hex_color: true # HexColorValidator # <div style="background-color: <% user.color %>"> - Use a database based session store
- Never put sensitive information in the session
- Set an expiration for the session (Limit: 30 minutes)
- Limit "Remember Me" functionality to 2 weeks
- The same timeline can be used for access & refresh tokens
- Set all cookies and session store as httponly and secure
- Revalidate cookie values
- Never store "state" in the session or a cookie
- Enforce password complexity (min length, no words, etc)
- Consider captcha on publicly available forms
- Consider captcha after several failed login attempts
- Always confirm user emails
- Require old password to change password (except for forgot password)
- Expire password reset tokens after 10 minutes
- Limit password reset emails within a specified timeframe
- Consider using two-factor authentication (2FA) (required if storing sensitive data)
- Don't use "Security Questions"
- Use generic error messages for failed login attempts (Email or password is invalid)
- add
before_action :authenticate_user!to ApplicationController andskip_before_action :authenticate_user!to publicly accessible controllers/actions.
# bad Rails.application.config.session_store :my_custom_store, expire_after: 2.years JWT.encode payload, nil, 'none' # good Rails.application.config.session_store :active_record_store, expire_after: 30.minutes, httponly: true, secure: true cookies[:login] = {value: "user", httponly: true, secure: true} JWT.encode({ data: 'data', exp: Time.now.to_i + 4 * 3600 }, hmac_secret, 'HS256') config.force_ssl = true - NEVER do authorization on the frontend
- Admin interface should be isolated from the user interface
- Use 2FA on the admin interface
- Don't use
accepts_nested_attributes_forfor permissions - Prefer policies over querying by association (current_user.posts)
- Always use policies if using multi-user accounts
# bad @posts = Post.where(user_id: params[:user_id]) @comment = Commend.find_by(id: params[:id]) accepts_nested_attributes_for :permission # good @posts = current_user.posts @posts = policy_scope(Post) @comment = current_user.comments.find_by(id: params[:id]) authorize @post - If you use cookie-based authentication anywhere, use
protect_from_forgery - If you use token-based authentication, you don't need
protect_from_forgery
# Newer versions of Rails use: config.action_controller.default_protect_from_forgery # Implementation class ApplicationController < ActionController::Base protect_from_forgery with: :exception rescue_from ActionController::InvalidAuthenticityToken do |exception| sign_out_user # destroy the user cookies end ...(rest of file)... end This is basically guessing ids in the path: https://example.com/user/10
- Use UUIDs, hashids, or a non-guessable id
- Avoid changing the default primary key (
id) - Policies can help mitigate this as well
- Don't let a user-supplied params to determine which view to render
- Don't show the numerical
idin an API call when using a uuid, hashid, etc
Stripe Customer ID = cus_9s6XFG2Qq6Fe7v # don't do this def show render params[:user_supplied_view] end - Avoid passing any user-supplied params into
redirect_to - If you must use user-supplied URLs for redirect_to... sanitize or use an allowlist
- Validate with regex using \A and \z as anchors, not ^ and $
- If your needs are complex, use Shopify's redirect_safely gem
# bad redirect_to params[:url] redirect_to URI.parse(params[:url]).path redirect_to URI.parse("#{params[:url]}").host redirect_to "https://yourwebsite.com/" + params[:url] # ok, but not good redirect_to "https://instagram.com/" + params[:ig_username] # good redirect_to user.redirect_url # sanitize beforehand redirect_to AllowList.include?(params[:url]) ? params[:url] : '/' - Avoid user-generated filenames (e.g ../../passwd), assign random names if possible
- Only allow alphanumeric, underscores, hyphens, and periods
- Don't process images or videos on your server
- Always (re)validate on the backend (file size, media type, name, etc.)
- Process media files asynchronously
- Use 3rd party scanners if necessary
- Prefer cloud storage services such as Amazon S3 to directly handle file uploads and storage
- Use rack-cors gem
- Unless your API is open to anyone, don't set wildcard as an origin.
# bad Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins'*' resource '*', headers: :any, methods: :any end end # good Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins' http://example.com:80' # regular expressions can be used here resource '*', headers: :any, methods: [:get, :post] end end Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins' http://example.com:80' resource '/orders', :headers => :any, :methods => [:post] resource'/users', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do if Rails.env.development? origins 'localhost:3000', 'localhost:3001', 'https://yourwebsite.com' else origins' https://yourwebsite.com' end resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end - NEVER commit credentials, passwords, or keys
- Use
config.filter_parametersfor sensitive data (passwords, tokens, etc) - Use
config.filter_redirectfor sensitive location you redirect to - Don't use 403 Forbidden for authorized errors (it implies the resource exists)
- Don't include implementation details in view comments
- Don't write your own encryption
- Encrypt sensitive data at the application layer
- Don't do this in routes
match ':controller(/:action(/:id(.:format)))" - Only use
httpsgem sources - Use blocks for more than one gem source
- Never set
config.consider_all_requests_local = truein production - Separate gems by environment
- Don't use development-related gems (better_errors) in public-facing environments
- Don't make non-action controller methods public
- Use
JSON.parseoverJSON.load - Keep dependencies up-to-date and watch for vulnerabilities
- Don't store credit card information
- Avoid user-supplied data in emails to other users
- Avoid user-created email templates (heavily sanitize or markdown if necessary)
- Use
_htmlfor I18n keys with HTML tags
Additional Resources