├── .devcontainer └── devcontainer.json ├── .github ├── CONTRIBUTING.md ├── dependabot.yml └── workflows │ └── ruby.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE ├── README.md ├── lib └── safe_query.rb ├── safe_query.gemspec └── spec ├── activerecord └── safe_query_spec.rb ├── database.yml ├── models └── user.rb └── spec_helper.rb /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/ruby 3 | { 4 | "name": "Ruby", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/ruby:0-3.1-bullseye" 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "ruby --version", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | - Fork repository 2 | - `bundle install` 3 | - Make your change, add tests, and ensure they are passing with `bundle exec rspec` 4 | - Open pull request 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "bundler" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | ruby-version: ['2.7', '3.0', '3.1', '3.2'] 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true 34 | - name: Run tests 35 | run: bundle exec rspec 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | spec/test.db 2 | Gemfile.lock 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # SafeQuery changelog 2 | 3 | ## Unreleased 4 | - Add your PR changelog line here 5 | 6 | ## 0.1.0 (2023-03-22) 7 | - Initial release 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | Don't be a jerk 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Peter Cai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SafeQuery 2 | 3 | Query things in ActiveRecord _safely_. 4 | 5 | ## Why 6 | 7 | To prevent unbounded resource consumption, we always want to limit how many rows can be returned from database fetches. 8 | 9 | Calls to `ActiveRecord::Relation#each` (without a LIMIT clause) are dangerous and should be avoided, because they can accidentally trigger an 10 | unpaginated database fetch for millions of rows, exhausting your web server or database resources. 11 | 12 | In the worst case, this can present a denial-of-service vulnerability, so exceptions to this rule should be carefully vetted. 13 | 14 | Worse, it's common to hit this problem only in production, because development environments seldom contain enough database rows to highlight the issue. 15 | This makes it easy to write code that seems to work well, but fails when operating on a database with more data. 16 | 17 | This gem raises an exception whenever you attempt to call `ActiveRecord::Relation#each` without a limit clause, giving you the opportunity to catch and fix 18 | this before any unsafe code hits production. 19 | 20 | ## How it works 21 | 22 | With this gem installed, Rails will throw an exception when you make an unsafe query. It will attempt to highlight the query and the code that triggered it: 23 | 24 | ![image](https://user-images.githubusercontent.com/222655/227005861-a9ab39cc-dfa9-4adc-8c30-e71bd2b73fb9.png) 25 | 26 | ## Compatibility: 27 | 28 | - Rails 5+ 29 | - Ruby 2.7+ 30 | - Postgres, MySQL, SQLite, maybe others (untested) 31 | 32 | ## Installation: 33 | 34 | Add to your gemfile: 35 | 36 | ``` 37 | gem 'safe_query', group: [:development, :test] 38 | ``` 39 | 40 | then `bundle install`. 41 | 42 | It's recommended to set `config.active_record.warn_on_records_fetched_greater_than` (available since Rails 5), so you have warnings 43 | whenever a query is returning more rows than expected, even when using this gem. 44 | For example, if your app is never supposed to have no more than 100 records per page, add to `config/environments/development.rb`: 45 | 46 | ```ruby 47 | config.active_record.warn_on_records_fetched_greater_than = 100 48 | ``` 49 | 50 | ## Example fixes: 51 | 52 | When SafeQuery catches a problem, you will commonly want to apply one of these fixes. This list is not exhaustive, and contributions are welcome. 53 | 54 | ### 1) Use `find_each` instead 55 | 56 | Sometimes the fix is as easy as changing 57 | 58 | ```ruby 59 | book.authors.each do |author| 60 | ``` 61 | 62 | to this: 63 | 64 | ```ruby 65 | book.authors.find_each do |author| 66 | ``` 67 | 68 | Sometimes this doesn't work: 69 | - For some reason you don't have an autoincrementing primary key ID for Rails to paginate on 70 | - You have a specific sort order and you want to maintain the sort order. `find_each` will sort by ID, which may not be what you want. 71 | 72 | In those cases, you may have to add some custom code to maintain your existing app behavior. But otherwise, you can 73 | use `find_each` or any other solution from the [ActiveRecord::Batches](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html) API. 74 | 75 | ### 2) Paginate your results 76 | 77 | Use your existing pagination solution, or look at adding [pagy](https://github.com/ddnexus/pagy), [kaminari](https://github.com/kaminari/kaminari), 78 | [will_paginate](https://github.com/mislav/will_paginate), etc to your app. 79 | 80 | ### 3) Add a `limit` clause 81 | 82 | Sometimes you are simply missing a limit clause in your query. This might be the case if you have an implied 83 | upper bound on the number of results enforced by the application elsewhere. SafeQuery will find cases where this limit isn't expressed in your queries, 84 | which might be a problem if your enforcement logic is flawed in some way. 85 | 86 | ### 4) Ignore the problem 87 | 88 | You can ignore this problem (and prevent SafeQuery from raising) by converting the relation to an array with `to_a` before you operate on it: 89 | 90 | ```ruby 91 | book.authors.each ... 92 | ``` 93 | 94 | to this: 95 | 96 | ```ruby 97 | book.authors.to_a.each ... 98 | ``` 99 | 100 | Obviously, you should only do this if you are sure that the number of records is bounded to a reasonable number somewhere else. 101 | 102 | # Contributing 103 | 104 | see [CONTRIBUTING.md](https://github.com/pcai/safe_query/blob/main/.github/CONTRIBUTING.md) 105 | -------------------------------------------------------------------------------- /lib/safe_query.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | class UnsafeQueryError < StandardError 3 | # skip ourselves in the backtrace so it ends in the user code that generated the issue 4 | def backtrace 5 | return @lines if @lines 6 | @lines = super 7 | @lines.shift if @lines.present? 8 | @lines 9 | end 10 | end 11 | 12 | class Relation 13 | module SafeQuery 14 | def each 15 | QueryRegistry.reset 16 | super 17 | 18 | query_to_check = QueryRegistry.queries.first.to_s 19 | 20 | unless query_to_check.blank? || query_to_check.upcase.include?("LIMIT ") || query_to_check.upcase.include?("IN ") 21 | raise UnsafeQueryError, "Detected a potentially dangerous #each iterator on an unpaginated query. " + 22 | "Perhaps you need to add pagination, a limit clause, or use the ActiveRecord::Batches methods. \n\n" + 23 | "To ignore this problem, or if it is a false positive, convert it to an array with ActiveRecord::Relation#to_a before iterating.\n\n" ++ 24 | "Potentially unpaginated query: \n\n #{query_to_check}" 25 | end 26 | end 27 | 28 | ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| 29 | QueryRegistry.queries << payload[:sql] 30 | end 31 | 32 | module QueryRegistry 33 | extend self 34 | 35 | def queries 36 | ActiveSupport::IsolatedExecutionState[:active_record_query_registry] ||= [] 37 | end 38 | 39 | def reset 40 | queries.clear 41 | end 42 | end 43 | end 44 | end 45 | end 46 | 47 | ActiveRecord::Relation.prepend ActiveRecord::Relation::SafeQuery 48 | -------------------------------------------------------------------------------- /safe_query.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding : utf-8 -*- 2 | lib = File.expand_path("../lib", __FILE__) 3 | $:.unshift lib unless $:.include? lib 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "safe_query" 7 | s.version = "0.1.0" 8 | s.authors = "Peter Cai" 9 | s.email = "hello@petercai.com" 10 | s.homepage = "https://github.com/pcai/safe_query" 11 | s.summary = "Safely query stuff in ActiveRecord" 12 | s.description = <<-EOF 13 | Helps developers avoid unsafe queries in ActiveRecord. This gem will raise an error 14 | when iterating over a relation that is potentially unpaginated. 15 | EOF 16 | s.required_ruby_version = '>= 2.7.0' 17 | 18 | s.license = 'MIT' 19 | 20 | s.add_dependency "activerecord", ">= 5.0", "< 8.0" 21 | s.add_dependency "activesupport", ">= 5.0", "< 8.0" 22 | 23 | s.add_development_dependency "rspec", "~> 3.12" 24 | s.add_development_dependency "sqlite3", "~> 1.7.1" 25 | 26 | s.metadata["rubygems_mfa_required"] = "true" 27 | 28 | s.files = Dir['CHANGELOG.md', 'LICENSE', 'README.md', 'lib/**/*.rb'] 29 | 30 | s.require_path = "lib" 31 | end 32 | -------------------------------------------------------------------------------- /spec/activerecord/safe_query_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ActiveRecord::Relation::SafeQuery do 4 | it "raises an error when iterating over a relation without a limit" do 5 | expect { User.all.each {} }.to raise_error(ActiveRecord::UnsafeQueryError) 6 | end 7 | 8 | it "does not raise an error when iterating over a relation with a limit" do 9 | expect { User.limit(1).each {} }.to_not raise_error 10 | end 11 | 12 | it "does not raise an error when iterating over a relation with an in clause" do 13 | expect { User.where(id: [1, 2, 3]).each {} }.to_not raise_error 14 | end 15 | 16 | it "does not raise an error when iterating over a relation with an in clause and a limit" do 17 | expect { User.where(id: [1, 2, 3]).limit(1).each {} }.to_not raise_error 18 | end 19 | 20 | it "does not raise an error when iterating over a relation converted to an array" do 21 | expect { User.all.to_a.each {} }.to_not raise_error 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/database.yml: -------------------------------------------------------------------------------- 1 | adapter: 'sqlite3' 2 | database: 'spec/test.db' 3 | -------------------------------------------------------------------------------- /spec/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler.setup(:default, :development) 3 | 4 | require "active_support/all" 5 | require "active_record" 6 | require "safe_query" 7 | 8 | ActiveRecord::Base.establish_connection YAML::load(File.open('spec/database.yml')) 9 | ActiveRecord::Base.connection.execute "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT, created_at DATETIME, updated_at DATETIME)" 10 | 11 | require "models/user" 12 | --------------------------------------------------------------------------------